php2shell: The PHP Bug That Fires Before Your Code Runs
Two critical PHP flaws landed October 21 and were in CISA's exploited-vulnerabilities catalog five days later. The 9.2 score isn't the part that should bother you. It's where the bug lives — it goes off while PHP is still assembling $_POST, before a single line of your application has run.
I've spent fifteen years being told the way to stop a PHP app getting popped is to write better PHP.
Sanitize your input. Don't trust $_POST. Parameterize your queries. Escape on output, not input. All true. All worth doing. I've given that advice to people who badly needed it, and I'd give it again tomorrow.
None of it would have saved you from the thing that shipped on October 21.
Because this one goes off while PHP is still building $_POST. Your code hasn't run. There's nothing to sanitize, because the sanitizing lives in a function that never gets called.
That was the story three weeks ago. Here's what actually happened since, and it's not what anyone predicted.
Millions of exploit attempts. Something like 34 million vulnerable services on the internet. And the confirmed casualties are twenty-five Palo Alto firewalls and a handful of honeypots. Wordfence's people logged over 77,000 attack requests at WordPress sites and, as of October 28, knew of not one compromised through this.
Why it rooted firewalls and bounced off WordPress is the single most useful thing to come out of this, and I'll get to it. First, the disclosure, because that went about as badly as a disclosure can go.
The embargo didn't leak. It disintegrated.
The bug was reported properly and early. Calif reported an unconditional RCE to the PHP security team on August 11. Wiz independently found its own on August 19, confirmed RCE against Drupal on the 20th and WordPress on the 21st, and reported on the 24th. OpenAI Codex Security independently reported the same root cause on September 18.
Three separate parties, same bug, nobody talking to each other. That alone tells you it was findable.
Then October arrived.
| When (2026) | What |
|---|---|
| Oct 17 | Amazon's MadPot honeypots record an exploitation attempt against the embargoed bugs, from one IP. Amazon calls it "largely a one-off probe" and doesn't know how the actor got the information |
| Oct 20, ~15:00 | CVE IDs leak — with a wrong release time and a CVSS of 10.0. No technical detail |
| Oct 20, 19:30 | PHP pulls the release forward from Oct 22 to Oct 21 at 14:00 |
| Oct 20, ~20:00 | Calif accidentally publishes a video showing a working proof of concept. It spreads on social media |
| Oct 20, ~23:00 | PHP starts giving mitigation advice to some critical infrastructure providers. That advice may also have leaked |
| Oct 21, 03:00–05:30 | Vendors with pre-disclosure access push WAF and IPS rules |
| Oct 21, 07:41 | Chainguard breaks embargo, publishing fixed container images more than six hours early |
| Oct 21, 14:00 | Fixed releases ship. Wiz and Calif publish technical detail |
Sit with the October 17 row for a second. Somebody was probing for this three days before the CVE IDs even leaked, while the bug was still under embargo, and Amazon's own assessment is that they don't know how that actor knew.
And then the researcher posted a video of his own exploit working. Not code — a video. That was enough.
I don't say any of that to pile on Calif, who found the thing and reported it responsibly and then made the kind of mistake any of us could make at the end of a two-month embargo. I say it because we talk about coordinated disclosure as though the hard part is deciding the date. The hard part is that an embargo is a secret shared between a vendor, several research teams, a pile of distros, the cloud vendors with pre-disclosure access, and some critical infrastructure operators — and it only has to fail in one place.
Here it failed in about four.
So what php2shell is
Two CVEs: CVE-2026-109618 and CVE-2026-109619, both CVSS v4.0 9.2.
CVE-2026-109618: a counter that moves before the thing it counts
This is the one I keep rereading, because the bug is three lines long and it's the kind of ordering mistake any of us would write on a Tuesday afternoon.
PHP's Zend engine keeps request data in hash tables. Building $_POST during request init goes through _zend_hash_str_add_or_update_i(). Datadog excerpts the relevant piece of zend_hash.c:
idx = ht->nNumUsed++;
ht->nNumOfElements++;
p = ht->arData + idx;
p->key = key = zend_string_init(str, len, GC_FLAGS(ht) & IS_ARRAY_PERSISTENT);
Counters go up on lines one and two. The key gets allocated on line four.
Make that allocation fail — by walking PHP up against its memory limit — and PHP raises a fatal error and abandons the insertion. But nNumUsed and nNumOfElements already count the bucket. Cleanup comes along later, sees a bucket the counters swear blind is real, and processes it. Datadog's phrasing: "the counters already include the new bucket, but PHP never initialized the bucket's key or value."
Their one-line summary of the class is the bit I'd write on a sticky note: "a memory error abandons an operation while the internal state is incomplete, and cleanup then treats that state as valid."
That's not a PHP problem. That's a pattern. If you've written C, you've shipped it. I have.
CVE-2026-109619: state that outlives the request that made it
A use-after-free across requests. Request-owned state gets left behind when a request dies badly, and a later request landing on the same worker picks it up — pointing at freed memory.
Two confirmed ways in: fatal-error backtraces, which only exist on PHP 8.5, and upload processing including session upload progress.
FPM workers are long-lived and handle request after request — that's the entire point of FPM. So a fatal error in request N sets up request N+1 to be the one that executes something. And triggering a fatal error is usually something an attacker can do whenever they like.
mod_php is the bad case, and it's architectural
Before the numbers, the thing that decides how badly this applies to you.
Rapid7 doesn't hedge about which deployment is worst: "The vendor considers mod_php the highest risk because exploitation can succeed before any application code runs."
That sentence is the whole article. I could have just printed that.
With mod_php, PHP lives inside the web server process. Request initialization happens in there. So if the bug fires during initialization, it fires inside Apache, with Apache's privileges, and nothing you wrote ever got a vote on the matter.
| Deployment | Status |
|---|---|
| Apache mod_php | Confirmed RCE paths for both CVEs; highest risk |
| PHP-FPM | 109618: no complete FPM exploit reported. 109619: confirmed session-lifecycle RCE under specific conditions |
| php-cgi, FrankenPHP, LiteSpeed | Listed as at risk |
Rapid7 on scope: "All installations serving web requests are at risk, including PHP-FPM, Apache mod_php, php-cgi, FrankenPHP, and LiteSpeed."
Keep that table in mind for the next section, because it turns out to predict almost exactly who got hurt. Every confirmed compromise in this whole affair — the honeypot shells, the firewalls — was mod_php or an appliance running something equivalent.
Why the mass exploitation didn't happen
Sansec predicted mass exploitation "will likely start within 15 minutes of the patch release." Cloudflare saw tens of thousands of attempts within 45 minutes, hundreds of thousands within two hours, and millions by early on the 22nd.
And then it mostly didn't work.
The PHP Foundation found no functional exploits at release. Sansec's own later finding: "The public exploits do not work against a default PHP installation." VulnCheck had a working exploit by 05:30 on October 22 — for commercial customers — and it only worked under mod_php with ASLR on, against specific narrow version bands. The public ASLR-bypass PoC worked on exactly one version: 8.4.26.
Tenable found the reason, and it's the thing worth carrying away: each exploit has to be fitted to the target's build, SAPI, extensions and configuration. Defiant lists the same variables — ASLR, PHP build, running extensions, memory limits, request handling.
A memory-corruption exploit needs to know the shape of the heap it's corrupting. Every PHP install has a slightly different shape. Different distro, different compile flags, different extension set, different memory_limit, different opcache state. The thing that makes PHP hosting such a mess — that no two servers are alike — turned out to be load-bearing defence.
CrowdSec's telemetry says the same thing from the attacker side. Across October 21–25 they logged 10,073 signals from 1,187 unique IPs on 1,730 sensors in 73 countries. Then they looked at behaviour: half the sources sent one request in five days. 82% sent three or fewer. Only 24 sent ten or more to the same sensor. 78% presented browser user agents.
Their read: "attackers testing, not harvesting." Nobody with a working exploit behaves like that.
Why the firewalls fell anyway
Now flip it. What if every target were built identically?
That's an appliance. And that's exactly where this worked.
A China-linked group chained php2shell with Fragnesia (CVE-2026-46300), a Linux kernel privilege-escalation bug, to get root on Palo Alto Networks firewalls and drop webshells. Dream reported at least 25 rooted out of a pool of hundreds. Unit 42 tracks the activity as CL-UNK-1280. CISA added Fragnesia to KEV on October 28.
Palo Alto's own statement: "Active exploitation has been observed targeting the Palo Alto Networks management interface exposed to the internet." The default configuration doesn't expose that interface, which held the blast radius to roughly 0.02% of their installed base.
Tenable's note that build fragility defeats exploitation came with an exception attached: network appliances. Of course it did. An appliance is one firmware image, one PHP build, one extension set, one memory layout, shipped identically to every customer. Write the exploit once and it works on all of them.
So the summary of three weeks is almost funny. Thirty-four million vulnerable PHP services on the internet, millions of attempts against them, and the attackers who got anywhere were the ones who ignored the thirty-four million and went after the devices that are all the same.
Two further confirmed hits worth knowing: a researcher recorded 35 successful shell executions on three honeypots running PHP 8.4.26 under a single-worker mod_php setup on October 24 — note the version, that's the one the public PoC worked against. And watchTowr observed successful exploitation from a single German IP which, in every case, "executes a simple 'curl' command to prove exploitation was successful" and then did nothing further.
Where exploitation actually stands
| Claim | Source | Status |
|---|---|---|
| Probe against embargoed bugs, Oct 17 | Amazon MadPot | Confirmed; "largely a one-off probe," source of knowledge unknown |
| Millions of attempts by Oct 22 | Cloudflare | Confirmed; attempts, not successes |
| 1,187 unique IPs, Oct 21–25 | CrowdSec | Confirmed; behaviour reads as testing, not exploitation |
| 77,000+ requests at WordPress sites | Defiant / Wordfence | Confirmed; no WordPress compromise known as of Oct 28 |
| 35 shells on honeypots, Oct 24 | Researcher honeypots | Confirmed; PHP 8.4.26, single-worker mod_php |
| 25 Palo Alto firewalls rooted | Dream, confirmed by Unit 42 and Palo Alto | Confirmed; chained with Fragnesia, tracked CL-UNK-1280 |
| Public exploits work on default installs | — | False. Sansec: they do not |
The shape of it: enormous attempt volume, tiny success rate, and the successes concentrated entirely where builds are uniform.
Both CVEs went into CISA's KEV on October 26 with a three-day federal deadline.
How much of the internet is still unpatched
Depends who's counting, and the methods differ enough that you should treat all of these as order-of-magnitude:
| Source | Exposure figure |
|---|---|
| Censys | 34.4M vulnerable services (Oct 23), ~30.3M (Oct 29) |
| Unit 42 / Cortex Xpanse | 22.6M+ potentially vulnerable instances |
| VulnCheck | ~2.6M internet-facing hosts, ~99.998% vulnerable or likely vulnerable |
| Tenable | 1.3M–1.9M internet-facing hosts |
| Qualys | ~156M installs with known versions, ~71% on EOL branches |
Patch progress, which is the more interesting half:
- Qualys counted about 199,000 installs patched in the first 12 hours
- Wiz saw a ~67% drop in daily distinct vulnerable internet-exposed machines, with under 53% of initially affected organisations still vulnerable a week after disclosure
- Bitsight's October 27 snapshot: 33.5% of 9.47 million hosts patched overall — 68.6% on supported branches, 6.4% on EOL branches
That last pair is the whole EOL problem in two numbers. Ten times the patch rate on supported branches. And 91% of PHP 7.4 hosts were still on 7.4.
Which versions
| Branch | Affected | Fixed in |
|---|---|---|
| PHP 8.5 | before 8.5.12 | 8.5.12 |
| PHP 8.4 | before 8.4.27 | 8.4.27 |
| PHP 8.3 | before 8.3.36 | 8.3.36 |
| PHP 8.2 | before 8.2.35 | 8.2.35 |
| PHP 7.4 – 8.1 | yes | no release; source backports only |
| PHP 5.4 – 7.3 | yes | nothing, ever |
Check what's actually serving requests, not just the CLI:
php -v | head -1
php-fpm -v
PHP 8.3.6 (cli) (built: Sep 2 2026 12:56:02) (NTS)
Real output off a box I had lying around. 8.3.6 is a very long way short of 8.3.36.
One trap if you're on a backported EOL build: those often keep the old version string. Verify by build date instead. Plesk's fixed builds, for instance, show a build date of October 21 2026 or later. A version number alone will lie to you here.
That 7.4-through-8.1 row is where this turns ugly, and it's the row I'd bet money is sitting under somebody's production traffic right now. PHP published source backports — not releases. On 8.1 you're waiting for your distro or you're compiling. From 5.4 to 7.3 the project has nothing and isn't going to.
Anyone who's ever inherited a hosting environment knows exactly how many boxes live in that range. It's more than you'd like, and Bitsight's 91%-still-on-7.4 figure says most of them aren't moving.
Stuck there? Third-party backport bundles covering 5.4–8.1 have shown up on GitHub. I'm not linking one as a recommendation, and I wouldn't run unreviewed patches against a PHP interpreter on somebody's production box on a Friday. But if that's your situation it exists, and reading the diff yourself is a better use of an afternoon than staying unpatched.
The third CVE is still unpatched
CVE-2026-115560, disclosed October 23, is still not fixed. PHP never published affected version ranges or a CVSS score, and the fix went into the normal release queue rather than an emergency build.
PHP 8.5.13 RC1 and 8.4.28 RC1 are out alongside 8.6.0 RC5, with 8.6.0 going GA on 19 November. The new builds carry session-ID hardening that lines up with the pending advisory — but the advisory itself still isn't fully published, so I can't tell you with confidence that it's the fix.
All four preconditions have to be true for you to be exposed:
session.serialize_handlerisphp_binaryorphp_serialize- your code switches it to
phpwithini_set() - that switch happens before
session_start() - session upload progress is enabled — the default
Condition two spares most people. Laravel generally doesn't match, because it doesn't use native PHP sessions.
The mitigation, and where you put it is the whole trick:
; php.ini
session.upload_progress.enabled = 0
; PHP-FPM pool
php_admin_flag[session.upload_progress.enabled] = off
# Apache with mod_php
php_admin_flag session.upload_progress.enabled off
Server-level, applied before PHP parses the request. Not ini_set(). Not .user.ini. Those run too late — same timing problem that makes this whole cluster awkward.
Restart workers, don't reload. Then verify on the SAPI that serves your site. Here's a stock 8.3.6 with nothing configured:
php -i | grep session.upload_progress.enabled
session.upload_progress.enabled => On => On
On by default, confirmed — I ran that rather than taking the advisory's word for it. You want Off => Off. Both columns. If only one says Off, something downstream is overriding you.
Mitigations, including one that'll make you wince
Patch. Everything here is for the gap between now and when you can — and watchTowr's assessment of the .ini mitigations is that they're "ineffective," so treat these as narrowing the window rather than closing it.
| Mitigation | Targets | What it costs you |
|---|---|---|
memory_limit = -1, set administratively |
The allocation-failure path in 109618 | Your per-request memory ceiling |
session.upload_progress.enabled = 0 |
The session path in 109619 and all of 115560 | Upload progress reporting |
fatal_error_backtraces = 0 (8.5 only) |
The fatal-backtrace path in 109619 | Backtraces on fatal errors |
That first one reads like a typo the first time you see it. Removing your memory limit, as a security mitigation.
It holds up once you've seen the bug. CVE-2026-109618 needs an allocation to fail, and the easiest way to make an allocation fail is to walk PHP up to memory_limit and let the next one fall over. Take the ceiling away and that specific trigger goes with it.
What you've bought is a different failure mode, and go in clear-eyed: a single request can now eat memory until the kernel's OOM killer makes a decision on your behalf. The OOM killer has never once in my life picked the process I would have picked. On a box running one app with generous RAM that trade might be fine for forty-eight hours. On a shared host you have just installed a denial-of-service switch and handed out the key.
Better: enforce the memory ceiling with cgroups instead, where the kernel kills the right thing. Set post_max_size sensibly while you're in there. And apply all of it in server config, not ini_set() or .user.ini.
What to hunt for
I had this article down as "patch and move on." Three weeks of incident reporting later there's real detection guidance, and the hunting window matters: start at October 17, the earliest known attempt, not at the disclosure date.
In web server and PHP logs:
Allowed memory size of N bytes exhaustedtied to specific inbound requests, especially repeating from one source- Oversized or deeply nested multipart bodies
- Large or malformed
PHPSESSIDcookies in responses, or repeated 500s after multipart requests - PHP-FPM workers crashing or restarting more often than traffic explains
On the host: - The web server account —
www-data,apache,nginx— spawning shells or system utilities - Outbound connections from PHP processes, or to internal segments the web server has no business reaching
- New files in the webroot or other writable directories
- New cron jobs or local accounts
If you're doing that sweep on a box you also own at the OS level, the host-hardening guide covers the layer underneath — the ports and daemons that are exposed no matter what your application does.
And one piece of DFIR advice worth repeating because it's easy to get wrong under pressure: if you think a host may be compromised, capture PHP worker memory before you restart it. The restart is the first thing everybody reaches for and it destroys the evidence.
A warning on tooling, too: there are hundreds of repositories claiming to hold php2shell PoC code and very few are legitimate. A vulnerability with this much attention and no working public exploit is perfect bait.
About that WAF line
In the first version of this piece I said a WAF wouldn't reliably help, because there's no distinctive payload to match on.
That was half right, and the half I got wrong is interesting.
Cloudflare deployed rules at 03:02 UTC on October 21 — about nine hours before public disclosure — to free and paid WAF customers alike, as seven "PHP Remote Code Execution Attack" rules. They added CVE-2026-115560 coverage on the 23rd.
But look at how they had to do it. They built what they call full transaction detection, combining signals from the request and the response, because — their words — "Request data alone does not always provide enough evidence to distinguish an exploit from legitimate traffic."
So the original point holds: you can't catch this by pattern-matching the request. What I didn't anticipate is that you can catch a lot of it by correlating request with response and blocking the response before it leaves. That's a clever bit of engineering and I was wrong to write off the category.
Two caveats they're upfront about. Attackers were "mutating the attack payload to try to bypass our protections." And there's a coverage gap: customers who changed session.upload_progress.name aren't covered by that rule.
Their own framing is the right one — "Customers should patch their PHP repos even with WAF protections."
What this doesn't cover
No exploit code, no walkthrough from uninitialized bucket to shell. The mechanism's here because you can't make a sane patching call without knowing whether you're exposed, and the detection guidance above is what you need operationally. The rest stays where the researchers left it.
I also haven't verified any of the exploitation telemetry myself. Cloudflare's millions, CrowdSec's 1,187, Bitsight's patch rates, Dream's 25 firewalls — these are vendor measurements of their own sensor networks, taken with different methods over different windows, and they disagree with each other in places. That disagreement is in the tables deliberately. I've flagged where a figure is one vendor's view rather than an established fact.
The exposure numbers in particular should be read as ranges, not counts. Censys says 34.4 million and Tenable says 1.3 million. Both are measuring in good faith. They're just measuring different things.
And I can't tell you whether you were hit. Pre-disclosure probing from October 17 means "we patched on the 21st" and "we were never compromised" are two different claims, and only one of them is something you know.
The uncomfortable part
I've been fairly rude about PHP's culture before, so let me be fair now it counts.
The security team handled the technical side well, and under real pressure. Four branches patched same-day, under pressure, a day earlier than planned. Backports for three EOL branches they had no obligation to touch. An advisory that documented SAPI-specific risk instead of hand-waving. And when a third issue turned up two days later they disclosed it rather than sitting on it.
The disclosure process around them, though, failed in four separate places in about thirty hours — a leaked CVE ID with a wrong score, an accidental video, mitigation advice that may have leaked, and a vendor shipping fixed images six hours early. None of that was PHP's doing and all of it was PHP's problem.
What I keep coming back to is where the bug was.
We've built a decade of PHP security advice around one boundary. Untrusted input on one side, your application on the other. Validate here. Escape there. Never interpolate. That boundary is real and I'd teach it the same way next week.
But CVE-2026-109618 lives upstream of it, in the machinery that constructs the variables the advice is about. Every best practice in the ecosystem quietly assumes $_POST exists before you start being careful with it.
And the thing that saved most of us wasn't a best practice at all. It was that our servers are all slightly different from each other, in ways nobody designed and nobody can take credit for. The people who got rooted were the ones running hardware where that wasn't true.
If you run appliances, that should bother you more than the PHP bug does. And if you've got a management interface reachable from the internet, that's the thing to fix tonight — I've made the zero-trust case for homelabbers before and it scales up to enterprise kit without much modification.
Go check your versions. And if you're on an appliance with an exposed management interface, go check that one first.
I'll wait.
$ tail -f /forums
Discussion
Comments live on the community forums — sign in there to reply.