Two CVEs, One Broken Count. Inside PHP’s Latest Memory Bug.
A hash table that lied about its own size. A request that died without cleaning up. A worker that handed the next visitor a pointer into memory that no longer existed.
CVE-2026–109618 and CVE-2026–109619. Same root cause, two advisories, and three different research teams who could not even agree on which number means what.
Reference :https://x.com/CristiVlad25/status/2112966331406352653/video/1
Where it starts
PHP’s hash tables are the backbone of the engine. Every array, every object property list, every $_FILES entry goes through the same insert function in zend_hash.c.
That function does bookkeeping before it does the actual work:
ht->nNumUsed++;
ht->nNumOfElements++;
...
p->key = zend_string_init(str, len);The counters move first. The string that is supposed to back the new slot gets built second. zend_string_init allocates memory, and allocation can fail.
If it fails, the hash table believes it holds one more entry than it actually built. Counted: 1. Built: 0. Nothing downstream knows the difference, because nothing checked.
One ordering mistake. Two CVEs, because it gets reached two different ways with two different fallout patterns.
Getting there is cheap
The ordering bug alone is not scary unless you can make that allocation fail on demand. PHP processes usually run with a memory_limit well above anything a single request should need, so forcing an out-of-memory condition sounds expensive.
It is not, if you aim at the right ceiling. A default memory_limit of 128 MiB and a default post_max_size of 8 MiB sound like the second one protects the first. It does not, if the request body’s real size is never compared against the declared length. Chunked transfer-encoding and HTTP/2 requests without a Content-Length header both let the body grow past what the post size check ever saw.
Multipart form uploads add a second lever. A file field with an empty filename still builds a $_FILES entry, and that slips past the max_file_uploads cap. Nested field names multiply the number of entries built per part. Six $_FILES entries from one crafted multipart body, each one eating heap that the 8 MiB post limit never accounted for.
Stay under the declared-body limit. Blow well past the real heap limit. That is the whole trick, and it does not need a bug to work, just two settings that were never checked against each other.
The jump that skips cleanup
Run the engine out of memory early enough, during request startup, and PHP bails out with a longjmp back to a safe point before continuing. That jump skips everything that was supposed to run between the failure and the engine’s normal shutdown sequence. The teardown code that would have reset per-request globals never executes. Built: 0 stays uncorrected.
The fix changes that failure path. Instead of jumping past an unfinished setup, the patched engine issues a soft out-of-memory warning first, then calls a hard exit. No more silent skip of the request’s own cleanup.
Why it has to be the same worker
A longlived PHP worker, whether that is a prefork Apache process running mod_php or an FPM child that serves more than one request, keeps some globals alive across requests. Session state is one of them. PS(id) and PS(http_session_vars) point at memory inside the current request’s heap.
When a request dies through the skipped-teardown path, its heap gets freed at the end, but the global pointers that referenced it are never reset. The next request served by that same worker inherits a pointer into freed memory. Touch session handling in that second request and you are reading, maybe writing, memory that no longer belongs to anyone.
The catch for an attacker is the same-worker requirement. A pool of five workers means roughly a one in five chance that your setup request and your follow-up request land on the same child. More workers lower the odds without raising the ceiling, so this stays a probabilistic attack against a busy server, not a guaranteed one. Setting pm.max_requests to 1 kills it outright, because it forces every worker to recycle after a single request and breaks the pairing the attack depends on.
Does it reach your server
Not every PHP deployment is equally exposed.
mod_php: confirmed. A .htaccess php_value directive runs during request startup, which gives an attacker a path to trigger the failure at exactly the point where it matters.
PHP-FPM: validated, but application-dependent. An FPM worker that fails during startup normally just dies, which should end the story. The path that keeps the bug alive runs through session_start, unserialize and maybe_unserialize, and a global named BG(unserialize) that is never cleared between requests. Whether your app hits that path depends on how it uses sessions.
LSAPI, the worker model behind lsphp: a candidate, for the same long-lived-worker reason as mod_php, not independently confirmed in what I reviewed.
Get Aruvasaga chithan A’s stories in your inbox
Join Medium for free to get updates from this writer.
FrankenPHP: no early consumer of the bug was established at the time I looked at this. Open question, not a clean bill of health.
Three names, three ways to slice the same bug
PHP’s own advisory maps CVE-2026–109618 to the out-of-memory corruption and CVE-2026–109619 to the stale global state.
Wiz’s writeup maps the same two numbers differently: 109618 to an upload-progress use-after-free, 109619 to a use-after-free reachable through error backtraces.
The researcher behind the walkthrough I read counted it a third way: a first bug and a second bug, plus a fourth behavior that did not map cleanly onto either CVE number at all.
Read that twice. Three credible sources, same two CVE identifiers, three different pictures of what each one covers. Four distinct behaviors sit behind two root causes, and the grouping into CVEs crossed in different places depending on who wrote it up. If you are tracking this one, do not trust a single source’s mapping of symptom to number. Read more than one advisory before you decide whether a given app is affected.
How it was actually found
Worth noting, because it says something about where this kind of research is heading. The account I read described picking PHP as a target specifically because of its long bug history, building a theory to test in the spirit of the kind of PHP internals research Stefan Esser was doing back in 2004, then directing AI agents at specific places in the codebase: the memory allocator, the fatal-error reporting path, and the shutdown sequence.
One of those passes produced a short internal note pointing at the ordering bug in zend_hash.c, which is what led to the second bug being found at all. No special harness, no restricted or privileged model access. Wiz says in their own report that they found both bugs independently, and believe they did it before anyone was exploiting them in the wild.
Make of that what you will. The tooling for finding this class of bug is getting more accessible, not less.
What to do about it
Patch. 8.2.35, 8.3.36, 8.4.27 and 8.5.12 carry the fix.
If you run a WAF or sit behind an edge network, know its limits here. Blocking a response at the edge, after the origin has already processed the request, cannot undo work the origin already did. The dangling pointer exists on your server before any edge device sees the reply. The only fix that matters is at the origin, cutting the link between session globals and the freed heap before the drain happens, which is exactly what the patch does.
If you cannot patch immediately, pm.max_requests = 1 on FPM removes the same-worker requirement the attack needs. It costs you process recycling overhead. It is a real mitigation, not a workaround that only looks like one.
What I could not confirm
I read a public writeup and watched a technical walkthrough. I did not reproduce this against a live PHP build, and I did not write or run a proof of concept.
I have not independently verified the exact byte-for-byte source line in zend_hash.c against a specific PHP version’s source tree.
The FrankenPHP exposure is unresolved in what I reviewed, not ruled out.
I am relaying three sources’ CVE-to-symptom mappings as reported. I have not cross-checked them against the official CVE records myself.
What stayed with me
A single reordered pair of lines, count first, build second, split into two CVEs because of two different ways a server can keep running after the failure. That is the kind of bug that does not show up in a diff review unless you are specifically asking what happens when the allocation on this line fails.
Three teams, same two CVE numbers, three different pictures of what broke. Coordination on vulnerability disclosure is still catching up to how fast different groups can find the same root cause from different angles.
If you run PHP behind Apache or FPM, patch first and ask questions about exposure second. The blast radius here depends on your worker model and your session code, and that is not something you want to figure out after the fact.
More write-ups soon.








