PHP2Shell, dissected
How running out of memory became remote code execution in PHP, why it hid for years, the code, the fix, and what to learn from it.
On 21 October 2026 PHP released 8.2.35, 8.3.36, 8.4.27 and 8.5.12 to fix two critical flaws that became known as PHP2Shell: CVE-2026-109618, “RCE via OOM corruption”, and CVE-2026-109619, “RCE via stale global state”. Both score 9.2 on CVSS 4.0. On Apache with mod_php, PHP’s own advisory confirms unauthenticated remote code execution with default limits, before any application code runs.
This article answers the questions the advisories leave open, from PHP’s own repository and the reports of the people who found and fixed it. It quotes code from the public php-src history, with debug-build lines left out, and contains no exploit detail beyond what the advisories publish.
If you run PHP, update first and read later. The fixed releases are 8.2.35, 8.3.36, 8.4.27 and 8.5.12, or your distribution’s backport. Restart every worker afterwards: a PHP-FPM pool or an Apache server that keeps running still has the old code in memory. Our LAMP image ships the fixed release.
When it entered PHP
There is no single bad commit. PHP2Shell is the meeting of two old design choices.
Running out of memory jumps out of whatever was running. The very first commit of the Zend engine, 573b460 on 7 April 1999, already answers a failed allocation with zend_bailout():
if (!p) {
fprintf(stderr,"FATAL: erealloc(): Unable to allocate %d bytes\n", size);
HANDLE_UNBLOCK_INTERRUPTIONS();
zend_bailout();
zend_bailout() is a longjmp(): it abandons every function between the allocation and the request’s outermost error handler, without letting any of them finish. The memory manager that PHP 5.2 introduced in 2006 (29ed52f) kept the same contract in zend_mm_safe_error(), which every PHP up to the fix used when a request crossed memory_limit.
Some code runs before the request has started. PHP 5.4.0, released on 1 March 2012, “added support for storing upload progress feedback in session data”. To report progress while a file is still arriving, the session extension hooks into the parsing of the request body. That parsing happens before the request is initialized, before the session extension has reset its state for the new request. Sansec dates the vulnerable code to that release.
So the window opens in 1999 and widens in 2012: from then on, code that runs before request initialization reads state that a jump out of a failed allocation may have left half written.
Why it took so long to find
The bug class was named in 2004. Stefan Esser’s Advisory 11/2004 (CVE-2004-0594) showed that reaching memory_limit in the middle of zend_hash_init() left a hash table half built, and that an attacker could exploit it remotely. That instance was fixed. The behavior that allowed it, jumping away from half-finished work, stayed.
It is hard to see for three reasons:
- The failure has to happen at one exact allocation. Out of thousands of allocations in a request, only a few leave something dangerous behind. Fuzzers and tests rarely run out of memory at precisely that point.
- It looks like a crash. The usual symptom is a worker that segfaults while shutting down, which reads as an availability bug, not a code-execution bug.
- The damage and the symptom are far apart. The half-written state is left by one request and read later, possibly by the next request in the same worker. Nothing points from the crash back to the allocation that caused it.
The story of the last two years, as the PHP Foundation tells it:
- In 2024, during PHP’s first external security audit in years, carried out by Quarkslab and funded by the Sovereign Tech Agency, a core developer found the first signs of the out-of-memory problem. “A potential exploit for the OOM problem was theorized, but no concrete vectors were found.”
- Several ways to fix it competed, each with trade-offs, and the security team decided by consensus. That “caused work on a fix to stall and restart repeatedly over months.” The Foundation is direct about it: “the delay was a structural failure, caused by both the security team’s decision process and its lack of resources.”
- On 30 July 2026 Calif began auditing PHP under OpenAI’s Patch the Planet program. On 11 August it reported “an unconditional remote code execution with the same root cause as the OOM problem”. Wiz reported related findings on 24 August, and OpenAI reported further attack vectors on 18 September.
Once someone showed that the theory worked, the fix came together in about ten weeks. The release then went wrong:
| When (UTC) | What happened |
|---|---|
| 18 October | The fix is committed to the public php-src repository, three days before the release. |
| 20 October | The two CVE IDs circulate online, with a wrong release time and a wrong score. PHP moves the release forward one day. |
| 20 October, about 20:00 | Calif mistakenly publishes a video of its proof of concept. |
| 21 October, 07:41 | Chainguard publishes images built with the fix, more than six hours before the coordinated release. |
| 21 October, 07:43 | Cloudflare reports early attacks, according to Sansec. |
| 21 October, 14:00 | PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 are released. |
The vulnerable code
A jump out of half-finished work
This is how PHP 8.3.35 reported an exhausted memory_limit:
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);
}
It never returns. Whatever the caller was doing stops at that line, so anything the caller had already made visible, but not yet finished, stays that way.
Here is the shape of such a caller, from the same release’s hash table code (_zend_hash_str_add_or_update_i):
add_to_hash:
idx = ht->nNumUsed++;
ht->nNumOfElements++;
p = ht->arData + idx;
p->key = key = zend_string_init(str, len, GC_FLAGS(ht) & IS_ARRAY_PERSISTENT);
The table counts the new slot first, then allocates the key. If that allocation is the one that crosses the limit, the jump leaves a slot that the table counts but whose key and value were never written. When the request shuts down and destroys the table, it reads that slot as if it were valid. The advisory puts it in one sentence: “request memory is reachable before it is fully initialized. If an allocation fails there, the early exit leaves that memory invalid, and request shutdown may read or branch on it.” It also notes that “the failed allocation doesn’t need to be related to the invalid memory”: any allocation at the wrong moment will do.
The advisory names multipart request parsing as a place where a request can reach the limit “while staying within apparent PHP limits”, which is why no application code has to run.
State that outlives a request
The second flaw is about the other side of the same jump. A PHP-FPM or Apache worker serves many requests in turn and keeps some global state between them: error, session, object and extension state that may point at memory owned by one request. A fatal error or an exhausted memory limit can skip the reset of that state, and the request’s memory is then freed underneath it.
That stale state is harmless only if nothing reads it before the next request resets it. The advisory confirms two things that do: INI diagnostics raised while applying per-directory settings before the request starts, and session upload progress. In PHP 8.3.35 the upload progress handler started the session in the middle of parsing the request body, without checking whether that worked:
static void php_session_rfc1867_update(php_session_rfc1867_progress *progress, int force_update)
{
/* ... */
php_session_initialize();
PS(session_status) = php_session_active;
IF_SESSION_VARS() {
At that point the session module’s per-request state had not been reset yet, and decoding the stored session could create objects, with autoloading and __wakeup(), before the request itself had started.
The patch
The fix is commit 3c832b5, “Avoid state corruption via bailout”, by Arnaud Le Blanc with Ilija Tovilo, Jakub Zelenka, Nora Dossche and Nicolas Grekas. On the 8.3 branch it touches 392 files, adding 10,833 lines and removing 1,020. It does not patch the call sites one by one. It changes how PHP fails.
Running out of memory no longer jumps
When an allocation would cross memory_limit, the allocator now records the error, lets the allocation succeed and returns to its caller, who finishes what it was doing. From PHP 8.3.36’s zend_mm_check_limit():
if (!heap->fallible && add_size > heap->hard_limit - heap->real_size) {
zend_mm_hard_limit_error(heap, add_size ZEND_FILE_LINE_RELAY_CC);
}
/* Check soft limit again as gc may have reduced memory usage */
if (!gc || (heap->limit < heap->real_size || add_size > heap->limit - heap->real_size)) {
if (!ZEND_HAS_OOM()) {
zend_mm_delayed_error(
"Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)",
heap->limit, add_size);
}
return heap->fallible ? FAILURE : SUCCESS;
}
zend_mm_delayed_error() stores the message and raises the engine’s interrupt flag. At the next point where the virtual machine can safely stop, zend_handle_delayed_oom() ends the script the way exit() does, by unwinding it:
if (execute_data) {
/* An exception may already be in flight, and zend_throw_unwind_exit()
* does not allow that */
EG(exception) = NULL;
/* Object allocation should succeed unless it trips the hard limit */
zend_throw_unwind_exit();
}
Unwinding runs the normal cleanup on the way out. Nothing is left half written, so the hash table above keeps its order and is still correct: its allocation now always returns.
A hard limit that ends the process
Letting allocations succeed past the limit needs a ceiling. The patch adds a hard limit, by default twice memory_limit, or memory_limit plus 1 GB above 1 GB, set by the new memory_hard_limit_percent setting. Crossing it does not try to recover: zend_mm_hard_limit_error() writes “Hard memory limit of … bytes exhausted” to standard error and calls _exit(137). The process ends without running any cleanup, so no damaged state can be read afterwards.
Code that can cope with a failed allocation asks for one that may fail, through new fallible allocation functions. Those get FAILURE back instead of a deferred error.
Upload progress runs in a sandbox
The session handler now checks that the session really started, resets the session module’s state at the start of each upload, and decodes the session with objects kept as __PHP_Incomplete_Class. That means no autoloading, no __wakeup() and no __unserialize() before the request starts:
- php_session_initialize();
- PS(session_status) = php_session_active;
+ /* Runs before RINIT: no user code may be invoked, see php_session_unserialize_init() */
+ PS(in_rfc1867_update) = true;
+ if (php_session_initialize() == FAILURE || PS(session_status) != php_session_active) {
+ PS(in_rfc1867_update) = false;
+ return;
+ }
case MULTIPART_EVENT_START: {
multipart_event_start *data = (multipart_event_start *) event_data;
+
+ /* This runs before RINIT, so we need to clear these early */
+ php_rinit_session_globals();
When the upload ends, the half-decoded $_SESSION is deleted, so the script that runs afterwards starts its session normally.
Patterns, and what to do
Patterns
- A non-local exit makes every allocation a place where work can stop half done. Code that publishes a change before its last allocation is only correct if that allocation can never jump away. PHP’s answer was to make failure return rather than jump, which fixes every such caller at once.
- Per-request state has to be reset at the start of a request, not only at the end. Cleanup at the end is exactly what a fatal error skips. Resetting at the start is the only reset that always runs.
- Code that runs before the request starts is the most exposed code there is. It runs for anyone who can send a request, with no application, authentication or framework in front of it, and it reads whatever the last request left behind.
- Old bug classes come back. The 2004 advisory described the same mechanism. Fixing the instance without the mechanism left it to be found again twenty-two years later.
- A fix in a public repository is a disclosure. The commit was public three days before the release, and builds made from it shipped six hours early. An embargo holds only as long as the code does.
If you run PHP
- Update and restart. Install 8.2.35, 8.3.36, 8.4.27, 8.5.12 or your distribution’s backport, then restart every PHP-FPM pool and web server. PHP’s advisories say updating is the only complete fix.
- If you cannot update yet, narrow the path. The advisories list partial measures:
- Set
session.upload_progress.enabled=0. Keep it off after updating if you do not use the feature. - On PHP 8.5, set
fatal_error_backtraces=0. - Keep
post_max_sizesmall and enforce request size at the web server or proxy. - Block multipart requests at the frontend if the application does not take uploads.
- Consider
pm.max_requests=1if you can afford it. - Do not limit PHP with
RLIMIT_ASorRLIMIT_DATA: they make allocations fail and trigger the vulnerable path. - Restart workers after each change.
- Set
- Know what the patch changes in your logs. A request that crosses the new hard limit ends the worker with exit status 137 and a “Hard memory limit … exhausted” line on standard error. If you see those regularly, look at which requests use so much memory before reaching for
memory_hard_limit_percent. - Look back. Before you updated, the warning signs were “Allowed memory size … exhausted” errors that name no script, segmentation faults in PHP workers, and unusually large multipart requests. Datadog and Sansec describe them in more detail. As the advisory warns, clean logs do not prove that nothing happened.
If you write C for PHP or any other runtime
- Finish initializing anything before you make it reachable, or make sure nothing between the two can exit.
- Prefer allocations that report failure to ones that jump. If your runtime only has the jumping kind, treat every allocation as a possible early exit.
- Reset global state at the start of each unit of work.
- Treat code that runs before initialization as reachable by anyone, and keep it small.
Sources
- PHP security advisories GHSA-w8hw-w9fx-j79w (CVE-2026-109618) and GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)
- The fix: php-src commit 3c832b5, and the PHP 8.3.35 and 8.3.36 sources it changes
- The PHP Foundation: Today’s PHP Security Release, and How We’re Strengthening PHP Security
- Stefan Esser: Advisory 11/2004, PHP memory_limit remote vulnerability
- Sansec’s research and timeline
- Datadog Security Labs’ analysis
- A line-by-line reading of the fix, published while it was public before the release
Some secondary reports swap the two CVE numbers. This article follows PHP’s advisories.