PHP2Shell: Patching Without Panicking
There's a special kind of feeling you get when you read the words "unauthenticated remote code execution" next to the name of the language your whole platform is written in.
It's not quite panic. It's more of a cold, slow "...oh." π¬
That was me on the morning of 21st October, scrolling through Techzine with a brew that very quickly went cold. Two critical vulnerabilities in PHP. Not in a framework. Not in some dusty package nobody's updated since 2019. In PHP itself.
This is the story of how I went from reading that headline to running patched PHP, what I decided not to do along the way, and the one thing that tripped me up right at the finish line.
What actually happened?
The two vulnerabilities are CVE-2026-109618 and CVE-2026-109619, and they've been nicknamed PHP2Shell. Which, as nicknames go, tells you everything you need to know π
Both scored 9.2 out of 10 (Critical). Both let someone run code on your server without logging in.

In plain English:
- CVE-2026-109618 is about what happens when PHP runs out of memory mid-request. PHP bails out of the request, but some memory gets left in a state it shouldn't be in. The clean-up code then trusts that memory... and an attacker can take advantage of that.
- CVE-2026-109619 is a similar story. Some global state ends up pointing at memory that's already been freed, and a later request can end up reading it.
The bit that really got my attention was where this happens. The attack surface is in how PHP parses the incoming request. That's before Laravel boots. Before your middleware. Before your authentication, your validation, your rate limiting... any of it.
You can write the most beautifully secure application code in the world, and it doesn't matter, because the request never gets that far π€·ββοΈ
To make the morning even better:
- Details had leaked the day before, along with screenshots of an exploit tool claiming to work against PHP 5.4 and up.
- A leaked demo listed Laravel among the "tested" applications (alongside WordPress, Drupal, Magento and friends).
- Cloudflare was already reporting early attacks.
- The patch wasn't due until 14:00 UTC that afternoon.
So, no pressure then π
Where does our PHP actually come from?
Before deciding what to do, I needed to answer a question that sounds obvious but often isn't: where is PHP running, and how does it get there?
It's easy to say "we're on PHP 8.4". It's harder to say exactly which build of PHP 8.4, installed how, in which environments. So I went and looked properly.

For us, it boiled down to:
- Production and staging run on Northflank, across our UK and Australian regions.
- Every environment builds from the same Dockerfile, based on the official PHP image:
FROM php:8.4-fpm-alpine- The web service and the worker service (queues and the scheduler) use the same image, just started differently.
- Local development and CI use PHP 8.4 too, but they're not sat on the internet waiting for someone to poke them.
That last point mattered for prioritising. Anything public-facing was the urgent bit. Everything else could be tidied up afterwards.
It also told me the worker had to be patched as well. It's not taking public traffic in the same way, but it's running the exact same vulnerable PHP build. "It's only the worker" is the kind of thing you say right before it isn't only the worker π
What could I do before the patch landed?
There was a gap between reading about this in the morning and the patch arriving in the afternoon. So the natural question was: what can I do in the meantime?
There was no shortage of suggestions flying around:
- Block
multipart/form-datarequests at the web server - Turn off session upload progress and turn on strict session mode in
php.ini - Lean on a WAF
- Take the site offline until it's patched
The nginx version of the first one is about as blunt as it gets:
if ($http_content_type ~* "multipart/form-data") {
return 403;
}And here's where I made a decision I want to be honest about: I didn't do any of them.

Not because I was being lazy (I promise!). Because when I actually weighed them up, none of them were worth it.
The patch window was short. The fix was due within hours. Every stopgap was another change to make, test, deploy... and then remember to undo once the patch was in. That's a lot of churn for a gap measured in hours, not weeks.
The ini tweaks weren't proven. Some people were suggesting php.ini changes, but people who actually work on PHP were saying the opposite. The line that stuck with me was that "the only mitigation is patching / upgrading PHP". When the people closest to the code tell you a config tweak won't save you, I'm inclined to believe them.
Blocking requests would break things for real people. Blocking multipart requests means blocking file uploads, and our platform relies on uploads in plenty of places. Taking the site offline is even worse. I'd essentially be doing the attacker's job for them, just more politely π
So the plan was simple. Wait for the patch, then get it out everywhere as quickly as possible.
Write it down
Before touching anything, I wrote it up for the team in our wiki.

That might sound like a strange thing to prioritise in the middle of a security scare. But when something like this happens, people want to know three things:
- Are we affected?
- What are we doing about it?
- How will we know it's done?
Having that in one place meant nobody had to piece it together from Slack messages. It also forced me to think through the steps properly, rather than make them up as I went along. And future-me will thank present-me the next time something like this happens (because there will be a next time).
The patch lands (sort of)
The PHP team released the fixes: 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Anyone on 8.1 or older? No official fix. Those versions are end of life. Which is a very good reason not to be on them π
For us, that meant PHP 8.4.27.
But here's the thing about relying on the official Docker image. PHP releasing a fix and the Docker image containing that fix are two different events. When I first checked, the 8.4.27 image simply didn't exist on Docker Hub yet. So a rebuild at that point would have happily produced... the same vulnerable build I already had.
This is where the floating tag helps. Because the Dockerfile uses php:8.4-fpm-alpine rather than pinning an exact patch version, a fresh build picks up the latest 8.4 release once the image is published. No code change required.
But "a fresh build" is doing a lot of heavy lifting in that sentence.

Don't let the cache lie to you
Docker builds are cached in layers. That's normally brilliant, because it's why your builds don't take forever. But the base image is a layer too.
So if your build platform has a cached copy of php:8.4-fpm-alpine from last week, a normal rebuild can quite happily reuse it. Even though the tag has moved on. Even though a patched image is sitting right there on Docker Hub. The build goes green, the deploy goes green, everything looks lovely... and you're still running the vulnerable version π₯΄
So once the patched image was published, I cleared the build cache in Northflank before kicking off the build. That forces a fresh pull of the base image, so you actually get what you think you're getting.
The bit that tripped me up
You'd think that would be the end of it. Fresh cache, patched base image, hit build, done.
Nope.
Could not download from "https://pecl.php.net/get/redis-6.3.0.tgz", cannot download "pecl/redis"
(File https://pecl.php.net:443/get/redis-6.3.0.tgz not valid (received: HTTP/1.1 500 Internal Server ErrorAnd the same again for excimer.

Our Dockerfile installs a couple of extensions from PECL (redis and excimer), and PECL was falling over. My first thought was "do these versions even exist?" So I checked. Both were there, both were the latest releases, and the release listings loaded instantly. It was only the downloads that were failing. Even the PECL homepage was timing out.
My best guess? Every PHP shop on the planet was doing exactly what I was doing. Clearing their caches. Rebuilding their images. All at the same time. And a lot of those builds pull extensions from the same place π
It's a bit of a funny one when you think about it. Clearing the build cache was the right thing to do. But it also meant re-downloading everything, at the exact moment everyone else was too.
The fix in the end was very unglamorous. I waited, retried, and it went through. Not exactly a heroic ending, but sometimes the right answer really is "give it a bit" β
Wrapping up
So that was PHP2Shell, from my side of the fence. No drama in the end, but a few things I'm taking away from it:
- Know where your runtime comes from. "We're on PHP 8.4" isn't enough. Which image? Which tag? Which environments? Which services? You want to be able to answer that in minutes, not hours.
- Patch everything that runs the code, not just the bit facing the internet. If your workers use the same image, they need the same fix.
- Stopgaps have a cost. A mitigation that breaks things for your users, or one the experts say doesn't work, isn't free just because it feels like doing something. Sometimes the best move is to wait for the real fix and be ready to ship it fast.
- Build caches can lie. A green build doesn't mean you're running the version you think you are. Clear the cache, and check what's actually running.
- Your build depends on more than your code. In an emergency, the whole world is pulling from the same package servers you are. It's worth knowing which external services your build needs, because that's exactly when they're most likely to struggle.
- Write it down. Even a short report for the team turns a stressful morning into a plan people can follow.
And if you're reading this and you're still on PHP 8.1 or older... listen, now's the time π
