WebFortID 2026
Ultimative PHP
Four independent PHP quirks (parameter-name mangling, array-to-string coercion, a double magic-hash collision, and a falsy strpos check) chain into a path traversal that reads the flag through an allow-listed unserialize gadget.
What this one teaches
- PHP rewrites a space, a dot, and a square bracket in a parameter *name* to an underscore when it builds
$_GET, so a blocklist that checks the raw query string foraccess_levelnever seesaccess.level, even though both populate$_GET['access_level']. - Converting an array to a string in PHP raises a non-fatal warning and produces the literal text
Array, so two different arrays concatenated into the same prefix hash identically even though the arrays themselves differ. - PHP's loose
==treats two strings as equal when both look numeric, and any hex digest that starts with0efollowed only by digits parses as scientific notation for zero, so two different0e...strings can be loosely equal while staying strictly unequal (!==). strpos($haystack, $needle)returns0, a falsy value, when the needle sits at the very start, so a plain truthiness check forstrpos(...)silently passes a string that starts with the substring being filtered; the fix is a strict=== falsecheck.- Restricting
unserialize()to an allow-list of classes only helps if none of those classes' magic methods (__toString,__wakeup,__destruct) have a side effect worth abusing.
Task description
The “Ultimative Identity Platform” exposes one final, all-in-one migration gateway. A single GET request must simultaneously satisfy four independent proofs: an administrator access assertion, two recovery answers with matching fingerprints, a dual-format compatibility code, and a base64url-encoded PHP profile export. The developers claim every individual check is secure. Passing all four in one request imports the profile and prints the flag.
Reading the gateway
The whole app is one file, src/index.php. Each of the four checks calls fail_request(), which exits immediately, so all four have to pass together in one request. The flag lives at /tmp/flag (with a /flag symlink), and the web root is /var/www/html. The four checks below follow from the source and PHP’s documented language semantics, with the hashing, encoding and path math recomputed directly.
Check 1: a blocklist on the raw query, a lookup on the parsed one
$rawQuery = $_SERVER['QUERY_STRING'] ?? '';
if (str_contains(rawurldecode($rawQuery), 'access_level')) {
fail_request('The access_level parameter is protected');
}
if (($_GET['access_level'] ?? '') !== 'administrator') {
fail_request('Administrator access required');
}
The first check inspects the raw query-string text; the second reads the already-parsed $_GET array. Those are not the same string. PHP rewrites a space, a dot and an opening square bracket in a parameter name to an underscore when it builds $_GET, so a key of access.level in the query string becomes $_GET['access_level'] once parsed. Sending access.level=administrator leaves the raw query clear of the literal access_level (the blocklist passes), while $_GET['access_level'] still ends up set to administrator (the second check passes).
Check 2: arrays stringify to the same literal text
if (get_debug_type($primary) !== get_debug_type($backup)) {
fail_request('Recovery answers must use the same format');
}
if ($primary === $backup) {
fail_request('Recovery answers must be different');
}
$primaryHash = hash('sha256', 'legacy-recovery:v1:' . $primary);
$backupHash = hash('sha256', 'legacy-recovery:v1:' . $backup);
if (!hash_equals($primaryHash, $backupHash)) {
fail_request('Recovery fingerprints do not match');
}
The three checks want two values of the same type, not equal to each other, whose SHA-256 over a shared prefix matches. For strings that is impossible: different strings give different concatenations and different hashes. Arrays break it. Sending primary[]=1&backup[]=2 makes both values arrays, so get_debug_type reports "array" for both (same format) and the arrays differ as values (passes “must be different”). The sting is the concatenation: 'legacy-recovery:v1:' . $array triggers PHP’s array-to-string conversion, which raises a non-fatal E_WARNING (invisible with display_errors off, which challenge.ini sets) and produces the literal text Array regardless of contents. Both sides collapse to 'legacy-recovery:v1:Array', their SHA-256 hashes match, and hash_equals returns true.
Check 3: a hash collision that is strictly unequal but loosely equal
$material = 'ULTIMATIVE-PHP-2026:' . $code;
$legacyDigest = substr(hash('md5', $material), 0, 9);
$modernDigest = substr(hash('sha256', $material), 0, 9);
if (!($legacyDigest !== $modernDigest && $legacyDigest == $modernDigest)) {
fail_request('The compatibility code was rejected', [
'legacy' => $legacyDigest,
'modern' => $modernDigest,
]);
}
This demands two 9-character digest prefixes that are different as strings (!==) but loosely equal (==).
The "magic hash" 0e trick
PHP’s loose == between two strings first asks whether both look like numbers. If they do, it compares them as numbers rather than character by character. A string of the form 0e followed only by digits is a valid number in scientific notation: 0e3791209 means 0 times ten to the 3791209, which is 0. So any two distinct 0e[0-9]+ strings are both the number 0 and compare equal under ==, while staying different text under ===. Truncating a digest to nine characters just makes a prefix of that shape easy to brute-force.
So I needed a code whose 9-character md5 prefix and 9-character sha256 prefix both match ^0e[0-9]{7}$. Searching over candidate codes for that double condition finds one quickly:
PREFIX = b'ULTIMATIVE-PHP-2026:'
def ok(d): # hexdigest[0:9] == "0e" + 7 digits
if d[0] != 0x0e:
return False
for b in d[1:4]:
if (b >> 4) > 9 or (b & 0xf) > 9:
return False
return (d[4] >> 4) <= 9
The code 59036305 is a hit, which I confirmed with Python’s hashlib:
code = 59036305
md5[0:9] = 0e3791209 # == 0 under loose comparison
sha256[0:9] = 0e9920675 # == 0 under loose comparison, but !== the md5 string
Both prefixes match ^0e[0-9]{7}$, both evaluate to 0.0, so 0e3791209 == 0e9920675 is true while 0e3791209 !== 0e9920675 is also true. The check passes.
Check 4: a falsy strpos check opens a path traversal
$profile = @unserialize(decode_base64url($encodedProfile), [
'allowed_classes' => [MigratedProfile::class, LegacyTemplate::class],
]);
if (!$profile instanceof MigratedProfile) {
fail_request('Unsupported profile type');
}
...
echo '<p class="flag">' . htmlspecialchars((string) $profile, ...) . '</p>';
The win condition is (string) $profile. MigratedProfile::__toString() returns (string) $this->displayName, so if displayName holds a LegacyTemplate object rather than a plain string, that object’s own __toString() runs:
public function __toString(): string
{
if (strpos($this->template, '../')) {
return '[invalid template]';
}
$path = __DIR__ . '/templates/' . $this->template;
$contents = file_get_contents($path);
return $contents === false ? '[missing template]' : $contents;
}
The guard checks the truthiness of strpos(), not whether it returned false. strpos() returns the integer offset of the match, and when ../ sits at the very start of the string that offset is 0, which PHP treats as falsy. A template that starts with ../ slides straight past the check. With __DIR__ equal to /var/www/html, a template of ../../../../tmp/flag resolves to /tmp/flag, which I confirmed by normalizing /var/www/html/templates/../../../../tmp/flag.
Restricting unserialize() to MigratedProfile and LegacyTemplate does not help, because those two classes are exactly the gadget needed: nesting a LegacyTemplate inside a MigratedProfile’s displayName turns the profile import into an arbitrary local file read, rendered straight into the page.
Why the ?manual= parameter is a dead end
The same file has a ?manual= include handler that looks like a second way in. It blocks php:// and ../ before decoding and then requires the final path to end in .php. The flag is not a .php file, and PHP 8 does not honour null-byte path truncation, so that route cannot disclose /tmp/flag. The unserialize gadget is the intended path.
Building the request
The serialized profile nests the two allowed classes:
O:15:"MigratedProfile":1:{s:11:"displayName";O:14:"LegacyTemplate":1:{s:8:"template";s:20:"../../../../tmp/flag";}}
The length prefixes check out: displayName is 11 characters, template is 8, and ../../../../tmp/flag is 20. Base64url-encoding that string (and I verified the result round-trips back to exactly this serialization) gives the profile parameter, which combines with the other three proofs into one GET request:
GET /?access.level=administrator&primary[]=1&backup[]=2&code=59036305&profile=TzoxNToiTWlncmF0ZWRQcm9maWxlIjoxOntzOjExOiJkaXNwbGF5TmFtZSI7TzoxNDoiTGVnYWN5VGVtcGxhdGUiOjE6e3M6ODoidGVtcGxhdGUiO3M6MjA6Ii4uLy4uLy4uLy4uL3RtcC9mbGFnIjt9fQ
Firing this at the service returns the migration-completed page with the flag inside the <p class="flag"> element.
Verification
Three of the four checks reduce to arithmetic I could run without PHP, and they hold. The magic-hash code 59036305 produces md5 and sha256 prefixes 0e3791209 and 0e9920675, both matching ^0e[0-9]{7}$, strictly unequal but both equal to 0.0 under loose comparison. The serialized string’s declared lengths (11, 8, 20) are correct, its base64url encoding matches the profile value in the request byte for byte and decodes back to the same serialization, and /var/www/html/templates/../../../../tmp/flag normalizes to /tmp/flag.
The two runtime-PHP behaviours, parameter-name mangling turning access.level into $_GET['access_level'] and array-to-string coercion yielding Array, are documented PHP behaviour. The flag in the frontmatter comes from the live run, not the handout’s placeholder flag.txt.
The flagReveal
FortID{C0RP_M1GR4T10N_1S_WH3R3_D4T4_G03S_T0_D13_3115928a92}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.