PHP2Shell — pre-application PHP RCE wave: OOM-interrupted hash buckets, cross-worker stale state, and the heap-grooming battery (CVE-2026-109618 / CVE-2026-109619)¶
Source: hourly offensive-security scan, 2026-10-23. PHP shipped fixes on 2026-10-21 for two CVSS-v4 9.2 unauthenticated RCEs, CVE-2026-109618 (out-of-memory corruption during request initialization) and CVE-2026-109619 (stale worker-global state across requests), fixed in 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 with source-only backports for EOL 7.4/8.0/8.1. This scan closes the tracked-not-promoted item from the 14:1xZ run: the exploitation walkthrough has now landed — Datadog Security Labs independent reproduction (Oct 21, updated Oct 22), with Wiz, Huntress, Tenable, Abstract, and watchTowr coverage. Exploited in the wild: watchTowr honeypots report active exploitation; AWS reports attempts as early as Oct 17 — four days before disclosure/patch. Not yet in CISA KEV as of this scan (KEV-watch: the ICSA-to-KEV-lag head-start rule applies — target richness before the KEV entry mobilizes the herd).
Framing note: this page documents the attack-surface mechanics and validation boundaries for authorized engagements. Lab-owned targets only; the class fires against any PHP endpoint before the application runs, so on out-of-scope infrastructure even a failed probe is hostile traffic.
The two legs¶
CVE-2026-109618 — interrupted insertion in the request parser. PHP's $_POST hash-table insert helper (_zend_hash_str_add_or_update_i() in the tested build) increments nNumUsed/nNumOfElements before allocating the key string. A request sized so the key allocation trips memory_limit raises a fatal error mid-insertion: the bucket is counted but never initialized. Request shutdown then destroys $_POST, walks every counted bucket, and interprets the unfinished bucket's leftover bytes as live PHP values. Datadog's repro (PHP 8.5.11, Apache prefork mod_php, stock defaults memory_limit=16M / post_max_size=64M) lands execution in three stages:
- Reach the interrupted insertion — a form-urlencoded body shaped
=<binary>&a=1&<~3.5MB name>[x]=1. The[x]array syntax is load-bearing: it routes through the nested-array path that hits the allocate-after-counts helper; a plain field name takes a different, non-vulnerable path. The oversized name makes the first key allocation succeed and the second blow the limit during the parser's own copy cascade. - Heap-groom the unfinished bucket — the empty-name field carries a 276-byte binary value that, plus string header/terminator, lands in the same 320-byte size class as a small hash table's allocation; PHP copies then frees it (empty name), and the following
a=1field forces the hash tables to reuse the freed block — so the attacker's bytes survive into the counted-but-uninitialized bucket, shaped to look like a PHP value pointing at a fake array. - Turn cleanup into a call — the fake array's element-destructor callback slot is set to
system()and its element to the command string; request shutdown "destroys" the fake array and callssystem(<cmd>)aswww-data.
Key operational properties from the repro: the request never reaches application code — authentication, input filtering, WAF-inside-the-app are all downstream of the bug; any PHP script exposes the path (even one that just prints). The successful exploit returns HTTP 200 with the memory-exhaustion text in the body and the same worker still running (~3.1s). ASLR is the remaining gate: guessing addresses crashes workers (child pid ... exit signal Segmentation fault (11) in the error log), and Wiz describes a PHPSESSID cookie memory-disclosure technique that leaks worker process memory back to the attacker — leaked pointers make the chain reliable. The same OOM path also yields an unauthenticated process-memory disclosure primitive on its own (Abstract).
CVE-2026-109619 — stale state crosses the request boundary. Worker-wide fields keep pointers into freed per-request memory. Two confirmed exploitable carriers: fatal-error backtraces (PHP 8.5 only; backtrace state survives shutdown, a later request's diagnostic reads it before init — command execution confirmed on mod_php) and upload processing (after a fatal error, session/decoder state persists; the next upload reads it before initialization — command execution confirmed on mod_php AND PHP-FPM; on FPM the app must perform a suitable operation after startup). The upload carrier rides session.upload_progress, enabled by default and running before the target script, even if the app never calls session_start(). Exploitation requires attacker requests to land on the same worker — a larger pool lowers per-attempt odds but does not remove the flaw.
Durable operator axes¶
- Pre-parser bugs outrank every application control. PHP processes the malicious request during request initialization: login walls, rate-limit middleware, and app-level input validation never see it. Audit posture for any PHP target: the request-parsing surface IS the perimeter. Corollary for blocking: only controls in FRONT of PHP (reverse proxy, request-size limits at the edge) sit on this path — a control behind the parser is theater for this class. Fingerprint the front: same-connection request-size behavior + parser error text locations tell you if a WAF is inspecting the body at all (inspection engines silently truncating large bodies is a standing bypass).
Unknown on line 0is the tell that a fatal happened before any script ran. When fuzzing/reconning PHP stacks, error-log-visible fatals at line 0 location during request init = you are touching the parser's own allocation paths — the richest pre-auth memory-corruption surface on the box. Pair the request log (giant single field names, control bytes in values) with the error log shape; a 3.5MB single form-field name is not legitimate traffic on any app.- Syntax variants select code paths — run the path-selection battery, not one payload. The array-syntax
[x]vs plain-name split decided vulnerable vs non-vulnerable insertion helper. For any parser-stage probe: submit the same primitive through every syntax variant the grammar accepts (nested array, empty key, multi-value, encoded separators) and diff behavior — helper divergence is where the memory bugs live. - Metadata-before-allocation is a recurring bug class, and the patch admits it. The fix defers the soft-limit error to a safe point and hard-kills (
_exit(137)) past the hard limit, but the hash insertion still updates counters before allocating the key in 8.5.12 — only the surrounding error handling changed. Datadog explicitly expects more instances of the pattern, including unpatched instances unique to older branches. Operator translation: PHP 7.4/8.0/8.1 fleets with vendor backports (version numbers unchanged!) are standing targets; when a new interrupt-init allocation failure is found anywhere in php-src, the same cleanup-trusts-stale-metadata chain re-applies. Grep-the-fix audits now extend to "what did the patch NOT refactor." - Worker model is an exploitation-surface variable.
mod_phpprefork: single long-lived worker, everything shares one heap — easiest target, both legs apply. PHP-FPM: 109619 needs same-worker affinity (pool size = your hit-rate divisor; sticky sessions via keep-alive help) plus a suitable app operation; 109618 works wherever automatic POST parsing runs. FrankenPHP/LiteSpeed are named in-scope by PHP's own announcement — and the Feb 5 FrankenPHP page already carries the exact prior lesson: worker-pool runtimes that reuse process state across requests are a fingerprint class, and build recipe ≠ security posture. - Success has no crash signature. The refined exploit returns 200 and leaves the worker alive; segmentation faults are the ASLR-guessing failures. On a pentest, never conclude "probe failed" from status code or worker liveness alone — process-level evidence (worker spawning a shell) is the ground truth. For version fingerprinting: CLI
php -vcan differ from the web runtime; read parser-error shapes and header surfaces, and remember vendors backport without version bumps. - The public PoC herd is noisy — triage before trusting it. Multiple public
poc0_109618.py/poc1_109619.py-style repos circulated, some using techniques later invalidated (Recorded Future). Field signals of the real campaign are already catalogued (public writeups carry the scanning UA and payload-drop patterns; botnet integration produced a likely Mirai variant). In-lab validation stays on owned VMs: single-worker prefork build,memory_limitvisible, inertid/marker command, no reverse bind outside your own listener.
Validation boundaries (authorized lab/scope only)¶
- Reproduction only on lab-owned PHP builds (Datadog's setup: one prefork worker, OPcache off, errors displayed). Inert marker commands only; no binds/ shells to non-owned hosts.
- Against customer scope: this class executes before logging/auth in the app — confirm written scope covers memory-corruption testing before touching it; a crashing worker is an availability incident on shared hosting.
- Memory-disclosure probes (PHPSESSID-style leak paths) can capture other users' data from worker memory; in scope, prove only with your own seeded marker values.
- KEV-watch: re-check on each scan; expect an entry imminently given ITW reports.
October 26 follow-up — KEV'd¶
Both CVEs entered CISA KEV on 2026-10-26 (catalog 2026.10.26, 1758 entries): CVE-2026-109618 "PHP Memory Corruption Vulnerability" and CVE-2026-109619 "PHP Stale Internal Request-State Vulnerability", both due 2026-10-29, forensic-triage required, ransomware-use Unknown, both carrying the same five-CWE stack CWE-416 / 755 / 824 / 1325 / 1419. CISA also published its own Critical Vulnerabilities in PHP alert on 10-21 — ahead of the KEV entry, unusual sequencing that confirms federal-level exploitation confidence.
Operator consequences of the KEV listing:
- The head-start window is closed. From here the herd mobilizes: expect commodity exploit traffic against every internet-facing
mod_php/FPM host within days. Richness now comes from unpatched-but-backported estates (7.4/8.0/8.1 with silent distro backports — version strings lie, parser-error-shape fingerprinting per axis 6 above is the real check), not from being early. - The multi-CWE label is the taxonomy tell. One CVE carrying uninit-pointer (824) + UAF (416) + unhandled-error (755) + two interaction/sequencing classes is CISA encoding the bailout-lifecycle bug class, not a single sink. For any runtime with a non-unwinding abort path (longjmp/setjmp, panic-with-cleanup, signal-based timeouts), the producer/consumer framing of CVE-2026-109619 — operation stashes state in a persistent global, fatal skips the reset, next request consumes it before init — re-applies. Grep runtimes for
longjmp/setjmpin allocator/error paths as a discovery axis on custom C/C++ SaaS stacks. - Forensic-triage + 3-day due date = federal estates are purging this class before month-end; on pentests against government-adjacent targets, PHP version evidence and multipart-shape request logs are now likely-triage items — collect parser-error-shape fingerprints as reachability findings rather than attempting the chain where scope excludes memory corruption.
- The ITW→KEV lag for this campaign was nine days (Oct 17 ITW → Oct 26 KEV), matching the Collabora calibration on the KEV page: days, not weeks. The RouterOS CVE-2026-84411 watch remains the oldest open ITW-confirmed un-KEV'd item.