PHP said a piece of memory was ready. It wasn't. Then the next bit of code believed it.
When I saw PHP's October 2026 fixes for CVE-2026-109618 and CVE-2026-109619, I wanted to know where that promise broke. We use PHP across Antsand and generated sites. Rather than argue about languages, I pulled the failure apart: what was allocated, what was marked ready, what happened when allocation stopped, and what survived into the next request. There is a small C program below so we can watch the mistake happen without needing a PHP exploit.
First: what actually happened?
On October 21, PHP released 8.2.35, 8.3.36, 8.4.27, and 8.5.12 to fix two critical remote-code-execution vulnerabilities. The PHP project rates the risk particularly high for Apache mod_php, where request startup failure can still enter request cleanup before application PHP executes. PHP-FPM has a different startup failure path, but a fatal after successful startup can leave a worker alive for another request. Neither “we use FPM” nor “our PHP controllers are careful” is a substitute for patching the interpreter. See the PHP security announcement and the researchers' technical write-up.
| Finding | Broken assumption | Where the damage appears |
|---|---|---|
| CVE-2026-109618 | OOM may interrupt native code between publishing a structure and completing it. | Cleanup reads partly initialized request memory as though it were valid. |
| CVE-2026-109619 | A persistent worker global may still point into a request heap after that heap is freed. | A later request accesses stale state before normal initialization replaces it. |
I found it helpful to keep two questions in mind: “What happens if construction stops halfway through?” and “Who still holds this pointer after the request ends?” The first finding lives in the first question; the second lives in the second. Two layers I maintainPHP runtime and Antsand codePatching php-src handles the engine bug. Our checkout and reader authorization still need their own tests.
The timeline is older than this week's patch
Stefan Esser's 2004 advisory described a memory-limit interruption during HashTable initialization in older PHP releases. The specific bug there was an incompletely initialized table and potentially controlled destructor pointer. In 2020, bug #78876 recorded OOM while parsing long multipart variables and a temporary-file cleanup problem. A 2024 audit identified a related issue without proving exploitation, according to Calif's account. The 2026 work connected memory-pressure producers to interruption points and viable consumers across real SAPIs. These are not one continuously unfixed CVE; they are separate episodes with a recurring engineering question: what if an allocator aborts in the middle of a state transition?
The linked archived Immunity paper is part of that historical trail, but I could not verify its contents from the archived copy while preparing this post. I do not use it as evidence for the 2026 mechanism. The separate May 2026 unserialize() use-after-free study is also worth reading: it examines a missing serialization lock around a user-defined Serializable callback, with a much narrower remote precondition. It is not the same defect as the October vulnerabilities discussed here.
Step 1: the wire can be smaller than the heap it creates
Why can a web request reach an allocation failure at a useful point? The wire size is not the same as the in-memory expansion. Multipart parsing can turn a compact, nested field name into multiple arrays, hash tables, keys, and metadata entries. The official advisory identifies multiple ways that input can drive memory pressure, including requests without a useful declared content length, many multipart headers, and empty file controls that still cause metadata allocation. The principle is simple: bounded input bytes do not imply bounded native allocations. Limits at the proxy and the interpreter must cover decoded bytes and structural counts, not only Content-Length. See the PHP OOM advisory.
\[M_{\mathrm{request}} \approx M_{\mathrm{body}} + \sum_i M_{\mathrm{keys},i} + \sum_i M_{\mathrm{buckets},i} + M_{\mathrm{metadata}}\]
This is an accounting model, not a measured exploit formula. The important inequality is that \(M_{\mathrm{request}}\) can be much larger than \(M_{\mathrm{body}}\).
Step 2: a HashTable advertises a bucket too early
The researchers highlighted a concrete ordering in _zend_hash_str_add_or_update_i(). An abbreviated pre-fix code path increments the used-entry count, then allocates the new key, then writes the bucket fields. That key allocation is fallible. If OOM bails out, the later writes never run. Teardown iterates according to the count and assumes the new bucket is initialized. Here is a compact excerpt, with comments added for the invariant; see the research write-up for source context.
idx = ht->nNumUsed++; /* bucket is now visible to teardown */
ht->nNumOfElements++;
p = ht->arData + idx;
p->key = zend_string_init(str, len, persistent); /* allocation may abort */
p->h = ZSTR_H(p->key) = h;
ZVAL_COPY_VALUE(&p->val, pData);What should be true at every point? If nNumUsed = n, each bucket in [0, n) must be safe for the destruction path to inspect. The code temporarily makes the count say n+1 before that is true. This is a transaction problem as much as an allocation problem: public state says “committed” while construction is still underway.

Make the invariant fail in a tiny C program
We write enough C in our projects that I wanted a version I could run and stare at. This small program models the ordering problem; it is our code, not a copy of php-src. Instead of trying to exhaust the machine's memory, copy_key() takes a fail flag and returns NULL right where an allocation would fail. That makes the interesting moment reproducible every time.
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char *key;
bool ready;
} Bucket;
typedef struct {
Bucket slots[2];
size_t used; /* Teardown assumes every slot below used is ready. */
} Table;
static char *copy_key(const char *key, bool fail)
{
/* Force failure at the interesting point; do not exhaust real RAM. */
if (fail) return NULL;
size_t bytes = strlen(key) + 1;
char *copy = malloc(bytes);
if (copy) memcpy(copy, key, bytes);
return copy;
}
static int add_buggy(Table *table, const char *key, bool fail)
{
if (table->used == 2) return -1;
size_t index = table->used++; /* BUG: publish the slot first. */
Bucket *bucket = &table->slots[index];
bucket->key = copy_key(key, fail); /* Allocation may fail here. */
if (!bucket->key) return -1; /* used stays incremented. */
bucket->ready = true;
return 0;
}
static int add_fixed(Table *table, const char *key, bool fail)
{
if (table->used == 2) return -1;
char *owned_key = copy_key(key, fail); /* Fallible work first. */
if (!owned_key) return -1; /* Table still unchanged. */
size_t index = table->used;
table->slots[index].key = owned_key;
table->slots[index].ready = true;
table->used = index + 1; /* Publish only a complete bucket. */
return 0;
}
static bool safe_to_destroy(const Table *table)
{
/* A real destructor may trust used and inspect these fields. */
for (size_t i = 0; i < table->used; i++) {
if (!table->slots[i].ready) return false;
}
return true;
}
int main(void)
{
Table broken = {0};
Table fixed = {0};
int old_result = add_buggy(&broken, "example", true);
int new_result = add_fixed(&fixed, "example", true);
bool old_safe = safe_to_destroy(&broken);
bool new_safe = safe_to_destroy(&fixed);
printf("old: result=%d used=%zu safe_to_destroy=%s\n",
old_result, broken.used, old_safe ? "yes" : "NO");
printf("fixed: result=%d used=%zu safe_to_destroy=%s\n",
new_result, fixed.used, new_safe ? "yes" : "NO");
/* Detect the violation, but never dereference the bad bucket. */
return old_result == -1 && broken.used == 1 && !old_safe &&
new_result == -1 && fixed.used == 0 && new_safe ? 0 : 1;
}Build it with cc -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -o /tmp/half_published_bucket half_published_bucket.c, then run /tmp/half_published_bucket. The expected output is:
old: result=-1 used=1 safe_to_destroy=NO
fixed: result=-1 used=0 safe_to_destroy=yesStart with Table broken = {0}. Both slots are empty and used is zero. In add_buggy(), the expression table->used++ does two jobs: it gives us slot zero and tells everybody that slot zero is part of the table. Only then do we call copy_key(). With fail=true, that call returns NULL. We leave immediately, never reaching ready = true. The table now claims it has one entry, but that entry is unfinished.
Notice what safe_to_destroy() does: it walks from zero to used - 1, just as cleanup code might. It finds an unready slot and says NO. We deliberately stop there rather than make the demonstration itself dereference bad memory. In add_fixed(), copy_key() runs before used changes. When it fails, the table is still empty. On success, we fill in the key and readiness flag first, then publish the new count. That is the whole ordering change in this miniature example.
| Moment | Buggy function | Fixed function |
|---|---|---|
| Before allocation | used becomes 1 | used stays 0 |
| Allocation fails | Slot 0 exists only according to the count | No slot has been published |
| Cleanup checks entries | Finds slot 0 unready | Has zero entries to inspect |
Two related C questions I would ask elsewhere
A cache entry. Suppose a C cache sets ready before allocating its value. Cleanup after an allocation failure can see ready == true while value is still NULL. I would allocate into a temporary pointer, check it, assign the value, and set ready last. If other threads read the cache, this also needs proper synchronization; statement order alone is not a thread-safety mechanism.
char *value = malloc(bytes);
if (value == NULL) return -1;
entry->value = value;
entry->ready = true; /* Advertise it only after it exists. */A long-lived worker. Here the problem is not publishing an unfinished entry; it is keeping a pointer after the thing it points to has gone away. Imagine a worker-global scratch pointer that names request A's arena. If request A ends and frees its arena, request B must not inherit that pointer. The rule I would check in C is: who owns this allocation, who keeps an alias, and which shutdown path clears the alias? A failure path that skips the reset matters as much as the normal path. This is the angle behind the second PHP advisory, and the next section traces its actual request-to-request story.
I initially wondered whether FPM changed the answer, since our sites use it. It changes one path: after a startup failure, Apache's mod_php can still run request shutdown and touch that malformed table; FPM exits the child instead. But a failure after startup can still leave state behind in a surviving FPM worker. The research and PHP advisory separate those paths. The invariantCount only committed bucketsIf cleanup walks by nNumUsed, every advertised entry must be fully initialized or cleanup must be skipped safely.
Step 3: a request heap dies, but a worker may live
The second failure crosses a lifetime boundary. A request owns allocations; a persistent worker owns globals. If a global contains a pointer into the request heap, the global must be cleared before that heap is released. A fatal during decoding or early session work could bypass the matching cleanup. The next request may reach the global before ordinary request initialization resets it. That is the producer → freed heap → consumer pattern described by the stale-global-state advisory.
request A: publish pointer to A-owned memory in worker global
fatal skips the matching reset
free A's heap; worker continues
request B: early hook reads worker global before ordinary initialization
pointer still names the old, freed allocationCalif details two illuminating routes. Session upload progress can touch session state while the body is parsed, before normal module activation; an interruption can leave session globals referring to freed request memory in a surviving mod_php worker. Separately, a fatal during a post-startup decode can leave unserialization bookkeeping published, after which an early operation in the next request can encounter it even in a PHP-FPM worker. The confirmed exposure depends on SAPI, configuration, reachable operations, and the producer/consumer pair; it is not accurate to say that every PHP app has the same exploit path. See the technical walk-through.
What the real patch changes
My little C model moves the publish step. PHP needed a wider repair. The PHP 8.4 fix commit touches the allocator, executor, request processing, extensions, and tests. Its commit message says it replaces OOM bailout with UnwindExit and resets globals reachable during startup. The advisory explains the design: a soft OOM is delayed to a VM safe point; a higher hard limit exits immediately rather than unwinding through partly initialized C state. The companion advisory covers the stale globals.
- Allocation failure can jump out of native code immediately.
- Soft OOM requests a controlled exit at a VM safe point.
- Hard OOM exits the worker rather than trusting unsafe cleanup.
- Request-owned pointers can remain in persistent globals after shutdown.
+ Shutdown and startup clear or safely initialize relevant globals.
- Parser work can expand beyond apparent input limits.
+ Multipart and input-accounting paths gain tighter bounds and tests.I found the full PHP 8.4 commit more useful than any one-line summary: it shows how many places had to agree on what an allocation failure means. It also adds OOM-focused .phpt tests. The sketch above groups the changes so the diff is easier to navigate.
For a tiny literal look inside Zend/zend_alloc.c, the old error helper ended with a bailout; the new delayed-error path records an OOM state and asks the VM to interrupt. These are selected, noncontiguous lines from the upstream diff, not a patch to apply:
- zend_bailout();
+ EG(delayed_oom).state = ZEND_OOM_STATE_PENDING;
+ zend_atomic_bool_store_ex(&EG(vm_interrupt), true);The point is timing: record the failure now, then leave at a safe execution boundary instead of jumping out of arbitrary native code. When even that is unsafe because the higher hard limit is reached, the patched design terminates the worker rather than running cleanup over corrupt intermediate state. The accompanying global resets address the other half of the problem.
Why I am not ditching PHP
People understandably ask whether a bug like this means we should leave PHP. I don't think so. Antsand's generated membership sites already have accounts, purchases, protected reading, billing, and deployment flows built around it. Rewriting all of that in another language would give us a new set of bugs to find. I would rather understand this failure, patch the runtime, and keep exercising the boundaries our sites actually rely on.
Our application and generated-site source uses PHP for HTTP and membership. The Dockerfile now declares php:8.4.27-fpm-bookworm, one of the fixed releases. The next question is less glamorous: which image are the live workers running? A Dockerfile change does not restart them. I want the serving version, worker restart, and smoke tests recorded. I also don't want to mix this up with our recent SQLite “database is locked” reader bug. That was an application/database failure, not this PHP engine bug.
| Boundary | What I want to verify | What it does not prove |
|---|---|---|
| Runtime | Supported patched PHP build in every web worker; restart completed; October 23 guidance reviewed. | That application authorization is correct. |
| Request edge | Decoded body and multipart structure limits; upload-progress policy; least-privilege worker. | That limits replace the engine patch. |
| Generated site | Source/deployed parity; protected-route tests; purchase, refund, and entitlement checks. | That an arbitrary new product policy is automatically safe. |
| Incident evidence | Version inventory, logs, regression results, and rollout/rollback record. | That an absence of observed attacks proves no exposure. |
Where AI helped, and where old-school engineering did the real work
One detail in the Calif report caught my attention: the researchers used an LLM to look for ways to create memory pressure, then asked where code might publish state before a fallible operation finished. That is a good question to hand to a model. The harder part was checking the actual C paths, figuring out which PHP request lifecycle could reach them, reproducing the behavior, and working with maintainers on a fix. OpenAI's account describes that collaboration too.
That resembles how I like to use AI in our projects. Let it suggest places to inspect. Then put a small number beside the state change, make a failure happen on purpose, read the diff, and run the test against the binary that will actually ship. That is much more useful than asking a model for a verdict on whether a codebase is “safe.”
\[\text{hypothesis} \rightarrow \text{source path} \rightarrow \text{failure point} \rightarrow \text{reproducer} \rightarrow \text{patch invariant} \rightarrow \text{deployed evidence}\]
There is one extra step for Antsand: we generate sites. If I fix a template but forget to update an existing site, the production worker will never see my fix. I need to check the generated copy and the process serving it. Question for a code reviewWhere is the promise made?Find the line that says an entry exists or a pointer is live. What could fail immediately after that line?
What I would do on a PHP system today
- Inventory every PHP that serves traffic, including generated sites, CLI jobs, separate FPM pools, and older containers. Check the running build, not just a Dockerfile or package manifest.
- Update supported branches to at least the October 21 fixed releases or verified vendor backports, then restart the workers. Review the October 23 upload-progress advisory for the additional configuration-specific issue.
- Retain request-size and structural limits as defense in depth, but do not confuse
memory_limit=-1or a WAF rule with the complete patch. The PHP advisory is explicit about this tradeoff. - Run application tests after rollout, especially uploads, sessions, checkout, private reading, and generated-site authorization. Review logs for crashes and unusual worker restarts.
- Record the exact runtime digest/version, source revision, deployed-site revision, and test evidence. If those differ, “we updated PHP” is only a plan.
I came away less interested in arguing over PHP and more interested in that tiny interval between “this exists” and “this is actually ready.” The C example makes that interval visible. The PHP patch addresses it in a real interpreter, across allocation, cleanup, and worker lifetime. Our job is to run the patched interpreter and keep the generated sites honest about what is deployed.
Primary reading
- PHP's October 2026 security release and follow-up advisory for versions and operator guidance.
- PHP's OOM-corruption advisory and stale-state advisory for the vulnerability boundaries.
- PHP 8.4's upstream fix commit for the actual diff and tests.
- Calif's technical analysis for the discovery narrative and code-path walkthrough. The researchers use “PHP2Shell” as a name for the two related findings; it is not a PHP package or feature.
- Stefan Esser's 2004 advisory for the historical memory-limit bug, and Calif's separate 2026 unserialize study for a related but distinct lifetime failure.