A single unauthenticated request can run code as your web worker on PHP before 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35. The bug is real, the patch is out, and reliable exploitation outside a lab is far harder than the headlines suggest. This is the full analysis, and an honest account of how long it took to prove (and why so long).
Lab-only research on a patched vulnerability. Knowledge sharing, not a how-to.
If you read nothing else, do these five things
- Upgrade now to PHP 8.5.12, 8.4.27, 8.3.36, or 8.2.35. These fix CVE-2026-109618 and CVE-2026-109619. On an end-of-life branch, do not hand-apply an isolated source patch on top of the last EOL release: it closes these two CVEs but leaves you exposed to everything else fixed since the branch went out of support. Get a maintained build from your distro or a package maintainer (Ondřej Surý's
deb.sury.org, Remi Collet's repo, Red Hat's module streams), which carry patched builds reaching back through 5.6, or upgrade. Check the package changelog for the CVE IDs before you trust any build. On Windows, take the -1 rebuild (8.5.12-1 and friends): that one bundles a separate Brotli 1.2.1 fix for CVE-2026-111497, an out-of-bounds write reachable through the cURL extension when automatic response decompression is on. Different bug, same download.
- Set
session.upload_progress.enabled=0, and turn off every extension and feature you don't use. The session flag is PHP's own published mitigation. The general habit, shrinking the attack surface, is the part that pays off on the next bug you haven't heard about yet.
- If you run mod_php, move to php-fpm. The FPM process model removes an entire class of these chains and isolates workers from the web server. It raises the bar. It does not close the door: I got a shell on FPM too.
- Exploiting this in the wild is hard. Not impossible, hard. Every chain is calibrated to one exact build, the heap grooming is fragile, and even with current LLMs doing the heavy lifting it took days of dead-ends to reach a reliable shell. Sansec and Wiz have both reported public "php2shell" PoCs that do not work against a default install.
- This article explains the mechanism and the mitigations. It is not a copy-paste exploit kit, and the working lab harness targets the vulnerable 8.5.11 image only.
Credit where it is due
Discovery and analysis: Calif and Wiz. Their public write-ups, along with the PHP source patch, are what let me understand the issue well enough to reproduce it. If you want the discovery story and the wider impact catalogue, read theirs first.
The fix: the PHP team. "Avoid state corruption via bailout", authored by Arnaud Le Blanc, Ilija Tovilo, Jakub Zelenka, Nora Dossche, and Nicolas Grekas. They turned a messy memory-corruption class into a two-line conceptual fix: stop using zend_bailout on OOM, and reset the globals that startup touches.
Review: special thanks to Arnaud Le Blanc for reviewing an early draft and giving sharp, generous feedback that made this write-up more accurate.
Why this wasn't Log4Shell
The response was loud. NSA, the FBI, and the UK's NCSC all told anyone who could not patch immediately to take the affected system offline until it was mitigated; CISA said the same in its October 24 update, and days earlier had reported active exploitation and added both CVEs to its Known Exploited Vulnerabilities list. I would take the "active exploitation" label with a grain of salt. In waves like this, what gets reported as exploitation is often mass scanning and failed attempts. More than one vendor has quietly walked back a confident "actively exploited" claim once someone checked whether the traffic was a scan or a real shell, and this is not a bug that falls to a generic payload. The PHP team never called for shutting servers down. Their guidance was to patch, then apply the config mitigations.
Everyone reached for the Log4Shell comparison. So why wasn't the whole internet on fire? Why targeted hits instead of a mass, worming wave? The answer is in the offsets, and it is the reason this write-up exists.
The root cause: PHP panics by longjmp, and panic skips cleanup
When a PHP request exhausts memory_limit, the engine historically raised a fatal error and recovered with zend_bailout(), a longjmp back to a safe point. That unwind is the problem. A longjmp does not run the cleanup that a normal return would. If the allocation failed halfway through building a data structure, the half-built structure survives, and a global still points at it.
Both CVEs are that same mistake, caught in two different places.
| CVE | What it is |
| CVE-2026-109618 · Bailout state corruption | An OOM during request startup, request parsing, or session handling leaves engine globals pointing at freed or half-initialized memory. The next request on the same worker inherits the mess. |
| CVE-2026-109619 · Pre-RINIT decode reuse | The upload-progress feature decodes a session before the request re-initializes its globals. With the unserializer left in a dirty state by a prior OOM, that early decode reuses a dangling structure. |
The patch stops the OOM from longjmp-ing mid-operation. It defers the out-of-memory error and raises it at controlled chokepoints, an uncatchable UnwindExit while a userland frame is on the stack, and resets the globals that startup reads. 389 files changed, and a pile of new oom_rfc1867_*.phpt tests pin the exact spots where an allocation could fail mid-structure. On the supported branches an ordinary memory_limit hit is now a graceful fatal that still runs shutdown handlers; only a request that blows past a new hard ceiling, 200% of memory_limit, forces the process out with _exit(137). That rework did not reach the out-of-support branches: the fixes carried by distros and maintainers for 7.4, 8.0, and 8.1 take the blunt route and simply _exit(1) on the offending allocation, tearing the whole process down rather than attempting the unwind that caused the bug. Either way, the corrupting bailout is gone.
The primitive: one signed integer turns a session into an arbitrary write
Everything downstream rests on one function in the unserializer. var_tmp_var() hands out scratch slots from a block of destructor entries. It reads how many slots are used, then returns a pointer past them:
// ext/standard/var_unserializer.re (simplified)
static zval *var_tmp_var_num(php_unserialize_data_t *var_hashx, zend_long num) {
var_dtor_entries *var_hash = (*var_hashx)->last_dtor;
...
zend_long used_slots; // <-- SIGNED
for (used_slots = var_hash->used_slots; ... )
ZVAL_UNDEF(&var_hash->data[used_slots]);
return &var_hash->data[used_slots]; // base + used_slots*16
}
used_slots is a signed zend_long. Make it negative and the returned pointer lands before the block, anywhere the attacker can reach. The session decoder then writes the parsed value through that pointer, one 8-byte value plus its type tag per session variable. Control where the block sits and what used_slots holds, and you have a controlled write with controlled data.
With that write, you build a lie: a chunk of memory that PHP believes is an array. The only field that matters is the array's destructor pointer. Aim it at the internal handler for system(), and the moment PHP tears the "array" down, it runs your command.

PHP's array teardown calls pDestructor(&bucket). Overwrite that one pointer with the address of zif_system, put the command where the bucket points, and freeing the array is a shell.
From one write to a shell: the four-request mod_php chain
A single write primitive is not a shell. You still need to know where to write, which means defeating ASLR, and you need something that turns a write into execution. The mod_php chain does both across four requests on one persistent Apache worker. It is pure HTTP. No files on the target, no debugger, no application code. The chain scripts live in off_chain/.

The payoff in B2 is a classic one: a fake PHP array whose destructor pointer is the internal handler for system(). When PHP tears the array down, it calls that "destructor" with attacker-controlled data, and the command runs as www-data.
$ cat out/proof
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Without the leak, every address is a guess, and a guess on a 64-bit ASLR heap is not an exploit. The forged zend_string read in B1 is what makes the rest deterministic.
Reading the layout from the wire: the Set-Cookie oracle
The leak needs no local access. It rides out on a header the server sends unprompted: when PHP starts a session it writes the session id verbatim into Set-Cookie: PHPSESSID=.... That id is a zend_string, and B1 controls which one.
A1's bailout freed the session-id buffer but left send_cookie=1 and the dangling PS(id) pointer in place. B1 reclaims that freed chunk with a crafted multipart field and overwrites it with a fake zend_string header: a benign refcount and type word, hash zero, and a len field widened to 2048. The character data is left pointing at the surrounding chunk. When the END cleanup emits the cookie, PHP copies len bytes starting from that pointer, so the PHPSESSID value becomes a 2 KB window of live engine memory rather than a 32-character id.
Everything after that is parsing on the attacker's side. parse_leak.php URL-decodes the cookie, walks it in 8-byte steps, and keeps the qwords that fall in the heap range. The pointer that recurs with its low 12 bits equal to 0x7a0 is zval_ptr_dtor, so libphp base is that value minus 0x7b47a0; bucketing the rest by 2 MB chunk recovers the main heap base. Two reads off one response header, and ASLR is gone from outside the process.
Six paths, two execution models, one scorecard
The same primitive reaches code execution through several routes. They are not equal. Some are pure HTTP, one still needs a debugger, and the FPM and mod_php models behave very differently under the same bug. Every path's exact container setup and commands are in RCE_PATHS.md.
| Path | Model | What it is | Result |
| A | mod_php | Four-request chain, display_errors=On | shell |
| B | mod_php | Same chain, stock php.ini-production. Pure HTTP. | shell |
| B2 | mod_php | Path B with a popen ROP stack instead of a direct call | shell |
| C | php-fpm | Session-decode reuse, app-visible leak, ASLR on | shell |
| C2 | php-fpm | Path C fired through a popen ROP pivot, no forged execute_data | shell |
| E | mod_php | Multipart partial-bucket teardown (path1) | crash is HTTP-only; no native leak and the groom is blocked, so the write needs an external leak or a debugger |
Why FPM is the harder target
mod_php runs PHP inside a long-lived Apache worker that shares the server's address space and survives across requests. That persistence is exactly what the four-request chain feeds on. php-fpm exits the worker the moment php_request_startup() fails, so the whole startup-failure class, including path1, simply cannot carry to a second request there. FPM is the more resilient model, and moving to it is good advice.
It is not a fix. The session-decode reuse (Path C) lives in exactly the pre-RINIT window FPM still has, and I landed a clean uid=33 on a stock FPM container. FPM falls two independent ways, in fact: the forged-frame route above, and a data-only variant (Path C2) that carries no fake execute_data at all. It overwrites the destructor with a jump_fcontext stack pivot into a short popen ROP chain, and fires it through the upload-progress hash update's own old-value destructor rather than the teardown. Same leak, same reuse, no forged frame. "Harder" is the honest word. "Safe" is not.

FPM kills the worker on a failed request startup, which is what blocks the mod_php chains. Path C slips through anyway: the upload-progress decode runs before RINIT resets the worker, so request A's dangling d is still there for request B to reuse and overwrite with the same fake-HashTable frame. That pre-RINIT window is CVE-2026-109619.
One more piece of honesty about the FPM result. The mod_php chains run out of memory at the stock 128 MB limit, driven by the request itself. Nothing on the target has to cooperate. The FPM chains here lean on a lab endpoint that hands back the pointer leak and, for determinism, sets memory_limit to 4 MB with ini_set() so a 1 MB session decode is enough to OOM. The 4 MB is a lab convenience, not a requirement. The arming OOM reaches the real 128 MB limit: a session record that declares a huge array count makes the deserializer pre-allocate the whole bucket table, 128 MB from under 5 MB of input, before it parses one element. The constraint the easy framing misses is where the OOM lands. It has to fire inside the session decode, so php_var_unserialize_destroy() is skipped and the context dangles. An ordinary base64 or image-resize OOM in application code aborts in the wrong place and arms nothing. So the FPM chain needs three things from the target, not one: the pointer leak, an OOM inside session deserialization, and attacker-influenced session content to carry that record. Stock PHP hands over none of them, and the leak is still the hardest.
A reader flagged that my FPM lab spoke FastCGI straight to php-fpm, a shortcut, not a requirement. So I reworked the lab to do it properly; the repo ships that path now: the same leak and write travel the full route over plain HTTP through a stock nginx with php-fpm unreachable from outside, and still land uid=33, roughly 6 fresh workers in 10, every miss a clean retry. The FPM result is remote through a normal reverse proxy, not an artifact of reaching php-fpm directly.
What I did leave on the table, deliberately, were routes that lean on an application weakness. If the target calls unserialize() on attacker input the primitive is one request away, but the PHP manual has warned for years that unserializing untrusted input gets you hacked. That is object injection, the application's bug, not PHP's. I wanted the vulnerability shown in core PHP on a stock install, through mechanisms every install ships, not against someone's vulnerable code.
The barrier the PoCs skip: every stage needs the same worker
The chains above share a requirement the lab quietly satisfies. All four requests have to hit the same PHP worker, because the corrupted state the exploit builds lives in that one worker's memory. With a single worker that is free, and a single worker is where I started. On a production server, with a pool of workers behind a load balancer, landing four requests in a row on one specific worker looks like the hardest part of the attack.
In the lab: one worker (apache2 -X, or a single FPM child). Every request lands on it, so the state carries for free. This is where most public PoCs quietly live, and it is not a real deployment.
In production: many workers, a load balancer in front, other clients in the mix. The four stages scatter across the pool, and the state built on one worker is invisible to the next.
The obvious fix, HTTP keep-alive to pin one connection to one worker, does not work here. The chain OOMs twice on purpose, and PHP closes the connection on a bailout: the response is HTTP/1.0 500 ... Connection: close even when the request asked to keep the socket alive, and the next request on it gets EOF. I confirmed it is the SAPI bailout handling and not an unread body: a plain fatal error and a script-level OOM both close the socket the same way. Keep-alive carries a chain of healthy 200s. It cannot carry a chain whose whole point is to run out of memory.
What does work, in the lab, is process pinning: hold every worker but one busy with slow, half-sent requests, so the single free worker serves all four stages. I got this to a clean shell on mod_php, 9 of 10 prefork children pinned, and on php-fpm, 3 of 4 workers pinned with truncated FastCGI records. Both landed uid=33.
Pinning in a lab is one thing. Against a live server it is the noisy option: other clients hit that free worker too, you rarely know how many workers there are, and a fleet of half-sent connections is the shape a WAF or a bored load balancer drops. But pinning turned out to be unnecessary. Behind a stock-default nginx the php-fpm workers share one listen socket and the kernel hands new connections out in a strict round robin, so on a quiet pool the attacker never lands four stages on one worker: they walk the whole pool. Prime every worker, leak every worker, prime again, fire every worker. That is 2N ordinary complete requests for a pool of N, no slow bodies, nothing held open. I ran this end to end through a stock nginx with php-fpm unreachable from outside, on pools of 2, 4, and 8, and it landed uid=33 on every pool. Live traffic perturbs the round robin and I have not measured the hit rate under load, so this is not a one-shot against a busy server. But it refutes the idea that a worker pool behind default buffering is itself a defense. The barrier that actually holds is the next one.
Every number is build-specific
This is why the public exploits don't work on your server. The chain is a list of offsets, and every one of them is pinned to a single compiled binary. Change the PHP build, the distro, or the CPU architecture, and they all move. A PoC calibrated on the author's machine is a crash on yours.
| Offset | x86-64 | aarch64 (52-bit VA) |
mod_php zval_ptr_dtor | libphp+0x7b47a0 | libphp+0x761b80 |
mod_php zif_system | libphp+0x5996e0 | libphp+0x5314f0 |
| user-pointer range | 0x700000000000 to 0x800000000000 | 0xf00000000000 to 0x1000000000000 |
BG(unserialize).data | bg+0x368 | bg+0x348 |
fpm leak: BASE (from leaked pie_ptr) | pie_ptr - 0x7c9777 | pie_ptr - 0x6e7810 |
I ported the two leak-based paths from x86-64 to a stock ARM server and both worked: the mod_php chain gave a shell in 4 runs out of 5 across randomized ASLR bases, and the FPM chain in 3 out of 3. That port did not need new exploitation ideas. PHP's internal struct layout and allocator bin sizes are identical on 64-bit ARM, so the small-bin spray and the heap grooming carried over untouched. What changed was a short list of symbol offsets and one pointer-range constant. The leak absorbs the rest, because it reads the current build's addresses at run time instead of hardcoding them. The full re-derivation method is in PORTING.md.
The fixed-address FPM variant, and why I left it out
There is a sixth route I built early and then abandoned: an FPM type-confusion that hardcodes every address instead of leaking one. The worker runs under setarch -R with ASLR off, so the PIE base and the reclaimed unserialize buffer d sit at fixed addresses across runs. Two files carry the whole exploit: a session blob sess_T whose values are d-relative pointers into a fake var_dtor_entries table, and a fill payload fillU.bin that sprays that table into the freed block so the signed-used_slots write lands on a crafted HashTable. No leak, no second request to parse one. On the exact build it was tuned against, it drops a shell on the first try.
It is also the only path I could not reproduce on a clean official image. Nothing about the bug changed; the heap did. On the freshly pulled php:8.5.11-fpm the reclaimed d came back 0x495000 away from the address baked into sess_T, so every d-relative pointer in the payload aimed into unmapped memory. Re-deriving d was not enough. The fill groom itself mis-landed: the spray reclaimed the d allocation as its own zend_string, which put the string's length word (0x1fea, 8170) exactly where last_dtor lives at d+0x10, and var_tmp_var then dereferenced 8170 as an entry count and faulted. The fixed address was not stale, it was occupied by the wrong object.
Fixing that is pure allocator choreography with no new capability at the end of it: re-derive d under the probe, rebase every pointer in sess_T and fillU.bin onto the new base, then re-tune the fill part size and count until the reclaim lands on the freed var_dtor_entries block instead of on d, and repeat for the next image release. Path C reaches the same FPM worker with a live leak that reads the current base at run time, so it absorbs all of this for free and ported to ARM with constant edits only (3 shells in 3 runs). The fixed-address variant demonstrates nothing Path C doesn't and breaks on every rebuild, so I left it out of the paths I stand behind. I am keeping the artifacts only as a worked example of how a build-specific heap groom decays, which is the real lesson: an offset is a fact about one binary, not about the bug.
Mitigations, mapped to what they actually buy you
| Action | What it does |
| Upgrade to 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 | Removes both CVEs at the source: no more bailout corruption, no more dirty-state reuse. This is the fix; everything below is defense in depth. |
Windows: the -1 rebuild | Adds Brotli 1.2.1 for CVE-2026-111497, a separate cURL-reachable OOB write. Same PHP runtime, updated dependency. |
session.upload_progress.enabled=0 | Removes the pre-RINIT session consumer the FPM and mod_php session chains rely on. PHP's own stopgap. Does not touch path1. |
| mod_php → php-fpm | Removes the startup-failure class and isolates workers. Not a cure. |
| Disable unused extensions and settings | The habit that pays on the next unknown bug. Every decoder you don't load is a decoder that can't be confused, and the same goes for INI switches you do not need: turn off cgi.fix_pathinfo, expose_php, and any feature your application never uses. |
| Confine the worker (AppArmor / SELinux / seccomp) | A profile that stops the PHP worker from spawning a shell blocks the system() payload, and it bites where disable_functions cannot: this bug calls the internal handler by address, so disable_functions is bypassed (I confirmed it fires with system disabled). Docker makes a tailored profile cheap to apply, though the default Docker profile still allows shell exec. Defense in depth, not a cure: a worker running attacker code can still readfile() your .env, keys, and source through PHP and hand them back in the response, no shell needed. Only the upgrade removes the bug. |
How this was done, honestly
A closing note on the effort, because the "hard even with LLMs" line up top deserves receipts. This is the story of actually building the thing, dead-ends and all.
I did not find this bug, and my first swing at it missed. The night before the patch I heard about it and spent a few late hours trying to reason it out. I got as far as the two parts that matter, a longjmp on OOM and memory_limit, and then told myself the wrong story. I was certain it had to route through serialization and the internal flags around it, so that is where I dug, and that is where I stayed stuck. Some of the information floating around online pointed the same wrong way, and I took it at face value. I'll blame the late hour rather than look too hard at my own incompetence 😆.
Then the patch landed, the shape of the thing got clearer, and the OCD kicked in. I wanted the full what, why, and how, so I set out to reproduce it end to end. The process was deliberate. I wrote up my own analysis first, a long guide and prompt laying out my theory of where the bug lived, how to reproduce it, and the path from there to a shell, then handed the exploration to DeepSeek V4.1 Flash 1017, under one firm rule: when it got stuck or unsure, it stopped and asked me how to proceed instead of guessing. At the dead-ends I gave direction and, a few times, code. We iterated over several days. Whole approaches looked right and went nowhere. The model was a regular, non-abliterated release. No jailbreak, no uncensored fork. A mainstream aligned model, pointed at a patched vulnerability, with a human in the loop the whole way.
The final validation and this write-up were mine, with Claude Opus. That last mile was the real test of the "hard even with LLMs" claim in the TLDR. Getting a shell on a known-vulnerable container is one thing. Getting the same chain to fire reliably, re-deriving every offset by hand for a new build, porting it from x86-64 to an ARM machine, that is where the days went. The AI accelerated the work. It did not trivialize it.
One more thing on the effort, because it matters. I am a PHP core developer. I have been release manager for over 20 PHP releases, I know my way around the engine internals, and I have spent years on the security side. I am not a neophyte poking at a black box, and it still took days of guided work, wrong turns, and hand re-derivation to reach a reliable shell. So when someone tells you they typed "find me an exploit" into a chatbot and out came a working php2shell chain, they are, quite frankly, full of shit. The prompt is not the hard part. The offsets are.
What this bug is, and what it isn't
php2shell is a genuine unauthenticated RCE, and if you run an unpatched PHP you should treat it as one and upgrade today. It is also a reminder that the distance between "a bug exists" and "a stranger owns your box" is measured in offsets, heap state, and days of patient grooming. The patch deletes the bug the whole path is built on, so there is no path left to walk. That is the only outcome that matters. The researchers who found it did the hard part; the PHP team shipped the fix fast; and the footnote is that even with a capable model doing the grinding, turning this into a reliable shell was work. That gap is not a reason to relax. It is why the upgrade is the whole answer.
Corrections welcome
This gets more accurate when people who know more poke holes in it. If I missed a path, botched an offset, or told the story wrong, tell me: email [*Emails are not allowed*](mailto:Emails are not allowed), DM me on X at @iliaa, or find me in the usual Discords :P.
Reproduction harness, per-path notes, and the full offset tables live in the companion repository, github.com/iliaal/php2shell, which targets the vulnerable 8.5.11 images only. Patched releases are the control in every test.