Skip to content
Exploit for CVE-2026

Exploit for CVE-2026

Sploitus • October 21, 2026

| `vuln` | PHP **8.4.26** + Apache prefork/mod_php | remote code execution as `www-data` |

| `fixed` | PHP **8.4.27** (security release) | no code execution |

| `attacker` | Python 3.12 | runs the exploit against both and reports the differential |

The vulnerable page is just ``. It never calls

`session_start()`: the bug is reached by PHP's `session.upload_progress`

Fixed in 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 (fix commit `31c0ea7d7f`,

docker compose up --build --abort-on-container-exit --exit-code-from attacker

[*] stage 3: HTDATA=0x... zif_system=0x...

PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)

==============================================================

PHP 8.4.26: code execution as the Apache worker

==============================================================

The command executed by the exploit is `id>/out/p`; the proof is written to

the shared `proof-vuln` volume and read back by the attacker container.

The patched target is attacked with the same chain and produces no proof.

Both targets are also exposed on the host:

` (vulnerable) and ` (patched).

- `cannot overwrite digest sha256:...` — old Docker versions can trip over a

locally tagged `php:8.4.26-apache` / `php:8.4.27-apache` image while

composing a digest-pinned service. Remove the tags and retry:

docker rmi php:8.4.26-apache php:8.4.27-apache

docker compose up --build --abort-on-container-exit --exit-code-from attacker

- `exec format error` on an arm64 host — the amd64 QEMU binfmt handler is not

### Running on arm64 (Apple Silicon, WSL2 on ARM, ...)

The images are pinned to the official **linux/amd64** build because the heap

calibration and `libphp.so` offsets were measured there. On an arm64 host,

docker run --privileged --rm tonistiigi/binfmt --install amd64

docker compose up --build --abort-on-container-exit --exit-code-from attacker

This PoC was developed and verified on an arm64 host with that emulation

PHP keeps a lot of worker-global state that points into the *request* heap.

Normally each request resets it. Two things can go wrong:

1. **Producer.** `session.upload_progress` makes PHP initialize the session

from the multipart callback *before* `RINIT` (inside

`php_hash_environment()`). If a `memory_limit` OOM bails out of

`php_session_initialize()`, the request is torn down with

`PG(modules_activated) = 0`, so the session RSHUTDOWN path is skipped.

`PS(http_session_vars)` (a `zend_reference*`) is left pointing into the

2. **Consumer.** The request on the same worker parses its multipart

body, allocating attacker-controlled field buffers, and only then reaches

`php_session_initialize()` → `php_session_track_init()`. The stale

`PS(http_session_vars)` is dereferenced and destroyed before normal

The OOM is only the *interruption* that abandons session initialization

(the same bailout class is also fixed by the related OOM advisory

GHSA-w8hw-w9fx-j79w). The vulnerability demonstrated here is the stale

session state surviving into the request and being consumed before

`php_session_track_init()` runs `zval_ptr_dtor()` on the stale reference:

zval_ptr_dtor(&ref->val) # forged IS_ARRAY_EX

pDestructor(&bucket->val) # -> zif_system()

The whole exploit is two kinds of ordinary multipart `POST`s. The producer

and consumer must hit the same worker (the lab uses one prefork worker).

When a worker aborts, the prefork parent forks a replacement that inherits

the same address-space layout, which keeps the heap/ASLR leak results valid

The 8.4.27 patch (and the equivalents on 8.2/8.3/8.5) does several things

- replaces OOM `longjmp()` bailouts with delayed/hard OOM handling,

- resets request globals at shutdown (`PG(http_globals)`, last error,

- early-resets session globals at `MULTIPART_EVENT_START`,

- runs upload-progress decode in `in_rfc1867_update` mode with

`__PHP_Incomplete_Class`-only decoding and no user callbacks,

- removes `$_SESSION` before the target script runs.

On 8.4.27 the producer no longer publishes and keeps the dangling session

reference, so the heap leak stops working and the chain cannot proceed.

All three stages use the same stale-session primitive.

1. **Zend MM chunk base.** The producer OOMs. The consumer reclaims the

stale `PS(id)` with a 79-byte field whose `zend_string` length field is

`0x1000`; `php_url_encode()` then reads past the string and

percent-encodes many request-heap pointers into `Set-Cookie`. The

2 MB-aligned mode is the Zend MM chunk base.

2. **libphp base.** A 56-byte HashTable page is placed at the stale

`PS(id)` so that the cookie data begins at

`nNextFreeElement | pDestructor`. `pDestructor` is `zval_ptr_dtor`

(`libphp+0x65eb60`); its address yields the libphp base.

3. **Control flow.** A forged 55-byte reference is placed at `R0-0x10`

(reclaiming the stale `zend_reference`), and a live 167-byte

`zend_string` (a normal multipart field, kept in `$_POST`) carries the

forged HashTable. `pDestructor` is `zif_system`

(`libphp+0x465370`); the forged `zend_execute_data` to the bucket

passes the command string to it. `system()` runs the command before the

Relevant addresses are all chunk-relative or libphp-relative; ASLR is

defeated by the two in-band leaks, not disabled.

├── compose.yaml # vulnerable + patched + attacker

│ ├── exploit.py # exploit implementation (stdlib only)

│ └── run_demo.sh # differential driver

├── apache.conf # single prefork worker, KeepAlive Off

├── php.ini # 8M memory_limit, display_errors On, upload progress On

The lab configuration matches the known reachable case:

- `session.upload_progress.enabled=On`, `cleanup=On` (defaults),

- `memory_limit` small enough to OOM during upload-progress session init

- `display_errors=On` for the in-band `Set-Cookie` leaks,

- Apache prefork with a single worker (or at least a way to pin the

producer and consumer to the same worker),

- `session.use_strict_mode=0`, `session.save_path` writable.

Production exploitation is more constrained (the producer/consumer must be

routed to the same worker, and the leaks require displayed errors); the

advisory's point is that the underlying stale-state lifecycle is a critical

The `fixed` service is the official **php:8.4.27-apache** image with exactly

the same Apache/PHP configuration. It is attacked with the same exploit and

fails at stage 1 (no stale `PS(id)` leak), so `run_demo.sh` exits non-zero

for it and the demo prints `RESULT: CONFIRMED`.

You can also attack each target manually:

python3 attacker/exploit.py -u --proof /tmp/p

python3 attacker/exploit.py -u --proof /tmp/p

`exploit.py` exits 0 only when the proof file contains `uid=`.

`zif_system`, `zval_ptr_dtor` and the 167-byte carrier offset are specific

to the pinned image. Recompute them from the target's `libphp.so`:

docker create --name libphp php@sha256:60111400ff77bdc44fa79b4f072afe49a42abba71e0846ad496be4761dc9c11a

docker cp libphp:/usr/lib/apache2/modules/libphp.so .

nm libphp.so | grep -E ' (zif_system|zval_ptr_dtor)$'

then pass `--zif-offset`, `--dtor-offset` and (after re-calibrating the

heap layout) `--htdata-offset`. Heap offsets are deterministic for a given

build/configuration but may move between PHP versions and architectures.

- PHP security advisory GHSA-6wmf-4rv5-p3rp / CVE-2026-109619 and the fix

commit `31c0ea7d7f` by Arnaud Le Blanc, Ilija Tovilo, Jakub Zelenka,

Nora Dossche, Nicolas Grekas; reporters sagitzadik-wiz, tomash-areion,

- The session upload-progress lifecycle chain and the in-band Set-Cookie

leaks were published in the `9bie/php2shell-bypass-aslr-exp` research

repository. This PoC reimplements and validates that chain against the

official image (`php:8.4.26-apache`) and adds the patched negative

For authorised security testing and research only. The exploit runs

arbitrary commands as the web server user and intentionally corrupts the

worker heap; run it only against systems you own or are explicitly

Extracted Entities