dddɘw
dddɘw
Platform: D3C2026 | Category: Pwn | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-25 | Status: Solved Techniques: php_filter_exploitation, heap_grooming, hashtable_corruption, zval_forgery, destructor_hijacking, page_probing
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
d3c2026| ID:20260725_d3c2026_dddew - Tags: php, heap_corruption, zend_mm, php_fpm, cnext, hls
- Indicators: md5_file() receives an attacker-controlled path, ISO-2022-CN-EXT gconv module restored in the image, custom Zend allocator hardening patches, raw php://input parsed after request startup
- Source:
20260725_d3c2026_dddew.md
Foothold
Vulnerability / Misconfiguration
- Php_filter_exploitation
- Heap_grooming
- Hashtable_corruption
- Zval_forgery
- Destructor_hijacking
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- php_filter_exploitation
- heap_grooming
- hashtable_corruption
- zval_forgery
- destructor_hijacking
- page_probing
- Tags: php, heap_corruption, zend_mm, php_fpm, cnext, hls
Original Writeup
<details><summary>Click to expand original content</summary>Description
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.
Attack Surface
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:
paramsis split byd(). If numeric elements0,1, and2exist, they are passed directly toc().- Otherwise, an HLS playlist is parsed by
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 containsmpegurlor 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().
Why the Stock CNEXT Exploit Fails
The supplied PHP is intentionally hardened, so a stock Ambionics CNEXT exploit is the wrong final route:
- The pinned PHP commit adds freelist integrity protection. A free slot stores both
nextand a byte-swapped/XOR shadow value. CNEXT changes only the low byte ofnext; the shadow no longer matches, and allocation aborts withzend_mm_heap corrupted. a.patchseparates allocations into default and user-input zones. This blocks ordinaryGET/POSTheap shaping, but not everything:file_get_contents('php://input')runs afterphp_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.patchmoveszend_mm_heapinto a separate two-page mapping and places custom allocator callbacks on its read-only second page. The usual writablemain_chunkcallback overwrite is unavailable.c.patchadds consistency checks before bucket unlinking, defeating the obvious forgedphp_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.
Finding the Intended Primitive
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.
Leaks and Forged Destructor
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.
Reliable Exfiltration
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.
Reproduction
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.
Dead Ends Versus the Final Route
- Dead end: stock Ambionics freelist poisoning. The shadow pointer check turns the corruption into allocator aborts.
- Dead end: shifting an allocated socket stream's ops pointer. Adjacency was reproducible, but the resulting callback arguments were unusable and led to OOM/crashes rather than a controlled call.
- Final route: default-zone raw-body allocations, compact page-preserving grooming,
HashTable/zval corruption, a forged destructor, remote page probing forsystem, and replacement of writableindex.php.
Artifacts
tasks/d3c2026/dddew/source/index.php— reachablemd5_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-alignedsystemprobe.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.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR