$ cat writeup.md…
$ cat writeup.md…
d3c2026
Task: A PHP-FPM video player passes attacker-controlled HLS or numeric parameters to md5_file() on a hardened Zend heap. Solution: Trigger CNEXT, groom compact page runs, corrupt a HashTable/zval, and invoke a forged destructor.
online vidddeo player
The attachment provides a PHP-FPM “video player,” its Docker build, and three PHP hardening patches. The goal is to turn its attacker-controlled file hashing into code execution despite those allocator defenses.
The relevant sink in source/index.php is:
function c($x, $y, $z) { /* type and length checks omitted */ $v = json_decode($x, true); if (!is_array($v)) $v = []; $v[$y] = md5_file($z); echo json_encode($v); }
There are two ways to control all three arguments:
params is split by d(). If numeric elements 0, 1, and 2 exist, they are passed directly to c().b(). Session-data fields supply the JSON and key, while #EXT-X-MAP:URI=... or the first media line supplies the file path. A raw body is also accepted when its content type contains mpegurl or it begins with #EXTM3U.Therefore $z can be a php://filter chain. The image deliberately restores the vulnerable ISO-2022-CN-EXT.so gconv module, making the ISO-2022-CN-EXT/CNEXT one-to-three-byte overflow reachable through md5_file().
The supplied PHP is intentionally hardened, so a stock Ambionics CNEXT exploit is the wrong final route:
next and a byte-swapped/XOR shadow value. CNEXT changes only the low byte of next; the shadow no longer matches, and allocation aborts with zend_mm_heap corrupted.a.patch separates allocations into default and user-input zones. This blocks ordinary GET/POST heap shaping, but not everything: file_get_contents('php://input') runs after php_request_startup() ends the user-input phase. The raw body, decoded HLS values, and filter buckets consequently return to default zone 0. This is the application-specific isolation bypass.b.patch moves zend_mm_heap into a separate two-page mapping and places custom allocator callbacks on its read-only second page. The usual writable main_chunk callback overwrite is unavailable.c.patch adds consistency checks before bucket unlinking, defeating the obvious forged php_stream_bucket::{next,prev} write primitive.These defenses explain the challenge design, but they do not prevent corruption of allocated PHP data structures that never use the protected freelist metadata or bucket unlink.
The PHP commit, heap-isolation design, metadata hardening, and CNEXT sink exactly matched the r3ctf-2026 challenge definitely-not-a-web-chal. Its reference exploit was preserved under:
prior-r3ctf/definitely-not-a-web-chal/solve.py
That exploit corrupts a PHP HashTable/zval layout and installs a forged destructor. It does not poison a protected small-bin freelist, overwrite the read-only custom callbacks, or depend on bucket unlink, so it survives the added defenses.
The original request was too large for this application's 1 MiB JSON limit. The adapter in solve.py keeps all 256 large-run page allocations while shortening their string bodies to the smallest sizes that preserve one-, two-, or three-page allocations. Removing the optional hold_X field produces a 939,778-byte decoded JSON request without changing the important page layout.
The original assumption that 128 packed integers consumed a full page was wrong: their zval table is only 0x800 bytes. Prepending twelve genuine one-page large runs fixed the alignment. Thus the stable setting is:
PAGE_OFFSET=12
At that alignment, the forged array entry becomes a floating-point value whose bits disclose pointers, and the same layout reaches the forged destructor during cleanup.
The remote leak phase returned:
near PHP-FPM: 0x59791c997ec0 Zend pointer: 0x7b5c98a12000 main chunk: 0x7b5c98a00000
The exploit then uses the corrupted HashTable and forged zval metadata to dispatch a chosen destructor as system(argument). A debugger-derived DIRECT_PC and a guessed local-relative system address were useful only for local validation. They were not assumed to be the successful remote address.
For the remote service, probe_remote_system.py probes page-aligned candidate addresses around the leaked heap/libc neighborhood. Each candidate triggers the forged destructor and then checks the web service. This page probe ultimately found a working system target.
The stock reference command writes into a root-owned static webroot and therefore fails under the supplied permissions. The writable application source is a better observable sink. The final 40-byte, self-terminating command is:
cd /h*/w*/s*;/r*>x;mv x i*;kill -9 $PPID
It changes into /home/work/scripts, executes the setuid reader via /r*, writes to x, atomically replaces index.php through the i* glob, and kills the corrupted FPM worker. Killing that worker is important: later destructor activity cannot truncate the replacement, while the FPM master starts a clean worker.
With PAGE_OFFSET=12, the command's stable zend_string body is at:
main_chunk + 0x42068
After a successful page probe, requesting /Items executes the replacement script and returns its plain contents. The probe's detector mistakenly searched for the shorter decoy pattern d3c{ rather than the actual d3ctf{...} format, so it continued probing after exploitation had already succeeded. Direct requests to /Items through both available remote routes confirmed the result.
Build and test the supplied environment:
docker build --platform linux/amd64 -t d3c-dddew:local source PAGE_OFFSET=12 LEAK_ONLY=1 LEAK_TYPE=heap python3 solve.py PAGE_OFFSET=12 LEAK_ONLY=1 LEAK_TYPE=zend python3 solve.py EXFIL_INDEX=1 PAGE_OFFSET=12 DIRECT_PC=<LOCAL_SYSTEM_ADDRESS> python3 solve.py
For the remote target, first obtain the leaks with solve.py, align the Zend value down to its 2 MiB main-chunk base, then run the page probe:
URL='https://target.example' PAGE_OFFSET=12 LEAK_ONLY=1 LEAK_TYPE=heap python3 solve.py URL='https://target.example' PAGE_OFFSET=12 LEAK_ONLY=1 LEAK_TYPE=zend python3 solve.py URL='https://target.example' HEAP_BASE=0x7b5c98a00000 WORKERS=4 python3 probe_remote_system.py curl -sS 'https://target.example/Items'
The complete payload construction and imported reference primitives are in solve.py; the bounded parallel address driver is in probe_remote_system.py.
HashTable/zval corruption, a forged destructor, remote page probing for system, and replacement of writable index.php.tasks/d3c2026/dddew/source/index.php — reachable md5_file() sink and both parser branches.tasks/d3c2026/dddew/source/{a,b,c}.patch — zone isolation, read-only heap callbacks, and safe bucket unlink.tasks/d3c2026/dddew/solve.py — compact adapted HashTable exploit and exfiltration command placement.tasks/d3c2026/dddew/probe_remote_system.py — remote page-aligned system probe.tasks/d3c2026/dddew/remote_exfil_proof.txt — local proof of the final command offset and index replacement.tasks/d3c2026/dddew/remote_probe.log — remote candidate-page probe log.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar