On 21 October 2026 PHP shipped an emergency release that fixes two remote-code-execution bugs in the interpreter itself. Not in a framework, not in a plugin. In the runtime that every WordPress site, every Laravel app, and every five-line API sits on top of. Both carry a 9.2.
I could have read the advisory and written a summary. Instead I checked out 8.3.35 (broken) and 8.3.36 (fixed), built both from source on a Mac, and kept poking until the diff made sense. This is what I found.
If you only remember three things, make it these:
- CVE-2026-109618 — RCE via out-of-memory corruption. PHP bailed out of failed allocations with
longjmp(). When that happened while request structures were still half-built, shutdown read the invalid state. A craftedmultipart/form-databody triggers it, and unauthenticated RCE was demonstrated against Apachemod_phpbefore any application code ran. - CVE-2026-109619 — RCE via stale global state (use-after-free). The
session.upload_progresshandler runs before RINIT, deserialises the session, and on the vulnerable builds leaves$_SESSIONin the global scope. A persistent worker can later reach state that belongs to a freed request. - Fixed in 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12. Branches 7.4, 8.0 and 8.1 are end-of-life and get no upstream fix.

If you run PHP, do this now
In order:
- Check your branch against the matrix in §5.1. And read the vendor advisory carefully — some backports cover only one of the two CVEs.
- Upgrade and restart every worker. Old interpreters keep serving from memory until the process is recycled. Running
php -visn’t enough; compare worker start times against your upgrade time. - If you can’t patch today: disable
session.upload_progress(§5.2) and enforce body-size and part-count limits at the proxy (§5.2). These are stopgaps. They do not close the hole. - Hunt. §5.3 has the log and host signals. The ones that matter: memory-exhaustion errors during request parsing, unexpected child processes from
php-fpm, and new files undersession.save_path.
1. What I actually reproduced
Both runtimes, built locally:
PHP 8.3.35 (cli) (built: Oct 22 2026) # vulnerable |
Configured with --disable-all --enable-cli --enable-cgi --enable-fpm --enable-session --enable-filter --enable-tokenizer, then driven through sapi/cgi/php-cgi over stdin/stdout — the same way PHP’s own oom_rfc1867_*.phpt tests do it. Three things came out of that, all of them firing before any user code runs:
- The 109618 trigger. A multipart POST exhausts memory during request parsing and dies with
Fatal error … in Unknown on line 0. A marker file proves the target script never ran. - The 109619 primitive.
session.upload_progresswrites attacker-controlled bytes into the session file during request startup. - The 109619 A/B. The vulnerable build deserialises an attacker-seeded session into
$_SESSIONand leaks it into$GLOBALSbefore the script runs. The fixed build doesn’t.
What I did not do is publish a weaponised exploit. Both flaws need a second ingredient to turn the primitive into code execution — heap grooming for 109618, a POP chain for 109619 — and I flag that boundary deliberately, so it’s clear what is proven below versus what is analysis.
2. CVE-2026-109618 — OOM corruption
The trigger
Set memory_limit=4M, leave post_max_size and upload_max_filesize well above it, and send a single form field carrying 8 MB. The parser accumulates the value until it dies:
Fatal error: Allowed memory size of 4194304 bytes exhausted |
in Unknown on line 0 is PHP’s signature for “this happened inside the engine during request parsing, not in a script”. And because the failure lands inside rfc1867_post_handler, the target script never executes — I confirmed that by having the script append to a marker file. The marker stayed empty.
The vulnerable code
The function to look at is multipart_buffer_read_body() in main/rfc1867.c. On 8.3.35:
static char *multipart_buffer_read_body(multipart_buffer *self, size_t *len) |
erealloc() is the infallible entry point. On failure it doesn’t hand back NULL; it runs the OOM handler, which raises E_ERROR through longjmp() and jumps out of the current frame immediately. If that happens while the request structures are half-built — the $value buffer, the $_POST/$_FILES arrays, the multipart callback state — the longjmp skips every cleanup path. Pointers stay dangling, structures stay partially initialised, and request shutdown later reads or frees that invalid state. That’s the corruption primitive.
The fix in 8.3.36 swaps the infallible call for a fallible one and stops on failure:
while((read_bytes = multipart_buffer_read(self, buf, sizeof(buf), NULL))) { |
The same pattern repeats across the fixed rfc1867.c (20 ZEND_HAS_OOM() checks): after every allocation or variable registration, check the OOM flag and, if set, efree() and goto fileupload_done — a structured unwind instead of a longjmp into nowhere.
The allocator change underneath
Zend/zend_alloc.h grows a new API:
typedef enum _zend_oom_state { |
Conceptually it’s a two-tier memory limit:

- Under the soft limit (
memory_limit), allocation proceeds normally. - Cross the soft limit in a fallible context and you get
NULLplus a queued “delayed OOM” state, instead of a longjmp. Request code sees it throughZEND_HAS_OOM()and unwinds gracefully. - The hard limit (soft limit plus headroom) is where even fallible allocation can’t continue. Hitting it aborts cleanly, so the process isn’t left corrupted.
This is a systematic fix, not a one-liner. The 8.3.35→8.3.36 diff spans 416 files: zend_alloc.c (+651), zend_hash.c (+302), zend_string.c/h, zend_smart_str.c/h, and main/rfc1867.c (+215) carry most of it, with roughly 200 new oom_*.phpt regression tests across the extensions.
3. CVE-2026-109619 — stale global state
The primitive: a session file the attacker controls
session.upload_progress has been on by default since PHP 5.4. During a multipart upload, PHP writes progress metadata into the session, and the attacker controls both the session id and the PHP_SESSION_UPLOAD_PROGRESS value.
Sending PHPSESSID=deadbeefcafe1234 and PHP_SESSION_UPLOAD_PROGRESS=<?php system($_GET["c"]); ?> produced this on disk, during request startup, before the script ran:
upload_progress_<?php system($_GET["c"]); ?>|a:5:{s:10:"start_time";i:...;s:14:"content_length";i:1048912;s:15:"bytes_processed";i:1048912;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{...}}} |
The payload lands raw. That write primitive, paired with a local file include, is already RCE — a trick people have known about for years. But that is not what these CVEs are about.
The actual bug: deserialisation before RINIT
The handler in question is php_session_rfc1867_update() in ext/session/session.c, called from the multipart callback during request_parse_body() — before RINIT, before any of your code runs. On 8.3.35 it did:
php_session_initialize(); |
php_session_initialize() reads the session file and deserialises it, with full class resolution. I demonstrated this by seeding a session file and triggering an upload:
# BEFORE (seeded) |
The seeded record round-tripped, and the progress data got appended — proof the session was unserialised and re-serialised at startup, in a context where the session module’s globals haven’t been initialised for this request yet. In a persistent FPM worker those globals can still point at a freed previous request. That’s the “stale global state” the advisory is talking about.
The difference, side by side
I made the target script print session state. Same request, two runtimes:
Vulnerable 8.3.35:
has_SESSION=YES |
Fixed 8.3.36:
has_SESSION=NO |
The attacker-seeded evil => "OWNED!!" gets deserialised into $_SESSION during startup and leaks into $GLOBALS on the vulnerable build. The fixed build no longer leaves $_SESSION behind.
The fix
Three moving parts in ext/session/session.c. First, a new PS(in_rfc1867_update) flag forces the serializer into incomplete-class mode while the handler is active:
static void php_session_unserialize_init(php_unserialize_data_t *var_hash) |
That means no autoloading, no unserialize_callback_func, no __wakeup() or __unserialize(). Objects stay as __PHP_Incomplete_Class and get written back unchanged.
Second, the handler now checks for failure and resets globals early, and a re-entrancy guard stops it from running during request_parse_body() after modules are active:
if (PG(modules_activated)) { |
Third, after the handler finishes, the leaked global is deleted:
/* $_SESSION was populated in incomplete_classes_only mode */ |
This is also why session.upload_progress.enabled=0 helps for 109619 but is not a fix. The stale-state class is wider than the one confirmed path, and 109618 has a completely separate root cause.
4. Exploitation notes, stated plainly
Both flaws are real memory-safety and deserialisation issues. Turning them into code execution takes real work beyond triggering the primitive: heap shaping for 109618, a producer/consumer pair on a persistent worker for 109619. Proof-of-concept code has appeared in public since disclosure, so treat any unpatched, internet-facing PHP host as already exposed — not as “hard to exploit”.
What I deliberately left out of this post: a working exploit chain, heap-layout details, amplification techniques, evasion methods. Defenders don’t need any of that, and publishing it only helps the other side. If you’re testing your own systems, send a large multipart request at a test pool with a low memory_limit and watch your detection rules fire. You don’t need an exploit lab to validate a rule.
5. Remediation and detection
5.1 Patch matrix
Patching is the only complete fix. Install the security-fixed release for your branch and restart every PHP worker — the old interpreter keeps serving from memory until the process is recycled.
| Branch | Vulnerable | Fixed | Status |
|---|---|---|---|
| 8.5 | < 8.5.12 | 8.5.12 | upgrade |
| 8.4 | < 8.4.27 | 8.4.27 | upgrade |
| 8.3 | < 8.3.36 | 8.3.36 | upgrade |
| 8.2 | < 8.2.35 | 8.2.35 | upgrade |
| 7.4 / 8.0 / 8.1 | all | (EOL) | migrate to a supported branch, or use the exceptional source backports |
Patched ≠ safe (2026-10-22). Patching kills both CVE chains, but two independent teams report a var_destroy re-entry use-after-free that survives the fix — ASAN-confirmed alive on 8.4.27/8.5.12, reachable through attacker-controlled unserialize() input. A serialize-handler mismatch (a php_serialize-written session decoded by the php handler after an application-phase session_start()) still instantiates gadgets on patched builds, and the upload-progress key still lands verbatim in session files. So upgrade and guard every unserialize() call site, and pin one session.serialize_handler across every vhost and pool that shares a session.save_path.
Don’t trust the version string alone: distros backport fixes without bumping the upstream number. Verify the vendor advisory covers both CVEs, then confirm the running build:
php -v |
5.2 Partial mitigations (not fixes)
CVE-2026-109618 (OOM corruption): constrain the decoded body size, field count, multipart part/header count and nesting depth at the proxy, and block multipart entirely where it’s unnecessary. Do not remove or inflate memory_limit as a mitigation — it doesn’t close the vulnerability and it risks letting one request eat the whole host.
CVE-2026-109619 (stale global state):
; in php.ini / FPM pool, BEFORE request parsing (not via ini_set or .user.ini) |
Also fix attacker-reachable fatal errors and execution timeouts, and remove invalid or deprecated per-request INI directives that emit startup diagnostics.
5.3 Detection
These are hunting leads, not validated IOCs. Their absence doesn’t prove an unpatched runtime is safe.
Network / WAF:
- Anchor multipart detection on body structure (the boundary delimiter lines), not on the
Content-Typeheader. PHP’s SAPI parser is lenient about separators, so header-anchored rules can be bypassed. - Enforce byte budgets, part counts and header counts at the proxy. Some inspection engines only read the first N bytes of a body — raise the read limit to cover the full body budget.
- Rules keyed on the engine’s own multipart extraction stay empty when the engine can’t parse the boundary. Prefer raw-body scanning.
Host / SIEM (correlate worker behaviour with traffic):
Allowed memory size of ... exhaustedin PHP logs, inUnknown on line 0(parse-time, pre-user-code).- OOM-killer events, worker crashes/restarts,
segfaultinphp-fpm/httpdworkers. - Unexpected child processes from PHP workers (
sh,curl,wgetas children ofphp-fpm), or new/foreign files undersession.save_pathand web-accessible docroots. - Fatal errors, execution timeouts, startup diagnostics, deprecated INI warnings during request cleanup.
Workload protection / auditd: alert on php-fpm/apache2 spawning an interactive shell or opening outbound connections. Both CVEs end in a shell-out, which makes that the most reliable post-exploitation signal you have.

6. Reproduction appendix
Everything below ran on macOS 26.6 (ARM64) with Apple Clang 21, Homebrew bison 3.8.2, re2c 4.6, autoconf 2.73. One environment-specific gotcha: the default MacOSX27.0.sdk ships a libSystem.B.tbd with an arm64e.x1-macos architecture tag the bundled linker can’t parse, so I pointed the build at the older SDK:
export SDKROOT=/Library/Developer/CommandLineTools/SDKs/MacOSX26.5.sdk |
The harness posts multipart/form-data to php-cgi -C -n with CGI environment variables set, exactly like PHP’s oom_rfc1867.inc helper. The OOM trigger is a single 8 MB form field against memory_limit=4M; the session primitive uses a two-field preamble (PHPSESSID, PHP_SESSION_UPLOAD_PROGRESS) followed by a file part.
The patch diff came from:
git fetch --depth 1 origin tag php-8.3.36 |
The upstream regression tests that best show what the fix guarantees are ext/session/tests/rfc1867_no_user_code.phpt, tests/basic/oom_rfc1867_upload_progress.phpt, and Zend/tests/oom_request_parsing.phpt.
The reproduction environment — build scripts, the php-cgi harness, and the trigger demos — is open-sourced in z3vok/cve-lab under cases/2026-109618-109619/.
References
- CIRCL TR-102 — Critical RCE vulnerabilities in PHP
- GHSA-w8hw-w9fx-j79w — RCE via OOM corruption (CVE-2026-109618)
- GHSA-6wmf-4rv5-p3rp — RCE via stale global state (CVE-2026-109619)
- Datadog Security Labs — PHP2Shell: detection, analysis, remediation
- oss-security — CVE-2026-109618 announcement
- VirusTotal — sizing the PHP exposure window
- Upstream regression tests:
ext/session/tests/rfc1867_no_user_code.phpt,tests/basic/oom_rfc1867_upload_progress.phpt,Zend/tests/oom_request_parsing.phpt