
PHP CVE-2026-109618/109619 — self-contained unauthenticated RCE on stock PHP 8.5.11 (in-band PS(id)→cookie memory disclosure + UAF chain), full control ladders, fixed-version negative control on 8.5.12
Lab reproduction and vulnerability-research package for the PHP 8.5.11 php2shell class (CVE-2026-109618, CVE-2026-109619), October 2026. Status: Proven — self-contained RCE (four plain HTTP requests, no debugger, no local access), plus amplification, patch-gap, and FPM stale-state findings below.
Contents: Results · Chains · Amplification · Patch gaps · FPM stale state · WAF bypass · Environment · Quick start · Proof discipline · Layout · Public context · Statement
| # | Finding | Classification |
|---|---|---|
| 1 | End-to-end self-contained RCE on PHP 8.5.11 — four plain HTTP requests, in-band address sourcing via PS(id)→cookie disclosure, both bases from one 512-byte PHPSESSID leak; 12/12 fires, uid=33, 8 ASLR generations | Proven — self-contained RCE |
| 2 | 52x memory amplification via duplicate 6-byte multipart headers — defeats post_max_size (2.57 MB body OOMs a 128M limit) on PHP 7.4–8.5.11 | Proven — lab, all four versions |
| 3 | Three exploit paths survive the 8.5.12 security patch (var_destroy re-entry UAF at n=1015, serialize-handler mismatch, upload-progress injection) | Proven — ASAN on GPG-verified 8.5.12 |
| 4 | FPM stale BG(unserialize): worker survives a session-decode OOM, stale state is consumed by the next request's rfc1867 window, heap-shaped consumption SIGSEGVs the worker | Proven — mechanism + DoS; RCE unproven |
| 5 | Content-Type separator bypass (multipart/form-data, boundary=X) — every published Content-Type-anchored WAF signature is bypassable | Proven — SAPI.c parser behavior |
| 6 | Both CVE chains dead on patched 8.5.12 (token-identical configure, GPG-verified source) | Proven — negative control |
| 7 | The public "cached-zval session.upload_progress" theory — refuted on 4 stock PHP versions + 3 LiteSpeed vendor builds, all cleanup/concurrency corners | Refuted — exhaustive |
Two independent memory-corruption chains reach attacker-controlled indirect
calls on the stock php:8.5.11-apache image; the end-to-end wrapper combines
Chain B's mechanism with a novel in-band disclosure for a fully remote fire.
| Chain A — stale bucket | Chain B — dangling reference | |
|---|---|---|
| Mechanism | OOM bailout leaves a HashTable bucket published but uninitialized; shutdown consumes it as a fake zval → fake array → pDestructor | Pre-RINIT OOM leaves PS(http_session_vars) holding a dangling zend_reference; a live 31-byte string reclaims it; forged reference → zend_hash_destroy pDestructor |
| Allocator | tracked/system (USE_ZEND_ALLOC=0) | stock default Zend MM |
| Terminal | libc system() (gdb-calibrated addresses) | zif_system @ libphp+0x5996e0 (no libc) |
| Debugger at fire time | calibration only | none — FIRE_GDB=0 |
| Reproducibility | 2 randomized parent generations | repeated, incl. cross-run |
Leak-A arms a dangling PS(id) (1001st variable trips max_input_vars →
headers_sent=1; the empty-cookie reset_id failure keeps send_cookie=1;
a 24 MiB field OOMs pre-RINIT). Leak-B lands a 257-element array whose
zend_array overlaps PS(id)−0x10: ZSTR_LEN reads nTableSize=512 and the
URL-encoded cookie carries pDestructor (→ libphp base) plus nine arData
heap pointers (majority vote → chunk base). The measured chunk−libphp delta
is not constant across generations (−0x1a00000 / +0x3600000 / −0x1c00000),
so the disclosure is genuinely load-bearing. The fire then runs with the
leaked addresses. Prerequisites: display_errors=On, post_max_size above
the OOM field, memory_limit below it, single-worker prefork.
A status='HTTP/1.1 200 OK' → OOM fatal, worker survives, PS(id) armed
B Set-Cookie: PHPSESSID=<512-byte encoded disclosure>
FIRE → MARKER=uid=33(www-data) gid=33(www-data) groups=33(www-data)
FIRE_OK (×12 across 8 distinct ASLR layouts)
Full receipt and control ladder: docs/END-TO-END.md.
Optimized duplicate-header amplification: short X: a headers (6 bytes wire)
exploit PHP's fixed 256-byte SMART_STR_START_SIZE per saved header
(~314 B memory per 6 B wire). Measured by exact OOM-threshold bisection on
8.5.11; identical on 7.4.33 / 8.1.34 / 8.4.26 (51–52x):
| memory_limit | min OOM body | amplification |
|---|---|---|
| 8M | 153 KB | 54.8x |
| 32M | 636 KB | 52.8x |
| 128M | 2.57 MB | 52.3x |
| 256M | 5.14 MB | 52.2x |
All bodies fit inside a stock post_max_size=8M — the practical upload
barrier is defeated on every unpatched version. Chunked framing
additionally bypasses the Content-Length pre-check (8.85 MB chunked parsed
and OOM'd where the same body with Content-Length was rejected). The
8.5.12 patch closes both vectors (unknown-header filtering + streaming
post_max_size enforcement). The nested-array "bomb" shape does not
reproduce as exponential (max 9.6x, 1.16 MiB — cannot OOM even 8M).
Details: docs/RELEASE-RECONCILIATION-2026-10-21.md.
Verified on a GPG-verified, token-identical 8.5.12 ASAN build — the two CVEs are fixed, but three separate paths to impact survive the patch:
var_unserializer.c:906:67); the patch's
ZEND_HAS_OOM guards are inert on this path; BG(serialize_lock) is
still not taken around __destruct dispatch.php_serialize-written |-key session
decoded by the php handler after an app-phase session_start()
instantiates the gadget on mod_php php_value and FPM .user.ini alike;
the patch's incomplete_classes_only guard is scoped to the pre-RINIT
upload window only.Lab-verified on php:8.5.11-fpm: a 24 MB session at memory_limit=32M
OOMs inside php_var_unserialize and the worker survives — the exit-70
barrier applies only to startup OOM. gdb shows BG(unserialize).level=1
and the stale data pointer persisting into the next request's rfc1867
pre-RINIT window; an 8191-byte multipart field produces an 8216-byte
_emalloc that overlaps the stale pointer and SIGSEGVs the worker in
var_tmp_var during request startup. Measured impact: repeatable worker
crash (DoS) plus a memory-corruption primitive; RCE beyond the crash is
unproven. Prerequisites: low memory_limit, large attacker-influenced
session, a session_start() endpoint, upload_progress=On, and
pm=static max_children=1 for reliable same-worker pairing. FPM is
therefore conditionally, not categorically, protected for this class.
Content-Type: multipart/form-data, boundary=X (comma separator) is
accepted by PHP — SAPI.c trims at ;, ,, and space. Every published
php2shell WAF signature anchored on Content-Type: multipart/form-data;
is bypassable. Defender guidance in docs/WAF-BYPASS.md.
display_errors=Off, and high memory_limit all
remain genuine barriers — see the fleet study in
docs/RELEASE-RECONCILIATION-2026-10-21.md);