That Time PHP2Shell Ate My Inbox
One email at 13:06, a patch at 17:00 that only covers PHP 8.2 and up, and 500 client sites spread over 25-odd servers, 10 cloud providers and more control panels than I want to count.

A week before all this, I finally built the thing I’d been putting off for a year: a little script that reads security feeds, throws away everything that doesn’t touch a server or plugin I’m responsible for, and emails me whatever’s left. I was proud of it the way you’re proud of a shelf you mounted yourself. It’s level, it’s real wood, it could hold a baby elephant, and nobody but me will ever notice it.
Its first week produced two emails about a WordPress plugin I’m fairly sure three people still run. I felt very responsible and forgot about it.
On October 21, at 13:06, it sent the third one.

Patchstack was flagging two CVEs, and the package line didn’t say a plugin or a theme. It said “PHP core”. The whole language. Someone had already named it PHP2Shell, which is never a good sign.
Bugs that get a brand before a patch tend to ruin my week.
Coffee first
I didn’t believe it. My notifier was seven days old. The far likelier story was that I’d botched a filter and it was yelling about nothing. So I did what any reasonable engineer does with a scary email they don’t trust: left it open on the screen and went to make coffee.
I was gone maybe six minutes. I came back holding a full mug to eleven new emails and two missed calls.
The notifier was fine. The bug was real. Remote code execution in the part of PHP that handles web requests, which covers basically everything: PHP-FPM, mod_php, php-cgi, LiteSpeed, FrankenPHP. If it runs PHP and faces the internet, it’s in.
The emails kept coming, in that tone people use once they’ve forwarded something to their boss and need someone else to own it now. Analysts, sysadmins, PMs and CEOs, all pasting some advisory or article that basically said “are we affected??”. Somebody’s accountant asked, I swear, whether this was the thing from the news.
It made me giggle. For years nobody has cared about PHP versions, or vulnerabilities in general. You can call them, write to them, send them a link to a working exploit, and you still won’t get an hour to patch, because who cares? Then it’s in the news, and suddenly it’s everyone’s top priority.
And underneath it all, one question: when is it fixed?
The fix was taking its time
Not yet. The details had leaked before the PHP team was ready, and the patch was scheduled for 17:00 my time. Four hours of everyone knowing and nobody being able to do much about it.
Four hours, on paper. Remember that part.
Some governments didn’t even try. Finland’s state IT provider Valtori simply switched government sites off until the fix arrived.

I ran the advisory through a chat window first and asked for two lines: what’s vulnerable, what’s fixed. Then I went to php.net and found the original text. The AI wasn’t going to fix any servers that day, and I wasn’t about to hand it a root prompt in the middle of a live incident. It got the boring middle layer instead.
The original had a version list. 8.2, 8.3, 8.4, 8.5.
That was the whole list. 8.1, 8.0, 7.4 and everything older are past end of life, so I guess I’m fucked. The security team did push minimal backport patches for 7.4, 8.0 and 8.1 to GitHub, which is lovely if you compile PHP yourself. I don’t know a single client who does. Most of them couldn’t tell you which PHP they run, and as it turned out that afternoon, neither could I.
Why this hit me harder than it should have
I have a day job as a developer, and I also consult for other companies. Some are friends with a WordPress site and a dream. Some I can’t name here.
They’re very different businesses, but two things hold for almost all of them. They work business hours, so the patch would land just as the people qualified to install it were heading home. And a lot of them can’t just bump PHP: there’s a plugin from 2019 that dies on 8 and a plugin that depends on that plugin, or a hosting panel that offers what it offers, or “the guy who set up the server”, who left in 2021 and took the root password with him (seriously).
So this wasn’t one server. It was everybody’s servers, all at once, with nothing to install yet.
The inventory I thought I had
I opened Obsidian on one screen and my half-baked server panel on the other, which is how I panic in an organized way. Ten minutes in, I realized I didn’t actually know what runs where. My panel knows about 15 servers. We manage somewhere between 25 and 30. Nobody knows the exact number.
That’s how it goes in most of the firms I work with. There’s no DevOps, no QA, usually no senior. Server details live in a note somewhere in monday.com, the bill keeps coming every month for a box nobody needs anymore, and the whole operation runs from fire to fire. I knew roughly what we had. You can’t patch roughly.
Once I’d added it up:
- around 500 client sites
- 25 to 30 servers, depending on who you ask
- 10 cloud providers
- more control panels than servers
The last line isn’t a typo. Some boxes have one panel, some have two, and one has a panel somebody half-uninstalled, gave up on, and then configured nginx by hand on top of the remains. Elsewhere it’s cPanel/WHM, Plesk, DirectAdmin, a couple of CloudPanel servers, Elementor Hosting (yes, that’s a thing), Cloudways, and shared hosting where all I have is an FTP login and hope.
The managed hosts are a challenge of their own. You can’t patch PHP there; they do. But will they, and when? Do they need your permission first? Do they bump you to 8.2 on their own, or leave you on 7.4 because that’s what you picked in 2020? You remember to ask the provider these questions exactly once the issue arises.
Each of them installs PHP its own way, updates on its own calendar and hides config in its own favorite folder. I needed one answer per site: which PHP is actually serving this thing?
php -v is not the answer
If you take one thing from this post, take this one.
$ php -vPHP 8.3.12 (cli)Great, 8.3, we’re getting the patch, go home. Except that’s the command-line PHP, the one your terminal happens to find first. The website never asked for its opinion.
Most panel servers have several PHP versions installed side by side, and every site picks its own from a dropdown somebody clicked years ago. The terminal can say 8.3 while half the sites on the same box quietly run on 7.4. Asking the server which PHP it has is like asking an apartment building what color its walls are. Every flat painted its own.
So I stopped asking the servers and asked the sites. They’re happy to tell you, if you know where to look:
- WordPress: Tools → Site Health → Info → Server. The PHP version is right there, no shell needed. Most of my 500 sites are WordPress, so this did most of the work.
- cPanel: MultiPHP Manager, one table, every domain, its version. (There’s a command-line way too, which I’ve written about before. It paid rent that afternoon.)
- Plesk: per domain, under PHP Settings. DirectAdmin and CloudPanel have the same thing under different names in different places, because of course they do.
- Managed hosts like Cloudways and Elementor Hosting: in their dashboard, assuming you can find the login.
For everything else, the shared hosts with an FTP login and hope, I uploaded a one-line PHP file that prints its own version, opened it in the browser and deleted it straight away. Ugly, quick, done.
The AI wrote those throwaway scripts: one for cPanel’s CLI, a Plesk-flavored variant, and the plain PHP file for the FTP-only hosts. I’d have written all three eventually. “Eventually” was the one thing I didn’t have.
By 15:30 I had an actual list, and it fell into three piles.
Three piles
| Pile | PHP | Plan for the 21st |
|---|---|---|
| The lucky ones | 8.2+ | Update at 17:00, restart FPM, check the site loads |
| The maybes | 8.0–8.1 | Test on 8.2 in staging, bump when the patched build lands |
| The ugly pile | 7.4 and older | No fix tonight. Make them a worse target |
Before any of that, I set aside about a quarter of the list: sites on PHP around 5, and old custom WordPress builds. Nothing was going to update those in a day, so they went straight onto the ugly pile’s plan.
The lucky ones (8.2+). Their plan was boring in the best way. The hard part was being ready at 17:00, because half these servers belong to companies that would be empty by then. I spent the afternoon collecting permission to touch boxes I’m normally not allowed near. Some owners approved in time. The rest would see it in the morning, and their sites got the ugly pile’s treatment for the night.
The maybes (8.0 and 8.1). Close enough to 8.2 that a version bump might just work. Might. Where a site had tests I ran them, which happened about as often as you’d guess. Where it didn’t, I flipped a staging copy to 8.2 and clicked around like a furious customer. Deprecation warnings everywhere, but most lived, and got queued to move the moment the patched build hit their panel. The ones that didn’t survive went to the ugly pile.
The ugly pile (7.4 and older). Can’t move without a rewrite, on panels that won’t build 7.4, owned by people who won’t approve that rewrite without an hour-long meeting and an email from the CEO. Plus the set-aside quarter, the 8.x sites that died on staging, and the ones still waiting on approval. Nothing here was getting fixed before 17:00, so the job was making them a worse target:
- Cloudflare in front wherever it wasn’t already, with the managed rules for this CVE on the moment they appeared. Fixes nothing. Buys time.
- Close whatever has no business being public. Old
phpinfo.phpfiles, forgotten admin panels,/test/folders. I found more than I’m comfortable admitting. - Watch the logs. Most of what hit my servers that evening was probing, not real exploitation, which matched what Patchstack saw. But “most” isn’t “all”, and other people were reporting real attacks.
- Tell the owner the truth, in writing. You’re on a version nobody patches. Here’s what I did tonight, here’s what a real fix costs, here’s the date I need an answer by.
That last one isn’t technical, and it matters most. A workaround without a written “this is temporary” quietly becomes the permanent setup. I’ve watched it happen. I’ve done it myself.
I wrote one honest version of that email and had the AI fit it to every other client, each with its own “here’s what I did on your box tonight”. By then I was too fried to phrase the “this is temporary, here’s what a real fix costs” paragraph well on my own, and it was the most important thing I sent all day.
17:00
On paper, this was the finish line. The patch landed on time: 8.2.35, 8.3.36, 8.4.27, 8.5.12.
Then, for a while, nothing happened, because a patch on php.net is not a patch on your server. Distros and panels build and ship their own packages, each on their own clock. And the managed hosts I’d worried about earlier turned out to be the fast ones:
| Where PHP comes from | Patched build |
|---|---|
| Managed hosts: uPress, Cloudways, Elementor Hosting | Within hours. Nothing for me to do |
| Panels on servers we pay to have managed: cPanel/WHM, DirectAdmin, CloudPanel | The next day at the earliest. Some are still shipping |
Panels make the easy 95% easy, and on the one day that matters you’re waiting on someone else’s release schedule. That’s an acceptable deal. Just know you made it.
At the day job, the patched builds for most of our servers were promised for first thing the next morning. The ones still waiting got a Cloudflare rule and a line in the next day’s calendar, and around 18:00 there was nothing left to do there but wait.
The phone went quiet. Everyone had gone home.
My private clients’ servers didn’t have a morning promise, so I kept going on those. Around 01:00 I was asking the AI whether 8.1 was close enough to 8.2 to risk a blind bump. That’s when I called it a night.
The promised morning builds didn’t all arrive. As I write this on October 25, four days after the patch, some of those servers are still waiting for theirs. So much for four hours.
The coffee was still on the desk, full and stone cold.
The PoC
At the time, a working proof of concept (PoC) was a rumor. @lcfr_hax was one of the first people to show one on video, and the image at the top of this post is a frame from it: a local WordPress on a vulnerable PHP, the exploit dropping a harmless pwned.txt, the browser next to it showing the file landed. Swap pwned.txt for a web shell and you’ve got the real thing.
I’m not linking a payload here. A working PHP2Shell exploit in a blog post helps exactly one kind of reader, and it isn’t you. The rumored shape is enough: a use-after-free deep in how PHP cleans up after a request. The interpreter frees a chunk of memory, then touches it again, and a crafted request gets to decide what’s sitting there when it does. One request to any PHP endpoint, no login, and the attacker has code execution.
I haven’t seen a report of a site actually taken over this way. I have seen plenty of reports of a probable PoC.
What decided how my week went was the gap between “a PoC exists” and “my clients are patched”, and whether I could close it before someone holding one got there first. The same tools that let me triage 500 sites in an afternoon work just as well for them.
Why nothing will change
Most agencies are like light in that physics experiment: they only behave when there’s an observer. On Wednesday the whole world was watching, so everyone suddenly cared about PHP versions. Next week nobody will. The “are we affected??” emails stop, and the 7.4 sites go back to being a line in a budget meeting that keeps getting pushed to next quarter.
They don’t behave because a real fix costs money and time. Wordfence, Kinsta and Patchstack (the one that warned me) each sell a real, useful slice. A firewall doesn’t update your PHP. A managed host won’t rewrite the 2019 plugin that dies on 8. A vulnerability feed tells you you’re exposed, not how to drag a site from WordPress 4.9 to 7.1.3 without taking checkout down on the way.
The price, for one WordPress site:
- One sweep (server, users, database, plugins, custom code): four or five honest hours, about $400 once.
- Staying safe: running that sweep every month, closer to $1,000 a month.
- With maintenance, piled-up changes and hosting on top: two, maybe three thousand a month.
On a busy site that ships changes every week, that math works. On an average site it’s a tough sell. And the firms that can pay for a real security team buy a different problem: speed. Approval, a scheduled window, a code review, a QA pass. By the time “upgrade PHP across production” clears all of that, the four-hour head start the attackers had is ancient history. Too poor to be secure, or too slow to be efficient. Almost everyone is one or the other.
Ready for the next one
For years I’ve told clients “we should really upgrade PHP” in the tone you’d use for “you should floss”. After Wednesday, I’m keeping this list instead:
- A notifier. Security feeds, filtered to the servers and plugins you’re responsible for. Mine paid for itself in a week: I heard about this before my clients did.
- An inventory that asks the sites, not
php -v. Site, server, panel, PHP version, who pays the bill. Mine is now a cron job that asks every server I can reach which PHP each site runs, every night, and writes it into one file. Next time the first question takes ten seconds, not two hours. - A plan, written now. What you check first, in what order, and what “done” means. A plan written at 13:10 on the day isn’t a plan. My WordPress security checklist is a start.
- Backups you’ve actually restored from at least once.
- Written permissions before you need them. “Yes, go ahead, do whatever you need” is worth a lot more on a quiet Tuesday than at 16:55.
- An escalation protocol. Who calls whom, and who’s allowed to say “switch the site off” when nobody else answers.
And if you’re still on 7.4: the backport is on GitHub, and somebody you know can compile it. Ask nicely. Buy them dinner.
Comments
Comments live in GitHub Discussions (via giscus), so you need a GitHub account to join in.