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 · Screening · 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 |
| 8 | The same stale-globals class on PHP 7.2 (EOL): both bases leak from one in-band cookie, zif_system dispatch runs as uid=33 — 6/6 under stock post_max_size=8M, incl. 5-worker pools |
Proven — lab, 7.2.34 + 7.2.21 |
| 9 | The 129 MiB producer is replaceable by a 1.41 MB header-dense part (470k × x:\n) with zero recalibration — 3/3 full chain, identical bases |
Proven — lab |
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_destroy re-entry UAF (CWE-416) — boundary unchanged at n=1015,
identical to 8.5.11 (fault at
var_unserializer.c:906:67); the patch'sZEND_HAS_OOMguards are inert on this path;BG(serialize_lock)is still not taken around__destructdispatch. - Serialize-handler mismatch — a
php_serialize-written|-key session decoded by thephphandler after an app-phasesession_start()instantiates the gadget on mod_phpphp_valueand FPM.user.inialike; the patch'sincomplete_classes_onlyguard is scoped to the pre-RINIT upload window only. - Upload-progress injection — attacker-controlled key stored verbatim in the session file.
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.
- a single-shot exploit against hardened production deployments (multi-worker
prefork reliability,
display_errors=Off, and highmemory_limitall remain genuine barriers — see the fleet study indocs/RELEASE-RECONCILIATION-2026-10-21.md); - RCE from the FPM stale-state primitive (crash only, so far);
- impact on PHP versions other than those explicitly tested (7.4.33 / 8.1.34 / 8.4.26 / 8.5.11 / 8.5.12 stock, 3 LiteSpeed builds).
| Property | Required value |
|---|---|
| Host | Native Linux x86_64 |
| Target | PHP 8.5.11, Apache module (Chain B also on FPM for stale-state work) |
| Image | php@sha256:974e3a920309308e1690ed607e6cc3061d6906f90cc8d30fe519daa48bccf101 |
| Chain A allocator | USE_ZEND_ALLOC=0, USE_TRACKED_ALLOC=1 (intentionally non-default) |
| Chain B allocator | stock default Zend MM |
| PHP limits (chains) | memory_limit=16M, post_max_size=32M, upload_max_filesize=32M |
| Apache | prefork, exactly one worker |
| Debugger | Host GDB with Python support (calibration/receipts only) |
Run only on an owned, isolated x86_64 Linux lab:
sudo ./scripts/preflight.sh
sudo ./scripts/reproduce.shThe runner validates the environment, generates the byte-identical
5,767,505-byte fixture, calibrates a safe type-0x7 slot, then runs
fresh-worker negative → abort → marker with independent marker-byte,
worker-transition, health, and trace checks, and writes a full SHA-256
manifest. A guarded-placement miss is recorded and retried once per fresh
worker — a miss is never reported as execution. The retained verdict is
valid only when every gate in runs/DATE/VERDICT.json is true; re-verify
without firing: python3 scripts/verify_run.py runs/DATE.
sudo ./scripts/lab-stop.sh # stop container, keep state
sudo ./scripts/lab-reset.sh # remove PoC container + network only| Case | Expected effect | RCE oracle |
|---|---|---|
| Calibration | Type-0x7 stale slot observed; worker survives | none |
| Negative | Fake structures present; no dispatch; same worker | marker absent |
| Abort | Refcount decrement; exact abort@plt; SIGABRT |
marker absent |
| Marker | Exact target dispatch; command output under /tmp | exact unique bytes |
An HTTP error, empty response, PHP fatal error, or worker crash is not accepted as command-execution evidence. The FPM stale-state finding is reported as a crash primitive precisely because it has not passed this bar.
- Dedicated Docker containers on internal networks; no host port published.
- Images pinned by digest. PoC builders accept no arbitrary command.
- Execution payloads are restricted
id/printf-class commands writing under/tmpinside the dedicated container only. - Negative and controlled-abort cases are mandatory in every session.
- Host GDB attaches only to the sole worker of the named lab container.
tools/screen/graded_screen.py is the graded triage for the parser class
behind both CVEs, packaged for defenders: banner/branch triage, a safe
surface probe, and two controlled parser-heap ladders (unique part headers;
small-body empty-filename fast fill). It reports CONFIRMED-OOM,
APP-FATAL, NO_SIGNAL, FRONT-BOUNDED or INCONCLUSIVE, always with the
equal-wire control and 3x-ceiling evidence that decided. No code execution,
no session carrier, paced at 1 request / 6 s with a per-host budget. Full
method, error classes and pacing: docs/SCREENING.md.
python3 tools/screen/graded_screen.py https://host/ --dry-run
python3 tools/screen/graded_screen.py https://host/ --max-tier 3 --authorizedtools/e2e/php2shell_e2e.py is the single entrypoint over every piece of this
program: precheck (banner/SAPI/front/session/body/memory/pool/patched), the
routing table, automatic calibration (producer, rotate mode, signed
leak-delta family, geometry transfer) and chain execution with a proof
receipt. One method registry drives the CLI, the router, the dry-run planner
and the sealed coverage summary; new techniques plug in as registry entries.
python3 tools/e2e/php2shell_e2e.py --phase all \
--target http://127.0.0.1:8080/ --authorized --budget 200 --ledger ./ledger
python3 tools/e2e/php2shell_e2e.py --phase self-test # 56/56, no networkStandard library only, per-host ledger with UTC timestamps, storm-risk-last
probes, hard stop on bot challenges, operator-supplied OOB proof only. Stubs
are first-class: zero4_unserialize, chain_192m, stock_chain_nested and
chain_php72 are registered, reported, and never auto-executed. chain_e_fpm
(FPM stale BG(unserialize)) is an experimental chain: it is wired end to
end and lab-proven under ASLR, and it runs only when named explicitly with
--method chain_e_fpm because its dispatch needs three absolute bases the
session-decode surface cannot disclose.
Operator guide: tools/e2e/README.md; sealed coverage and lab acceptance:
docs/E2E-TOOL.md.
app/ Minimal PHP/Apache target
tools/ php2shell_test.py (v1+v2) + domain_php2shell_scan.py
tools/e2e/ Consolidated end-to-end tool (precheck -> route -> chain)
tools/screen/ graded_screen.py - defender triage (banner -> ladders)
tools/bombchain.py 1.41 MB amplified-producer variant of the stock chain
tools/php72/ PHP 7.2 chain: drive.py, chain72.py, fullchain.py
docs/SCREENING.md Screening tiers, controls, verdicts, false positives, pacing
docs/E2E-TOOL.md End-to-end tool: sealed coverage, acceptance, boundary
ninebie/ Chain B PoC (send/env scripts, fire entry point, receipts)
docs/ Proof boundary, port write-ups, reconciliation log
evidence/reference/ Chain A two-generation proof summary + provenance
evidence/validation-*/ Chain A compact evidence, two fresh generations
scripts/ Chain A lab, calibration, body, runner, verifier
tests/ Deterministic fixture and safety tests
artifacts/, runs/ Generated locally; ignored by Git
SOURCE-SHA256SUMS authenticates the tree: python3 scripts/make_manifest.py --verify.
docs/SCREENING.md— defender screening: graded tiers, controls, verdict taxonomy, false-positive classes, safe pacing.docs/PUBLIC-CONTEXT-2026-10-21.md— the public record vs. this lab: what we verified and what we refuted (the cached-zvalsession.upload_progresstheory, exhaustively).docs/RELEASE-RECONCILIATION-2026-10-21.md— running log of every post-release finding: amplification, patch gaps, FPM stale state, third-party claim verdicts (junaire, bnd9, Whackermelon, ikow Chain E).docs/WAF-BYPASS.md— Content-Type separator bypass + defender guidance.docs/NINEBIE-CLASS-PORT.md— Chain B mechanism, offsets, divergence.docs/END-TO-END.md— self-contained RCE receipt and control ladder.docs/ASLR-STOCK.md— stock-config (nophp.ini, 128M) A-stage arming OOM and the B-stage 128M heap leak: cache walk + forgedPS(id)-> Zend-MM chunk base fromSet-Cookie(tools/php2shell_aslr_stock.py,tools/php2shell_stock_chain.py, demo indemo/aslr-stock/).
In an isolated PHP 8.5.11 Apache lab, two independent multipart-triggered chains reached attacker-controlled indirect calls and executed benign commands; a later end-to-end wrapper removed the debugger and local-access dependencies entirely using an in-band disclosure. On patched 8.5.12 both CVE chains are dead, but three separate paths to impact survive the patch. A 52x memory-amplification technique defeats
post_max_sizeon all unpatched versions since 7.4. These results demonstrate code-execution feasibility in the stated configurations; they are not evidence of a universal or unconfigured-deployment exploit.
docs/STOCK-CHAIN.md— full stock-config RCE chain (tools/php2shell_stock_poc.py): leak both bases + fire, ~6-11 s, verified 9/9 matrix + patched-negative, honest limits, lineage/attribution (concurrent with 9bie pure/). Also the 1.41 MB amplified producer (tools/bombchain.py) and the three driver fixes (delta band, worker normalisation, docroot drift).docs/PHP72-CHAIN.md— the same class on PHP 7.2: 7.2 geometry, the arm1/arm2 session-state split, the bin-56 pre-drain leak (both bases in one cookie) and the bin-24→bin-640 fire lever (tools/php72/).docs/ECOSYSTEM-ADVISORY.md— what not to believe in the third-party php2shell scanner wave: invented fix-version gates, a probe with no causal path, a success oracle that scores a file read as RCE, and whyCVE_RECORD_DNEis not proof that a CVE does not exist.