How I replicated PHP2Shell, and 5 ways we got super lucky
By Rob Lennon · October 28, 2026
On October 21, PHP shipped an emergency release to close two ways a stranger could run code on your server.
It required no login or bug in your app, just a malicious request that gets handled before your own code even gets a turn.
The pair got a name from its finders — PHP2Shell, from Ryan Austin of Calif, working with Wiz Research and OpenAI — and two numbers, CVE-2026-109618 and CVE-2026-109619.
Four version floors went out at once...
- 8.2.35,
- 8.3.36,
- 8.4.27,
- 8.5.12
— at 4:00 UTC.
Working exploits showed up inside a day.
Within five days CISA had both bugs in the catalog of things being actively used against real people. One scanning firm, Mallory, pegged the internet-facing hosts running PHP too old to ever receive the fix at 883,717 — a count carried to most of us through a TuxCare writeup.
That was the loud week. Unlike usual, it wasn't hype. These are real bugs in the engine itself, and the panic made sense.
I got interested.
So, I spent six days running AI-led research on this: mostly, grinding through public source code and local builds. I pointed it at the event and asked a dumb question: what's actually true here? can we figure it out?
Turns out I was able to figure it all out and map the whole thing.
Here I'll share my findings and tell the whole story from how I saw it go down.
Chapter 1: Oops, the fix nobody got
Start with the thing that should bother you most.
The October 21 patches closed the two bugs in the announcement.
They did not touch a third one sitting in the next room
— public since May, never part of the famous pair, and never going to be covered by the emergency release, because it's different code doing a different wrong thing.
The mechanism goes like this:
PHP cleans up after unserialize() in two phases. Phase one frees the internal bookkeeping. Phase two fires the object destructors, and it does that without holding the lock on the bookkeeping it just freed.
So if a destructor calls unserialize() again — and real-world objects absolutely do — the inner call reaches for memory that's already been handed back.
Use something after it's freed, remotely, on request.
A researcher publishing as dinosn put this mechanism in a public repo back on May 2, where it sat, patiently, for nearly six months.
The prerequisite sounds narrow and isn't.
The target app has to feed user-controlled data to unserialize(). Before you relax, think about sessions, cookies, cached objects. Laravel does it. Magento does it. Symfony does it. This isn't a misconfiguration somebody has to commit. It's standard equipment. The prerequisite is basically "this runs a framework."
Here's where it tips from serious into almost funny.
The PHP team wrote the fix for this one on October 22. A day after the emergency release.
It's a real fix — I built the same source tag twice, once with it and once without, and the difference is a clean on/off: corruption every run without it, clean every run with it.
Eight lines, tracked upstream as GH-24452.
It is in zero tagged releases.
Not few. Zero. Every version you can download today, including all four "patched" floors from the emergency release, still carries this bug. If you updated on announcement day and exhaled, you're covered against the two bugs in the news and wide open to the one next to them.
And the wait isn't even uniform, because the fix only exists on half the branches.
It's merged to 8.4, 8.5, and master. Not to 8.2 or 8.3. When the monthly releases ship, 8.4 and 8.5 will carry it and the older two still won't, with no timeline for when they might. Not behind schedule. Not scheduled.
I'm not the only one who found this. Someone publishing as WoYinDao landed the same result independently this week — seven environments, seventy attempts, a hundred percent reliability, all of it on 8.4.27, which is a patched version. Their repo says a report to PHP's security team is in preparation. The fix already exists upstream, on two of the four branches. Nobody had connected those two facts for them.
Why would a fix land on two branches and skip the other two? I didn't know either, at this point in the week.
But I found a clue.
The answer turned out to be a single commit from January, written by someone fixing a completely unrelated bug. We'll come back to that in a bit.
The operating lesson here is older than PHP and I keep relearning it: don't ask whether a fix exists. Ask which commits are in which tag you're actually running. The changelog is the patch. The version number is marketing. A distro can backport a fix and leave the version string untouched, and a project can tag a release that's missing a fix finished the day before.
Chapter 2: Four ways to break stuff
The headline bug — the one that made the news — is an upload size check that doesn't check anything. And whether it can hurt you comes down entirely to what building your PHP lives in.
The check for this is beautifully dumb.
PHP's post_max_size is supposed to cap how big a request body can be. It gets enforced once, at the door, against the Content-Length header — the number where the client declares how big the body is.
It's the coat-check ticket, read and believed, while the coat never gets weighed.
Send the body with chunked transfer encoding, a completely standard way of saying "I'm streaming this in pieces and there's no total," and there's no Content-Length to read. The door check reads nothing. The parser behind it reads everything. Three megabytes on the wire becomes a hundred-plus megabytes of server memory, because at no point did anyone count the actual thing. The limit wasn't bypassed, which would mean it engaged and lost. It was never consulted.
The team publishing as pattingbabies documented this on October 22, and PHP's own people have since confirmed full remote code execution on the classic Apache setup at completely default settings. So it's real and it's bad.
Take the identical build with the identical bug, put four different things in front of it, and the same attack meets four different fates.
- Bare Apache with mod_php.
This is the old one affecting things like shared hosting, the forgotten cPanel box, the VM nobody owns anymore. PHP lives inside Apache and reads the request body directly, so the chunked body streams straight into the parser with nothing counting. This is the configuration PHP itself flagged as highest risk, and they're right. Wide open. (Hold onto this one. It comes back wearing a different hat.)
- nginx in front of PHP-FPM.
The default modern stack, but probably by accident it is safe: nginx buffers the entire request body before it hands anything to PHP, and when it forwards, it attaches its own Content-Length, because it actually counted. The limit comes back from the dead. You're protected — by a side effect of how the proxy happens to work, not by anyone deciding to protect you. Your safety is an implementation detail, and implementation details have un-saved people before.
- Apache's proxy talking to PHP-FPM.
FPM gets a body with no Content-Length and just declines to parse it. Doesn't half-process it, doesn't error in some dangerous way, refuses the work. The attack dies of paperwork, which is the most bureaucratic failure mode in computing and the one I admire most. The safest possible response to a malformed request is to not read it.
- LiteSpeed and friends.
Different internals, different body handling, no chain. Dead end.
So when someone asks whether the chunked bombing is serious, the honest answer is a question back: which front door?
The bug is in PHP. Whether you have the bug is decided by the thing standing in front of PHP.
Most of the modern hosting world bought the immunity by accident, and the legacy corners are holding the exposed configuration exactly the way the exploit wants it. Any maybe that's cruel? Because those corners belong to the people least likely to be reading any of this.
One practical thing worth lifting out of that map: whatever counts your request body first is your real size limit, not the ini file. The ini file is a suggestion nginx may honor, FPM may ignore, and bare Apache may never check.
If you want to know your actual exposure, trace the path a request takes and find where bytes get counted, if they get counted. The counting is the control. And if your web application firewall doesn't reassemble chunked bodies, it's on vacation for this exact class of attack, because the oversized payload arrives in pieces its rules never glue back together.
The big edge providers shipped chunked-aware rules within days. If yours didn't, you learned something about it this month.
Chapter 3: MEH - the old stuff is mostly fine anyway
Now put the two scary facts side by side. Working exploits are public. 884,000 hosts can never get the official fix. A week has passed. The internet is still standing.
There was real exploitation inside that week —
AWS's honeypot network saw it four days before the patches even shipped
— so it's not that nobody can do this. It's that the mass event everybody pictured on October 20, the worm tearing through the have-nots, didn't happen. The reason is arithmetic, and it's the strangest comfort I've gotten from math since I bombed my International Bachelorette HL Math exam senior year.
The mental model most people carry is: bug found → exploit runs → shell.
The bug is a door, walk through it.
The step that model skips is the address. To turn memory corruption into code that runs, you have to tell the machine where to jump — a specific function pointer at a specific address, inside a thirty-megabyte library whose position gets randomized every time the server starts, plus a heap address that depends on the target's own request history.
Two unknowns.
Thousands of possible answers.
Both move per host.
Both invisible from outside.
Whether a leak exists is the whole ballgame. Without one, you're not exploiting. You're guessing.
So walk through a blind guess.
You fire the corrupting request with your best stab at both addresses, and one of three things happens.
Most of the time your fake structure lands somewhere nothing references and the request just completes — a clean 200, the only quiet outcome, and a rare one, because you have to get a guess roughly right for even the miss to be quiet.
Guess the library right and the heap wrong and the worker process dies: signal 11, one dead worker, one line in an error log the administrator can read. Every wrong heap guess costs one of those.
And then the one nobody mentions: guess the heap right and the library wrong, and the corrupted structure walks correctly and then calls a function pointer aimed at a random spot in a thirty-megabyte library.
That's not a failed exploit, it's an uncontrolled jump to an arbitrary location with your data as the argument.
Usually it crashes.
Sometimes it does something else.
You don't get to know which, and neither do I as far as I can tell.
Now let's count.
Library randomization on a 64-bit box leaves on the order of a hundred thousand distinct positions to sweep, times the heap candidates, each attempt costing about a minute of upload.
Call it a quarter-million tries at the optimistic end — six months of continuous multi-megabyte hammering per host, every wrong guess a dead worker in the log, to buy a single unverified dice roll at the finish, whose outcome is also the only confirmation you'll ever get.
That's not exactly a sneaky way to get into a website. It's the loudest possible way to knock on 884,000 doors, leaving a segfault at each one, for weeks, while the bug sits in CISA's active-exploitation list with every abuse desk on the planet watching for exactly this. Any shared host runs alarms on worker death rates. You'd be the most visible traffic they'd seen all year.
Sansec, the ecommerce forensics firm, walked back their own day-one alarm inside 24 hours — mass exploitation "less likely than we first expected," because an attack "needs specific knowledge of the target server."
They were right, because: math.
Without a leak, this is a denial-of-service campaign with extra steps and a police report at the end.
I want to be careful with "mostly," because if you have just one little bit of info about the computer you want to access, you're golden.
This is not "the old fleet is fine." Targeted exploitation with a leak borrowed from some other bug, a foothold already on the box, a future leak found in something else — all live.
But the worm? It doesn't survive contact with address randomization.
There's a free gift in here for defenders: your segfault rate is an intrusion signal. A blind campaign announces itself in the error log, child pid N exit signal Segmentation fault, for hours or days before it could possibly land. Graph it. Alert on the slope. It's the cheapest detection upgrade in this entire saga.
Chapter 4: The internet patched itself in six days
Fine — the unpatchable fleet can't be blind-fired. But what about everyone who could patch?
The internet got a week of warnings, a CISA alert, the full siren.
How fast does it actually move?
I did my best to count. Based on my research: Ninety-three to ninety-six percent.
Of the hosts running below-fix versions in my census pools two weeks before the fix existed, ninety-three to ninety-six percent were on a fixed floor when I re-checked, six days after release day, across all three patchable branches.
Peeps, that A LOT of hustle. You don't see that kind of shift very often.
Of course, you can't ask the internet what it runs. A lot of hosts tell you by accident through a response header that announces the PHP version on ordinary pages.
So what I did was take a big pool of version-reporting hosts from a public census, snapshot it, wait for the release, and re-read the same hosts. Same doors, same question, one variable moved. The pools were built before anyone knew these bugs existed, which is the only honest way to measure absorption — no bias toward hosts that care about security, because there was nothing yet to care about.
Everything lined up in one direction for once: a KEV listing with federal deadlines, exploitation seen before disclosure, big hosts auto-updating whole fleets, and a fix that shipped as a plain version bump. The internet's patching reputation is worse than its patching, but yeah, it moves when the alarm is loud enough.
Then the census started lying to me.
Re-counting, I watched the pool shrink. Not patching, not dying, just going quiet.
More than half of a seventeen-day-old pool had stopped reporting a version on its front page at all.
That number should read as catastrophe — thousands of hosts vanishing over a weekend — so I checked whether they were dead.
About three percent were.
The rest were alive and had simply stopped telling strangers their engine version, because edges got switched on and caching layers got bolted in front and hardening defaults crept outward.
Great idea. Stop advertising how vulnerable you are so easily.
The internet didn't lose those hosts. A census built on front pages went half-blind overnight.
The fix is to knock on a second door — a login page, a feed endpoint, somewhere PHP still answers directly — which let me recover a third of the silent ones as confirmed alive. A scan that only checks the front door undercounts the living by roughly half. Check the side door before you write the obituary.
Some hosts patched without the version number changing at all. Sneaky sneaky.
The PHP team published source backports for the end-of-life branches, and the big distribution channels quietly shipped them — the major Debian and Ubuntu repos, the RHEL-adjacent ones (Red Hat pushed a 7.4 backport as RHSA-2026:87080), the enterprise LTS vendors.
A backported package keeps its old version string. A host running the fixed build still announces the old number, indistinguishable by banner from a vulnerable one. Which means the 884,000 headline, "too old to ever be fixed," is an overcount of real exposure. Some slice of that fleet is already patched, and nobody can say how big the slice is, because the one instrument everyone uses can't see it.
If you wanted to figure it out: a backported build fails differently than a vulnerable one under a crafted-but-inert request, and that behavioral difference is checkable with two cheap probes — vulnerable, patched-by-backport, or upgraded, told apart by what the host does rather than what it says.
Chapter 5: Lol, we're okay because we got lucky
Remember the leak — the thing that collapses a quarter-million blind guesses into one calibrated request. For this bug pair, the leak works like this:
PHP hands out session cookies, and under the wrong conditions the next request's cookie gets built from a stale pointer, the session ID from a previous request whose memory has since been freed and reused by whatever the allocator dropped there. The cookie comes back carrying heap bytes. Heap bytes carry addresses. Addresses end the guessing.
So the real question about any given PHP version isn't "is it vulnerable." It's "can the leak happen here."
For days, everyone — me included — answered with a shrug.
The truth is there are two things that need to exist at the same time for this exploit to work.
Thing 1 is the dangling fatal memory error.
For the leak to exist, that session ID pointer has to point at freed memory a later request will read. The freeing comes from an ugly place: when PHP hits a fatal memory error during request startup, which is exactly where this attack pushes it, the cleanup path skips the per-module shutdown routines — the request died before modules finished waking up, so the bookkeeping says don't shut them down — while still tearing the heap down underneath them.
Session state never gets its polite "release the ID" call. The pointer now aims at a corpse.
I assumed this was some modern regression. It isn't. I traced it back to PHP 7.4.0, from 2019, and it's probably older.
Every version dangles.
The dangling was never the difference between versions.
Thing 2 is the emission, and this is where we got lucky.
A malicious file's corpse in memory can't hurt you if it never leaves the building.
The stale bytes escape through exactly one doorway, the cookie sender, and to send a session cookie two things have to be true at once: the pointer has to be readable, and an internal "a cookie is owed" flag has to still be set when the next request starts.
Before January 2025, that flag got cleared no matter what — on success, on failure, always.
Stale pointer or not, the emission died with the request.
Gate closed.
Then came a fix. Over January 22-24, 2025, PHP merged a change — Gina Peter Banyard, closing a reported session crash the commit itself calls a "NULL pointer dereferencement during ID reset" — that moved the flag-clear to behind the failure check.
If the send fails, the flag stays set, and the next request tries again. From the fix's own point of view this is plainly correct: a cookie that failed to send should be retried, not forgotten.
From the leak's point of view it's the missing half of the weapon. Now a request that reads a stale pointer and fails leaves the flag standing, and the next request re-reads whatever that pointer has become — freed memory, freshly reused, emitted to the attacker in a cookie. One fix. Both halves accidentally lined up.
First release to carry it: 8.3.17. It merged forward into 8.4 between 8.4.3 and 8.4.4, and 8.5 was born with it.
So the famous "8.3-era leak" is really the leak that exists from 8.3.17 onward — minus, of course, the versions patched last month, which kill the whole thing at the source and sit outside this map.
Run both gates across the rest of the version history and the estate sorts itself hard.
Leak alive, blind remote chain possible:
- 8.3.17–8.3.35
- 8.4.4–8.4.26
- 8.5.0–8.5.11
Everything older, no dice:
- all of 7.x,
- 8.0,
- 8.1
- 8.2,
- 8.3.0–8.3.16
- 8.4.0–8.4.3
— gate two sealed shut. The bug can corrupt and crash and burn workers all day and never once tell the attacker anything.
Vulnerable, but not blind-addressable.
That second band is most of the 884,000 I studied, and the deep reason the old fleet can't be swept.
One more wall, since I'm being complete:
...even in the leak-alive bands, the setup has to cooperate.
On PHP-FPM a startup failure kills the child outright, so there's no next request and no stale read, and the leak dies on every version. The chain needs Apache's module handler, where the worker survives to serve again. It's the same bureaucrat from the architecture section, different desk.
And since this is partly a piece about checking your work: my own earlier notes blamed the wrong gate.
I'd probed a frozen 7.4 rig, watched the session ID string sitting there alive, and concluded old PHP couldn't dangle at all.
Source archaeology said otherwise — it dangles fine, it just can't talk. Two reviews forced the correction, one from an outside researcher whose measurements landed the same week from a different direction, one from an agent on my own team. The outside researcher had the emission mechanism public before I published anything.
The lesson I'm keeping is that a disclosure primitive needs two survivals, not one: the stale state has to form and it has to escape, and those two things move independently through a project's history. Any "is version X affected" table built on one gate will mis-attribute, because the gates don't travel together.
Half the wrong advisories in the world are single-gate analyses. And the uncomfortable general version: when you fix error-path behavior, you are always also deciding what later requests get to see.
Nobody at PHP decided in January that versions below 8.3.17 shall not leak. An engineer fixed a reset bug correctly, and a security boundary showed up as a byproduct — one that ended up mattering more, for this class, than anything designed on purpose in October.
Chapter 6: Older timers are accidentally vaccinated
Last corner of the map is the fleet nobody can save: end-of-life PHP, 7.4 and 8.0 and 8.1, versions that will never get the official fix, on boxes whose owners are statistically not reading security advisories.
You'd expect this to be the bleakest part. It's the happiest result I found all week.
Because the chain has one more dependency, so obvious it keeps getting forgotten.
The whole attack is a way of making one specific feature's bookkeeping crash in a useful direction — the session upload-progress machinery, a setting called session.upload_progress. That's the engine the two headline bugs live inside. No engine, no chain.
Version vulnerable, architecture exposed, leak available, and if that one setting is off, every public exploit for these bugs is touching a dead part. Not harder. Dead.
So the question for the end-of-life estate stopped being "what version" and became "is the engine even switched on over there."
I went and checked. I won't name the host — they did nothing wrong and don't need the traffic — but picture a large European family of shared-hosting sites. Bare Apache. Old PHP.
On paper, dead center of the target; a risk model built on version numbers alone would paint every site they run bright red. Site after site, the probes came back the same: they parse everything perfectly, the whole menu, and the upload-progress machinery contributes exactly nothing, because the setting is off. Not on one box. Across the family.
Whoever runs their fleet decided at some point to disable a feature almost nobody uses, and in doing so disarmed an attack class that didn't exist yet. Thousands of sites, unpatchable versions, structurally immune to the two bugs the industry spent October panicking about.
Not patched. Disarmed.
Careful with the claim, because the official guidance deserves its accuracy: PHP's advisory says no configuration change fully mitigates these, and as a blanket statement that holds — the memory-corruption class has other producers, and the sibling bug from the first section doesn't need this machinery at all.
But scoped to the two headline bugs, switching that magic feature off closes every public chain I'm aware of, and I spent a week trying to prove myself wrong.
There's a newer wrinkle that makes the point better than I could.
Chapter 7: Bug numero 3
A third bug surfaced on October 23, still unpatched as I write this — CVE-2026-115560, a different mechanism with a different trigger, not the sibling from the first section — and PHP's own interim guidance for it is to set session.upload_progress.enabled to 0. The exact switch this whole section is about. The configuration door stopped being my clever theory the moment it became the official mitigation.
The lesson isn't that the advisory is wrong. It's that version is not the only axis of safety, and any risk model keyed to version alone will over-count and under-count at the same time, loudly, in both directions.
If you want to know where you stand, there are three moves in rising order of effort. Ask your host whether session.upload_progress is disabled on your plan — some publish hardening lists, some will answer a ticket, and it's a one-line answer.
If you run the box yourself, php -i | grep upload_progress and read the enabled line, which most self-hosted people have genuinely never typed.
Or run the self-check in the repo for this series, a benign probe you point at your own site that answers "machinery on or off" without crashing anything — and you'll notice I keep saying probe, never exploit, which is the whole ethic of how this was done.
Any attack chain can be stopped at three doors:
- the version door (you patched),
- the architecture door (the tier in front never lets the shape reach the engine — the nginx users), and
- the configuration door (the subsystem the chain needs is switched off — this host).
Almost all of the stuff you see int he media only talks about door one.
Doors two and three are why the real damage of this whole thing came in so far under what the version numbers predicted.
The modern hosting world stood behind door two without knowing it. A real slice of the legacy world stands behind door three because someone turned off a setting years ago. Nobody chose this distribution of risk. It chose itself, one hosting decision at a time, over fifteen years. Safety isn't a version number. It's whichever door happens to be closed. Find out which ones are yours.
Chapter 8: The shake-out
So here's how it all shook out. Kinda cool really.
- The patchable internet sprinted, better than 93%+ percent patched in 6 days.
- The headline bug is only dangerous behind 1 of 4 front doors.
- The blind-attack math takes too many attempts to reasonably execute a thing the fleet that can't patch.
- The leak that would un-collapse it only lives on a narrow band of versions, and only behind Apache...
- ...because of a January commit nobody wrote as a security boundary.
- And the unpatchable web is safer almost by accident — some by architecture, some by configuration, some by the grace of a setting somebody switched off before the danger existed.
Method notes, the miss-class arithmetic as a runnable notebook, the two-gate version table reproducible from source, and the benign self-check probe are all in the repo: the repo. Every measurement here was read-only or sized to be inert; no host was harmed in the research. Corrections welcome.