On October 20, 2026, word leaked that PHP, the language running roughly three quarters of the web’s known server-side code, had two unauthenticated remote-code-execution bugs in its core and that a working exploit tool already existed. The details were supposed to stay under embargo until the official release, but they didn’t. Exploitation attempts showed up before the patch did, major Dutch sites pulled PHP offline as a precaution, and within a day PHP shipped fixed releases for every supported branch.
The two bugs are CVE-2026-109618 “RCE via OOM corruption” and CVE-2026-109619 “RCE via stale global state”. We’ll walk through how they work, down to the source lines, and then answer the question we kept asking ourselves while reproducing this in the lab: why did this class of bug exist in PHP for two decades before anyone could turn it into a pre-auth RCE?
Background: how PHP runs a web request
Both bugs only make sense once you hold three facts about PHP’s execution model in mind at once. None of them is exotic, they’re just rarely stated together.
First, a web request has a strict lifecycle. The SAPI (mod_php, FPM, cgi) accepts a connection and calls php_request_startup(): extensions get their RINIT, output layers activate, and critically the request body is parsed, before any application code runs. The script executes, then php_request_shutdown() tears everything down. On the happy path this cycle is airtight: startup completes, shutdown cleans up, in that order.
Second, workers are long-lived but requests are not. A mod_php child or FPM worker serves thousands of requests in one process lifetime. At every request boundary the request heap: every emalloc‘d string, array, object of that request is freed wholesale. But the process’s global state (executor globals EG, session globals PS, core globals PG) persists across requests. Anything that stores a request-heap pointer into one of those globals creates a pointer that outlives the memory it points at, unless something resets it in between. Normally, something does.
Third, memory_limit is enforced by unwinding, not by refusing. PHP’s allocator (ZendMM) tracks every request allocation against a per-request cap. When the cap is hit, the engine doesn’t return an error code to the caller: it aborts the request by jumping. The jump mechanism is zend_bailout(), the longjmp() half of PHP’s zend_try/zend_catch error-control flow, which has been the engine’s answer to “C has no exceptions” since the PHP 4 era. An abort can therefore land at essentially any allocation site in a million lines of C, including, as it turns out, in the middle of request startup, where the state being built is reachable from shutdown but not yet valid.
Hold those three together: a lifecycle that assumes orderly startup-then-shutdown, globals that outlive requests, and an abort that can fire anywhere, then you can see the shape of the problem before we name the CVEs: if the abort fires during startup, the orderly part of the lifecycle never happens, but the globals and the shutdown code still do.
What the two CVEs are…
CVE-2026-109618, the longjmp problem: When PHP exhausts its memory_limit, the allocator historically raised a “safe error”: it printed a fatal error inside a zend_try { } block and then called zend_bailout() , which is a longjmp(), zend_mm_safe_error. A longjmp out of a context that was mid-initialization leaves that context’s memory half-built. And if the request gets shut down anyway, the shutdown code walks that half-built state as if it were valid. On Apache mod_php, that’s exactly what happens: the request shutdown routine runs unconditionally, even when request startup failed.
CVE-2026-109619, the stale global problem. PHP workers are persistent: one process serves thousands of requests, and its global state (executor globals, session globals) survives between them. Some operations publish request-owned pointers into those worker globals and some operations consume them before the next request’s normal initialization would have replaced them. If a fatal error interrupts that publish-and-reset dance, the global keeps pointing into memory that’s already been freed with the request. The next request that touches the global, before initialization, dereferences freed memory: a use-after-free whose contents an attacker can reclaim. The mental model that finally made this click for us: the bug doesn’t live in one request. It lives between two requests, carried by a worker’s globals.
The bug in code
Three functions carry the whole bug. The first is the allocator’s OOM handler, the mechanism behind the abort we just described. This is the old handler, removed by the patch:
static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
const char *format, size_t limit, /* ... */ size_t size)
{
heap->overflow = 1;
zend_try {
zend_error_noreturn(E_ERROR, format, limit, /* ... */ size);
} zend_catch {
} zend_end_try();
heap->overflow = 0;
zend_bailout(); /* <-- longjmp out of the current context */
exit(1);
}
src/php-8.5.11-src/Zend/zend_alloc.c:395-420The bug is in the control flow, not in any single line. The function raises a fatal error through PHP’s error machinery, which itself allocates memory, hence the heap->overflow guard, and then calls zend_bailout(), the longjmp() from the background section: an unwinding jump to whatever zend_try frame is nearest. The implicit assumption is that any context reaching OOM can safely unwind from that point. For most contexts, that holds. For request startup, it doesn’t, and the chain section shows exactly who trips over the wreckage.
The patch replaces this with a delayed OOM (record the error, raise a flag, let the VM handle it at the next safe point) and a hard limit that simply terminates:
/* new in 8.5.12: soft OOM is deferred, not unwound */
EG(delayed_oom).message = zend_string_init(buf, strlen(buf), true);
EG(delayed_oom).state = ZEND_OOM_STATE_PENDING;
zend_atomic_bool_store_ex(&EG(vm_interrupt), true); /* handled at safe point */
/* new in 8.5.12: hard limit kills the process instead of unwinding */
_exit(137);
src/php-8.5.11-src/Zend/zend_alloc.cNo longjmp, no half-built state for shutdown to trip over. The error still gets reported, it’s just no longer delivered by jumping out of whatever the engine was mid-way through.
The second function is the consumer side of the stale-state bug, one layer up in the session module. It’s the code that dereferences one of those dangling worker globals, before the next request’s initialization would have replaced it:
static void php_session_track_init(void)
{
zval session_vars;
zend_string *var_name = ZSTR_INIT_LITERAL("_SESSION", 0);
/* Unconditionally destroy existing array -- possible dirty data */
zend_delete_global_variable(var_name);
if (!Z_ISUNDEF(PS(http_session_vars))) {
zval_ptr_dtor(&PS(http_session_vars)); /* <-- stale pointer */
}
/* ... */
}
src/php-8.5.11-src/ext/session/session.c:253-262The comment even hints at the hazard “possible dirty data” but the code only guards against stale arrays, not against the pointer itself being stale. PS(http_session_vars) is a worker global; after the abnormal request death it points into the freed request heap, and zval_ptr_dtor runs on it anyway. That’s the exact instruction our gdb session caught freeing a live $_POST key string.
The third is the 8.5-specific producer/consumer pair in Zend/zend.c, the same shape in a different subsystem. PHP 8.5 made fatal-error backtraces default-on, and that convenience feature stores request-owned data in a worker global:
/* producer: any fatal error stores a request-owned backtrace
into a WORKER global (8.5 default: fatal_error_backtraces=1) */
} else if (EG(fatal_error_backtrace_on) && (type & E_FATAL_ERRORS)) {
zend_fetch_debug_backtrace(&EG(last_fatal_error_backtrace), 0, ...);
}
/* consumer: the NEXT error report — fatal or not — drops it */
zval_ptr_dtor(&EG(last_fatal_error_backtrace));
ZVAL_UNDEF(&EG(last_fatal_error_backtrace));
src/php-8.5.11-src/Zend/zend.cProducer and consumer, three lines apart in the same file; and 8.5.11 never resets the global at shutdown. The 8.5.12 patch adds the reset with a comment that reads like a confession: “may be used by zend_error_zstr_at before init_executor”. When the fix’s own comment describes the bug, you know the maintainers understood exactly what they were closing.
How it works: the chain, stage by stage
Everything hinges on one constraint, easy to miss in prose: the producer and consumer requests must land on the same worker, that’s what turns two harmless-looking requests into one use-after-free.
Stage 1. Making PHP run out of memory without breaking any rules
Every PHP install has two size limits for incoming data. post_max_size (8 MB by default) caps how big a request body may be, and memory_limit (128 MB by default) caps how much memory PHP may use while processing it. The trick is to blow past the second without ever tripping the first and the advisory describes three ways to do it, all of which we confirmed in source and in the patch’s own regression tests.
The first exploits a bookkeeping gap. post_max_size is checked against the Content-Length header, the number the client declares. A request sent in chunks (HTTP “chunked” encoding) simply has no such header, so the check passes and PHP keeps reading whatever shows up. We’ve watched real servers happily parse chunked bodies this way.
The second is a compression trick. One part of a multipart form may carry an effectively unlimited number of header lines. Each line costs about 12 bytes on the wire, but inside PHP each one becomes a copied key, a value buffer, and a linked-list entry, roughly eight times the space. So 19 MB of wire headers is enough to exhaust the default 128 MB memory limit. The patch added a regression test for exactly this scenario.
The third rides a PHP feature most sites never use: upload progress. When it’s on (the default), every file in an upload makes PHP decode and re-encode the entire session, thousands of times over for a large upload, until memory runs out [V].
The result looks like this:
Fatal error: Allowed memory size of 134217728 bytes exhausted
(tried to allocate 4096 bytes) in Unknown on line 0The telling part is in Unknown on line 0: no script was running. PHP died before the application even started; the memory ran out while PHP was still reading the request. The first time we saw that line come back from a server we didn’t build, it was a strange feeling: the machine is telling you, in its own error format, that you’ve exhausted it before its code even woke up.
Stage 2. The cleanup that shouldn’t happen, but does
When PHP aborts a request mid-startup, what happens next depends entirely on the web server setup and this is the pivot of the whole exploit:
Apache with mod_php runs its normal end-of-request cleanup anyway, even though startup failed. That cleanup now walks over data that was only half-built.
PHP-FPM does the opposite: the worker process simply exits, taking the broken state with it. Nothing dangerous survives.
php-cgi behaves like FPM.
That’s why the vendor rates mod_php highest, and why the generic, works-anywhere exploit is a mod_php exploit. FPM can still be exploited, but only through the second bug’s application-phase path (below).
Stage 3. The worker remembers things it shouldn’t
Here’s where the second bug comes in. PHP workers are long-lived: one process handles thousands of requests, and some of its internal bookkeeping: session data, IDs survives between them. During request startup, PHP’s upload-progress feature writes a few pointers into that long-lived memory: where this request’s session data lives, what its session ID is. Normally, cleanup wipes those pointers at the end of the request. On PHP 8.5 there’s a second way in: any fatal error saves a debugging backtrace into the same kind of long-lived storage.
Stage 4. The cleanup that never runs
The cleanup that would erase those pointers never gets its chance. The abort happened before the parser could signal “request finished,” and the session module’s shutdown routine doesn’t run because extensions were never activated. So mod_php frees all of the request’s memory and the worker’s long-lived pointers now aim at memory nobody owns anymore. That’s the whole bug in one sentence.
Stage 5. The next request stumbles into the trap
The attacker sends a second, ordinary-looking request to the same worker. It only needs two ordinary form fields: no files, no oversized anything. Because of how PHP processes uploads, this request touches those stale pointers before the normal startup code would have replaced them. The engine dereferences memory that was freed one request ago.
Watching it happen
This is where we stop summarizing the advisory and show what we saw ourselves. We built a lab based on PHP 8.5.11, mod_php, a single worker, completely default settings and attached a debugger. The stale pointer didn’t point at garbage. It pointed at memory the next request had already reused for its own data, specifically, the name of one of its form fields. PHP then “cleaned up” that pointer anyway, freeing memory the running request was actively using. Seeing that in a debugger was the moment the advisory stopped being abstract.
You don’t even need a debugger, the corruption is visible over plain HTTP. After the two requests, the server’s response contains fragments of raw memory: internal headers, addresses, leftover bytes. That’s an information leak, delivered in the response body.
Two properties make this practical rather than theoretical. First, PHP’s memory manager restarts each request from the same memory layout, so the corruption reproduces identically run after run. That determinism surprised us; we expected heap exploitation to be flakier. Second, the attacker can choose what lands in the freed memory: by shaping the form fields of the second request, we made the leaked bytes spell out our own marker strings . The advisory’s “strategically crafted heap layout” is exactly this.
Detection
If you’re defending PHP infrastructure, here’s what to watch:
Log signature (precondition firing).
Allowed memory size of ... in Unknown on line 0showing up at request startup, especially if it repeats, means someone is exercising the CVE-2026-109618 precondition against you. We’ve verified this signature is produced by the attack shape above on vulnerable builds.Configuration exposure. The patch’s own regression tests enumerate the risky settings:
session.upload_progress.enabled=1(default-on, the pre-init consumer), finitememory_limitwith defaultpost_max_size(the controllable abort), and on 8.5fatal_error_backtraces=1(the backtrace producer/consumer pair). Any of these on an unpatched, internet-facing stack is exposure.Runtime (post-exploitation). The end state of this exploit class is a webshell: alert on PHP worker processes (
php-fpm,httpd,apache2,php-cgi) spawning shells, interpreters, or download tools, the Huntress/Florian Rothprocess-watcherpattern.
Why it survived for two decades
The reachability of this class is an intersection: a SAPI that mishandles failed startup (or an app-phase fatal), a default-on pre-init feature, a heap layout an attacker can shape, and a consumer that fires before initialization. Remove any one and the bug is a crash or a leak, not RCE. Fuzzers struggle with it precisely because the state spans two requests on the same worker, the php-src fuzzer SAPI exercises single requests. And the people best positioned to find it (Stefan Esser’s 2007–2010 era of engine-level UAF research) moved on; the 2017 policy decision to stop treating unserialize() bugs as security issues tells you where the community’s attention went instead, away from engine-internals memory corruption.
Why now? In Project Zero’s RCA template terms “thoughts on how this vuln might have been found” the honest answer has three parts. First, the fuzzer blind spot: the vulnerable state spans two requests on the same worker, and php-src’s fuzzer SAPI exercises single requests, so coverage tooling structurally misses it. Second, variant analysis: the bug class (producer publishes request-owned state into worker globals, interruption skips the reset, consumer fires pre-init) is enumerable once someone names it, the session-upload-progress and fatal-backtrace pairs are instances of the same shape. Third, the reporter credits on the advisories lcfr-hax, sagitzadik-wiz (Wiz), tomash-areion, amyb-oai alongside Calif.IO’s published “Month of AI-Discovered Bugs” methodology, suggest the discovery cost of this bug class just dropped. The bugs were always there; the search changed. We’d put money on more engine-level UAFs surfacing this way in the next couple of years, not because PHP got worse, but because looking got cheaper.


