Vulnerability Research
Two unauthenticated PHP RCEs: one longjmp, one leftover state
Your PHP site can be broken by a request carrying no credentials, even when every page demands a login. The flaw sits in how PHP handles a request, not in your code: exiting on memory exhaustion leaves half-initialised request data behind, and state carried across requests can point at memory already freed. Both fire before your code runs; exploitation is demonstrated against Apache mod_php.

In this article
On October 21, 2026, PHP shipped security releases across four branches fixing two remote code execution bugs. What makes these worth more attention than the usual PHP privilege or bypass issue is a shared property: the attack surface sits ahead of application code. Your login checks, permission gates and middleware never get to run; PHP has already broken something inside its own request handling.
PHP calls mod_php the highest risk and reports demonstrated exploitation there. Everything below comes from an actual comparison of php-src 8.5.11 and 8.5.12. Both tarballs match the SHA256 values php.net publishes, so this is not second-hand reporting.
1 The attack lands before your code does
Start by identifying which PHP SAPI serves your traffic. Apache mod_php comes first, with demonstrated exploitation. PHP-FPM, php-cgi, FrankenPHP and LiteSpeed are all in scope, so not running mod_php is not an exemption.
Why a login requirement changes nothing here: once a request reaches PHP, the order is fixed. PHP builds the request context, parses input and initialises extension state before handing control to your script. Both bugs fire inside that setup phase. Your session check, JWT validation and route guard have not executed yet.
NVD had no score for either CVE when this was written, so do not wait for one. The available evidence is enough to schedule work: no authentication required, triggering ahead of application code, and demonstrated exploitation against mod_php. That is not the same as confirmed in-the-wild attacks, and it does not mean your server has already been compromised.
2 One longjmp leaves a half-initialised request
CVE-2026-109618 is GHSA-w8hw-w9fx-j79w, titled RCE via OOM corruption. To see how memory exhaustion turns into code execution, look at what 8.5.11 actually did. This is Zend/zend_alloc.c from 8.5.11:
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();
exit(1);
}
The decisive line is zend_bailout(). It is not a normal return; it is a longjmp. That single line sets up everything that follows.
First, longjmp does not unwind the C stack. It jumps straight back to the setjmp landing point registered by zend_try. Every C frame in between is left exactly as it was: local variables, temporary C structures, containers mid-modification. Nothing cleans them up and nothing is notified. This is not the mild problem of a leak; it is letting go halfway through a state change.
Second, the moment of triggering is the worst possible one. OOM happens when an allocation fails, and allocation runs throughout request handling. At that instant PHP might be parsing a multipart body, inserting a field into a hash table, or writing the type tag of a zval before writing its value. All of these are multi-step, and stopping one step short leaves a half-built object.
Third, the landing point keeps using those half-built objects. Control moves into request shutdown. Shutdown exists to clean up, and it works from an assumption that these structures are complete. So it walks the unfinished ones and reads uninitialised zvals, pointers into freed memory, and strings whose length field does not match their contents.
The official advisory states this directly: PHP uses longjmp for out-of-memory errors to exit the current context and abort request handling, and if an allocation fails while internal request data is only partially initialised, subsequent cleanup code operates on invalid data.
2.1 The step from there to code execution
Reading invalid data is undefined behaviour, which is not yet code execution. The advisory does not spell this out, so the following is this article's inference, labelled to keep it separate from official statements.
PHP represents values internally as zvals: a type tag plus a union interpreted according to that tag, often as a pointer. When shutdown reads a half-initialised zval as if it were valid, PHP trusts the type tag and interprets the union accordingly. If the tag says "string", PHP takes the union's pointer as a char* and reads its length and contents.
The part that matters: the attacker influences what sits in that memory. Controlling request body size, form field count, multipart part count and header count is controlling allocation timing and heap layout. OOM interrupts a structure mid-build, and the attacker has a say in what occupies and reuses that memory afterwards. So PHP holds something it believes is a zval while reading bytes the attacker arranged. A pointer gets dereferenced as valid, a length gets walked as valid, a type tag gets trusted. Follow that to its end and you have arbitrary memory read and write; go further and you have control-flow hijacking.
So the nature of this bug is not "running out of memory produces an error". It is "the way PHP exits on memory exhaustion breaks the consistency of request state, and the code that follows trusts that consistency".
3 The previous request's memory is gone; its state is not
CVE-2026-109619 is GHSA-6wmf-4rv5-p3rp, titled RCE via stale global state. The patch exposes the mechanism clearly. Version 8.5.12 adds this comment and function to ext/session/session.c:
/* The upload progress handler (php_session_rfc1867_update()) decodes and
* re-encodes the session during request startup, before RINIT, when no user
* code may run. Objects are then kept as __PHP_Incomplete_Class: no
* autoloading, no unserialize_callback_func, no __wakeup() or __unserialize(),
* and they are written back unchanged. */
static void php_session_unserialize_init(php_unserialize_data_t *var_hash)
{
PHP_VAR_UNSERIALIZE_INIT(*var_hash);
if (PS(in_rfc1867_update)) {
php_var_unserialize_set_incomplete_classes_only(*var_hash, true);
}
}
That comment describes a specific ordering problem. The session upload progress handler has to decode and re-encode the session before RINIT. No user code may run that early, so there is no autoloader, no unserialize_callback_func, no __wakeup() and no __unserialize(). A session can hold objects. The fix keeps those objects as __PHP_Incomplete_Class and writes them back untouched instead of disturbing them.
The advisory describes the wider class: PHP retains several kinds of global state across requests in a worker, including error, session, object and extension state. It is normally reset at request end, but a fatal error, an execution timeout, or use of that state before the reset lets the next request pick up what the previous one left behind.
3.1 Why leftover state becomes code execution
The core issue is that state and the memory it depends on have separate lifetimes. One request allocates memory to hold state; when the request ends that memory is freed, but the global field still points at it. The next request initialises in stages, and some code path may read that field before the reset happens.
What it reads is freed memory. If that memory has already been reused by a later allocation, the "old state" now points at new data the attacker may influence. PHP interprets that new data according to the old state's type tag, landing in exactly the same position as the previous bug: the tag is trusted, the union is interpreted as the wrong type, a pointer is dereferenced. Same failure mode, different entrance.
The patch closes two confirmed paths: session upload progress and fatal backtrace (8.5.12 adds a fatal_error_backtraces switch in Zend/zend.c, default 1). PHP states plainly that other variants beyond these two remain, which is why the mitigations do not amount to safety.
4 What the fix does: moving the interruption off the allocation path
The 8.5.12 fix is worth reading because it does not add a check where things went wrong; it replaces the exit mechanism. The design itself states where the authors believe the problem lives. Three pieces, three levels:
// Soft OOM: record only, no jump (Zend/zend_alloc.c)
static ZEND_COLD void zend_mm_delayed_verror(const char *format, va_list args)
{
if (ZEND_HAS_OOM()) return;
/* record message, filename, lineno */
EG(delayed_oom).message = zend_string_init(buf, strlen(buf), /* persistent */ true);
GC_MAKE_PERSISTENT_LOCAL(EG(delayed_oom).message);
EG(delayed_oom).filename = script_filename;
EG(delayed_oom).lineno = script_lineno;
EG(delayed_oom).state = ZEND_OOM_STATE_PENDING;
zend_atomic_bool_store_ex(&EG(vm_interrupt), true);
}
// Handle only at a VM safe point (Zend/zend_alloc.c)
ZEND_API void ZEND_FASTCALL zend_handle_delayed_oom(void)
{
zend_oom_info *info = &EG(delayed_oom);
zend_execute_data *execute_data = EG(current_execute_data);
if (info->state != ZEND_OOM_STATE_PENDING) return;
info->state = ZEND_OOM_STATE_HANDLING;
AG(mm_heap)->overflow = 1;
/* transfer message to request memory, unwind, then raise E_ERROR */
if (execute_data) {
EG(exception) = NULL;
zend_throw_unwind_exit();
}
zend_try {
zend_error_zstr_at(E_ERROR, info->filename, info->lineno, info->message);
} zend_catch {
} zend_end_try();
/* function may be entered from the VM interrupt handler */
}
// When even that fails, die immediately without cleanup (Zend/zend_alloc.c)
if (!heap->fallible && add_size > heap->hard_limit - heap->real_size) {
zend_mm_hard_limit_error(heap, add_size ZEND_FILE_LINE_RELAY_CC);
}
The logic is coherent. A failed allocation no longer changes control flow immediately; it records the failure in EG(delayed_oom) and raises a VM interrupt flag, so the current multi-step operation can finish instead of being yanked away mid-way. Handling happens at fixed VM checkpoints, called from Zend/zend_execute.c, Zend/zend_execute_API.c and Zend/zend.c at points the interpreter knows to be consistent, and it runs through zend_throw_unwind_exit() plus the normal E_ERROR path so the stack unwinds properly. The hard limit is the backstop: zend_mm_hard_limit_error() calls _exit(137) without running shutdown at all, because running cleanup would put you back in front of possibly inconsistent data. The process dies rather than read a half-built object.
The release also adds a fallible allocation family (femalloc, fecalloc, ferealloc) that returns NULL on failure for the caller to handle instead of jumping, plus two INI entries: memory_hard_limit_percent (unset by default, must be set explicitly and must be 100 or greater, or -1) and max_memory_limit (default -1, system level only).
Reading the fix tells you the authors' view of the root cause: the problem was never a missing check at one allocation site, but that "allocation failed" and "control flow is interrupted" had been welded into a single event. Break that weld and the problem goes away. That judgement is worth more than the patch itself.
5 Why mod_php is the worst case
PHP says mod_php is highest risk with demonstrated exploitation, but the advisory does not explain why. The following is this article's inference, marked so you can weigh it yourself.
mod_php embeds the PHP interpreter inside Apache worker processes, binding the request lifecycle to Apache's process model. PHP-FPM, by contrast, uses a separate worker pool with a clear boundary. Under mod_php, PHP's request data structures and Apache's request structures are allocated interleaved within one process, so when OOM interrupts things mid-build, the half-initialised state can involve not just PHP's EG() globals but Apache-side structures too. And mod_php lacks FPM's explicit worker isolation to limit the blast radius of leftover state.
PHP put its demonstrated exploitation on mod_php, which is consistent with this reading.
6 What WordPress users need to watch
The first question most people ask is whether the bug needs WordPress cooperation to be exploitable. The answer is no cooperation required, but WordPress makes it easier to hit. Those are two separate claims.
Take the first one. Both bugs trigger inside PHP's request setup, the phase that builds request context, parses input and initialises extension state. WordPress code only starts after that finishes. So whether WordPress has a login check, a nonce or a capability test is irrelevant; the attack happens ahead of all of them. This is a different kind of issue from the vulnerabilities in WordPress itself that require authentication.
Now the part where WordPress does matter, found by reading the WordPress 6.9 source: wp-includes/default-constants.php checks whether memory_limit is changeable and, if so, sets the limit to 40M for a single site or 64M for multisite, then calls ini_set('memory_limit', WP_MEMORY_LIMIT). The same file defines WP_MAX_MEMORY_LIMIT for admin work, defaulting to 256M.
That ini_set matters because CVE-2026-109618 triggers on a failed allocation, and WordPress actively installs a finite, fairly low ceiling back into the runtime. A tighter ceiling means failed allocations are easier to reach, so that window where something is interrupted mid-initialisation opens more often. WordPress is not a precondition for the bug; it is a risk multiplier.
This also explains why the official mitigation insists on php_admin_value to pin memory_limit=-1 rather than using ini_set. php_admin_value makes the setting unchangeable, so WordPress's own wp_is_ini_value_changeable('memory_limit') test returns false, it defines WP_MEMORY_LIMIT as the current value, and it stops putting 40M back. Set memory_limit=-1 in php.ini without locking it and WordPress will happily drop it to 40M again, which makes the mitigation pointless. This is the easiest step to get wrong in practice.
As for the CVE-2026-109619 session path, WordPress barely touches it. In the 6.9 source, session_start appears only in site-health explanatory text; WordPress authentication uses cookies and the database, not PHP native sessions. So session.upload_progress.enabled=0 buys WordPress little. The fatal_error_backtraces path still applies, because plugins and themes hit fatal errors routinely, especially during uploads, imports and cleanup.
The practical conclusion for a WordPress site: upgrading PHP is the priority, and do not expect anything at the WordPress layer to block this. Among the temporary mitigations, pinning memory_limit so it cannot drop back to 40M is the step that actually helps. Add the session switch if convenient, but do not rely on it.
7 Method: how to find bugs of this class yourself
These two bugs did not come out of a fuzzer. They are products of one auditing approach. The five methods below are directly reusable, and worth more than memorising two CVE identifiers.
7.1 Method one: audit paths that exit without cleanup
The question to answer: which code paths leave the current context while a data structure is inconsistent, and which later code still assumes consistency?
List every non-local jump primitive in the process. In C that is longjmp and setjmp; inside PHP it is zend_bailout(). For each primitive, find all trigger sites by searching zend_bailout, zend_error_noreturn(E_ERROR) and exit(). Then check whether those sites sit midway through a multi-step modification, the usual patterns being allocate-then-assign-then-register, a hash table mid-rehash, or an object built but not yet attached to a container. Finally check what the landing point does; if it is cleanup code that walks the possibly half-initialised structures, that is a candidate.
There is one decisive test: does this jump skip cleanup on the C stack? If it does, the resources owned by those frames are not just memory but locks, reference counts and registry entries, all left unattended.
That is exactly how this bug surfaces: zend_bailout() inside zend_mm_safe_error() sits on the allocation-failure path, meaning the moment memory is already tight, which is precisely when a multi-step construction is most likely in progress.
7.2 Method two: audit the separation between state lifetime and memory lifetime
The question to answer: which fields have a different lifetime from the memory they point at?
Find every worker-level state structure retained across requests. In PHP that is EG(), PG(), PS() and CG(). For each field ask two questions: who allocates and who frees this memory, and who resets this field. If the free site and the reset site are in different places, or the free happens before the reset on even one branch, that is a candidate. Then check request initialisation for any use-before-reset path, paying special attention to code that runs before RINIT, since normal reset has not been reached yet.
The decisive test: is there code that reads this field before the reset? If so, ask who owns that memory now.
That is how this one surfaces: php_session_rfc1867_update() decodes the session before RINIT. No user code may run that early, yet the session holds objects, so those objects exist in a semi-legal state during deserialisation while the class definitions and autoloader they depend on are not ready yet.
7.3 Method three: reason backwards from the patch, the most reliable verification
When you suspect a component has an issue of this class but are unsure, diff the official source of the two versions around the fix. Verify hashes first: releases/index.php?json&version=X on php.net gives the official SHA256, and if it does not match, stop there. Then group the differences by directory. This release changes 287 files, but the security-relevant ones cluster in Zend/zend_alloc.c, ext/session/session.c and main/main.c.
Next, read the new function and INI option names. New names expose intent: delayed_oom, hard_limit, in_rfc1867_update, incomplete_classes_only, each pointing at a specific inconsistency window. Then look at which tests changed; files like Zend/tests/gh11189.phpt and get-return-after-bailout.phpt reproduce the pre-fix behaviour. Finally compare the old and new paths: the difference is itself a description of what used to be wrong.
7.4 Method four: prove you can reach an inconsistent state, without a full exploit
Confirming a bug of this class exists does not require a complete exploit chain. It requires proving an inconsistent state is reachable.
Make an allocation fail at a chosen point. In PHP that can be a tiny memory_limit or a fallible API. Then make it fail midway through a multi-step construction by controlling request body size, form field count, multipart part count and header count, all quantities an attacker controls precisely and all of which shift allocation timing. Then watch whether the cleanup phase reads anything odd: build with USE_ZEND_ALLOC=0 plus Valgrind, or with ASAN, and you will see read of uninitialized value or use after free directly.
If the cleanup phase reads a zval whose type field is a value the attacker can influence, you have found the road to RCE.
7.5 Method five: watch for the idea of a safe point
The fix introduces safe points, handling OOM only where the VM is known to be consistent. That points at a general class of audit question: for any deferred handling mechanism, is the state guaranteed consistent at the moment of handling?
Signal handling is the same question: a signal arrives at an arbitrary instruction boundary, so is the data structure the handler touches safe? Request cancellation is the same question: when an operation is cancelled, who cleans up the intermediate state it was building? Execution timeout is the same question, with an arbitrary interruption point, and PHP groups timeouts with fatal errors in CVE-2026-109619's trigger conditions for exactly this reason.
8 What to do now
Install the release for your branch: 8.5.12, 8.4.27, 8.3.36 or 8.2.35. If you are still on 7.4, 8.0 or 8.1, PHP provides minimal source backports, and your distribution or LTS vendor may carry its own. Note that 8.1 and earlier are no longer maintained by the PHP project, so a source patch this time does not mean the old branch is back in support.
If you deploy from rpms.remirepo.net, do not wait on it: that volunteer-maintained repository will not update the same day, and PHP recommends distribution packages or building it yourself.
When you cannot upgrade immediately, apply the interim mitigations per CVE. For CVE-2026-109618 the idea is to keep PHP off that allocation-failure path: pin memory_limit=-1 through php_admin_value, confirm no pool, directory or application sets it back to a finite value, then constrain memory from outside using containers or cgroups, ideally killing only the affected worker. Do not use RLIMIT_AS or RLIMIT_DATA to lower the process ceiling; they make PHP allocations fail, which triggers the vulnerable path. At the same time, limit decoded request size, field count, multipart part count, header count and nesting depth at the web server or proxy, and make sure requests without a Content-Length are limited too.
For CVE-2026-109619 the idea is to close the two confirmed entrances: session.upload_progress.enabled=0 and, on PHP 8.5, fatal_error_backtraces=0. Both must take effect before request parsing, so they belong in php.ini, an FPM pool or server configuration; ini_set() and FPM's .user.ini are too late. Restart all workers afterwards. Both switches close only the confirmed paths, not every variant.
PHP states plainly that updating is the only complete fix. The reason is in the fix described above: what needs repairing is how the runtime handles request state, not a switch on one entrance. Close one known entrance and the other variants are still there.