October 21, 2026.
Dashain.
I’d just come back home from a quick visit to a relative.
Nobody else was home.
A message from a teammate:
“Can you upgrade the PHP version on this project?”
Sure.
Small thing.
Festival mood.
I had no idea why they were asking.
Not yet.
The latest that wasn’t
I opened the Dockerfile.
Looked up the newest PHP 8.3 image.
Pinned it.
php:8.3.35-fpm.
Latest patch. Done.
Then my teammate came back:
“That’s the same version we were already running.”
Wait, what?
The Dockerfile had been using the floating tag, php:8.3-fpm.
The floating tag had already moved to 8.3.35.
So my upgrade upgraded nothing.
I’d pinned the version we already had and called it new.
So I scrolled.
PHP on GitHub. Docker Hub.
The fix was already committed.
The image wasn’t there yet.
And that’s when I found out why they were asking.
PHP2Shell.
Two vulnerabilities, CVE-2026-109618 and CVE-2026-109619.
Unauthenticated remote code execution.
Somebody sends a request, and your server runs their code.
The details had leaked before the patch even shipped.
People were already trying it.
Both advisories list every 8.3 before 8.3.36 as affected.
That includes 8.3.35.
The "latest" I’d just pinned.
It was the newest image you could pull that day, and it was still vulnerable.
And the advisories say no config change fully closes it.
Upgrade, or stay open.
Now, about us.
Our major projects run on PHP.
Mostly Laravel.
Public facing.
The festival mood left the room.
What do we even run?
First question.
Not “are we affected.”
Where is all of it?
PHP from 7.4 all the way to 8.5.
On Docker Swarm. On plain Docker. On VMs. On Kubernetes.
Not one place.
Four kinds of places.
So I wrote a script.
It goes to a Docker host, finds every running container that has PHP in it, asks each one its version, and checks that against the fixed releases.
The heart of it is boring:
fixed_for() {
case $1 in
8.2) echo 8.2.35 ;; 8.3) echo 8.3.36 ;; 8.4) echo 8.4.27 ;; 8.5) echo 8.5.12 ;;
*) echo "" ;;
esac
}
Anything not in that list?
No ordinary upstream fixed release to compare against.
Those branches are end of life. 7.4 included.
PHP doesn’t put out regular releases for them anymore.
The PHP security team did publish source-level backports, and some vendors ship patched packages.
But there’s no ordinary patched release to pull from the official PHP images.
Nothing to bump.
Then a second script, to run the first one over SSH on every server and merge all of it into one report.
For Kubernetes, a for loop over every namespace, exec into the pods, php -v.
Not proof of what every FPM process was serving.
But enough to know where to look.
I’ve already forgotten exactly how I wrote it.
It did its job.
The report came back.
A lot of rows.
A lot of them said AFFECTED.
Some of them said:
AFFECTED - EOL branch, upgrade to 8.2.35+
Looking back, that line was too confident.
"Upgrade to 8.2.35+" makes it sound like a version bump.
From 7.4, it isn’t.
Those are the ones I’ll come back to.
The first night
I checked Docker Hub again two or three hours later.
There it was.
8.3.36.
I opened the merge request.
By 1:00 AM on October 22, I was properly into it.
One project took me from 1 AM to 6 AM.
The others ran alongside it.
Somewhere in the middle of all that, between pipelines, I searched for current PHP vulnerabilities.
Scrolled TikTok.
And that’s when the size of it hit me.
News about websites around the world being shut down.
Not just us.
Until then, I’d been heads down in my own little corner.
I had no idea what was going on out there.
The world was having the same night.
That project’s deployment went out later that morning.
The easy ones
Most of our projects are in Docker.
The newer ones especially.
For those, the fix is one line.
FROM php:8.3.36-fpm
Pinned this time. No more floating tag pretending to be the latest.
Same change in a lot of repos, so it went into a loop too: push the branch, open the merge request, next repo.
Then merge.
Run the pipeline.
Deploy.
Easy.
Not fast.
Two to three hours for some.
Four to six for others.
One line of change.
A whole day of waiting on pipelines.
The host-installed ones
Then there were the VMs.
No Docker. PHP installed straight on the OS.
Ubuntu 20.04. 22.04. Some 24.04.
Some of those boxes run automatic upgrades every morning.
On 22.04 and 24.04, Ubuntu shipped patched PHP packages the same day as the advisories.
Those machines were covered.
20.04 was different.
Its PHP 7.4 fix only ships through Ubuntu Pro, as an ESM package.
Without Pro, the morning upgrades had nothing to install.
So I cheated.
I installed Docker on the host.
Built an image with PHP 8.3.36.
Ran PHP-FPM inside the container.
And left everything else alone.
nginx stayed on the host. The app stayed in the same folder, mounted into the container at the same path.
The only thing nginx needed to know was where PHP lives now.
# before: PHP on the host
fastcgi_pass unix:/run/php/php-fpm.sock;
# after: PHP in the container, same nginx
fastcgi_pass unix:/run/app-php/php.sock;
The trick is the socket.
PHP-FPM inside the container listens on a unix socket in a directory that’s mounted from the host.
nginx on the host picks up the socket from that same directory.
I configured PHP-FPM to create the socket as www-data:www-data with mode 0660, and nginx on the host ran as www-data.
On these machines, www-data was 33 on both the Ubuntu host and the Debian-based PHP image, so nginx could open it.
Clever.
Slow to build, but clever.
That’s what I told myself.
The one I did to myself
Here’s the thing about moving PHP into a container.
localhost stops meaning what it meant.
docker-compose up -d --build.
Container up. PHP-FPM up.
And the app couldn’t reach Redis.
The env had Redis pointing at localhost.
On the host, localhost was the server, where Redis was running.
Inside the container, localhost is the container.
No Redis in there.
I’d forgotten to change it.
Then I updated things and forgot to reload.
The next day, I switched that server back from the container to PHP on the host.
And forgot to change fastcgi_pass back.
nginx was knocking on a door nobody was behind anymore.
Brief outage.
My fault.
All three.
None of them were hard.
I was just tired.
A whole night without sleep.
By then, I wanted to sleep so bad.
And a brain that wants to sleep skips the boring checks.
And that container?
The temporary one?
Still running.
Until we do some infra upgrades.
Temporary.
The ones I couldn’t patch
Now the 7.4 rows.
No official patched 7.4 image to pull.
For us, the long-term fix is moving to a supported PHP branch, 8.2 or newer.
And you can’t just bump PHP from 7.4 to 8.x underneath an old Laravel app and hope.
The framework has to move with it.
The code has to move with it.
That’s not a base-image change.
That’s development work.
So I made a list.
Every service I couldn’t fix by bumping a version.
And I pulled in every developer working on those projects.
It was Dashain.
They picked it up after the holiday.
Now it’s slow.
Upgrade PHP. Upgrade Laravel. Test. Next.
Still in progress.
Did anyone get in?
There weren’t any weird logs.
No weird behaviour.
People ask if I sat there watching the logs, waiting for an attack.
I didn’t.
That wasn’t the plan.
The plan was simpler.
Patch first.
Don’t wait to find out whether somebody else got there first.
A story for another day
At the end of my last post, Still Waiting, I wrote this:
“At one point, some of our apps were still on PHP 7.4, 8.1 and 8.2, long after they should have moved on. When things went wrong there, being that far behind made everything harder. That’s a story for another day.”
This is that day.
And it’s the same story.
We develop.
We ship.
We’re good at that.
What we’re bad at is coordinating when it’s time to upgrade.
Nobody owns "this project is still on 7.4."
So it stays on 7.4.
Until a Dashain night, when it suddenly matters.
The projects in active development are mostly fine.
Trivy is already in their pipelines.
Somebody touches them, the pipeline runs, Trivy shouts.
It’s the quiet ones that get you.
The finished ones.
The "it works, don’t touch it" ones.
Nobody rebuilds them.
Nobody scans them.
They just sit there.
Running.
On 7.4.
Hold on
Here’s what changes:
Subscribe to CVE feeds, and match them against what we actually run.
Keep the inventory script. Run it before I need it, not during.
Treat the projects nobody’s touching as projects that need somebody.
Pin versions on purpose, and check that the version actually exists before I call it the latest.
And the 7.4 apps?
Hold on.
They’re getting there.
Slowly.
You can’t just blindly upgrade production applications without knowing what else might break.
But you can’t wait for the attack either.
Dashain is a holiday.
Nobody on the other side of a PHP2Shell request takes holidays.
Precaution is better than cure.
Even during Dashain.