At 3am on 22 October, PHP shipped fixes for two critical bugs: CVE-2026-109618 and CVE-2026-109619, both scored 9.2. Unauthenticated remote code execution in PHP itself. Public proof-of-concept code existed from the moment of disclosure, and a packaged exploit tool was circulating within minutes of the release.
I run five PHP surfaces. At 9:27 that morning I went looking for them.
maintainai.co.nz, on a VPS, PHP 8.3.35. vaughantaylor.com, on the NAS in my office, PHP 8.5.11. Two Heimdall dashboards on the same NAS, both 8.5.11. And Coolify, the deployment panel that controls all of it, which is itself a Laravel app.
Not one of them was patched. The fix landed in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Every image I was running predated the release by days or weeks, because that is how container images work — you get the PHP that was baked in when the image was built, and nobody pushes you a security advisory for a runtime buried inside someone else’s Dockerfile.
The decision nobody enjoys
At 9:52 I took them all down.
WordPress and Coolify stopped on the VPS. Four hostnames — vaughantaylor.com, www, home, crypto — pulled out of the Cloudflare Tunnel ingress. I probed all five external attack surfaces afterwards and got 404s, 502s and 503s. Nothing was answering.
That’s not a heroic moment. It’s the opposite. My own sites were returning errors to anyone who looked, including me, and there was no patch I could apply yet because the upstream images hadn’t caught up. The guidance from NCSC is blunt about this: if you can’t patch it now, get it off the internet. So it went off the internet.
The part nobody warns you about is what a deliberate takedown looks like from the inside. It looks exactly like a crash. A container stopped by docker stop exits with code 0. No OOM kill, no crash loop, no error in the logs. Just a clean, quiet, deliberate stop — the same signature a Well-Behaved service leaves when you meant it.
Then my AI put one back
On 23 October at 14:38, a monitoring pass from one of my agents found the stopped maintainai.co.nz container, read “stopped” as “broken”, and started it. No lookup, no question, no check against anything. It restored the site, and the site served the unpatched PHP version publicly for about 80 minutes.
That is the part I keep turning over. The agent’s logic was locally perfect: a production service is down, down is bad, start it. Every single step of that reasoning is correct and the conclusion almost cost me the thing the takedown existed to protect. The state was in a config file and an incident note; the agent read the state and skipped the reason.
I don’t think this is an AI problem, incidentally. A human on call at 2am with a DOWN alert and no incident context does exactly the same thing. The failure mode isn’t artificial at all. It’s what happens when “is it running?” gets answered without “should it be?”
By 15:57 it was stopped again. At 16:05 wordpress:latest finally resolved to an image built on PHP 8.3.36, and at 16:12 the container came back up on the patched runtime. This time I didn’t trust the tag — I ran php -v inside the container, then requested the homepage, the pricing page and the challenge page and checked for 200s.
Finishing the fleet
The NAS took another day, because the NAS is where the awkward ones live. vaughantaylor.com runs behind SWAG, which bundles its own PHP. Heimdall does the same. Neither is a case of “update WordPress” — the PHP is in the reverse proxy or in the dashboard image itself.
By 24 October every one of them was moved and each was verified in-container, not assumed:
- vaughantaylor.com — PHP 8.5.12, HTTP 200,
x-powered-by: PHP/8.5.12 - home and crypto Heimdall dashboards — PHP 8.5.12, HTTP 302 to login
- Coolify — 4.4.6 (PHP 8.4.23) to 4.5.0 (PHP 8.4.27), database dumped first, migrations clean
The old Heimdall containers were renamed and left stopped rather than deleted. If the new ones had misbehaved, I wanted the previous ones sitting there with their config intact rather than a hole in the compose file.
Then the tunnel ingress went back, and only then. Restoring DNS exposure is the last step, never the first — a patched service behind a dead tunnel is safe, and an unpatched service behind a live tunnel is the incident.
What actually changed
Three things, and none of them are a patch.
A rule that a clean exit code is not an acquittal. Before starting any stopped production service, the agent has to search the record for an incident or takedown note for that host and service. If it finds one, it stops and asks. This is now written into the operating instructions, not into my memory of what happened.
Alerting to a channel I actually read. Uptime Kuma did its job during the takedown — two DOWN alerts, fired within four minutes, completely correct. It sent them by email, which I check roughly never in real time. An alert that arrives on a channel you don’t watch is a log entry with a subject line.
A watchdog that watches the right thing. Watching whether a container is running would have told me nothing useful during any of this. The watchdog now checks Docker Hub for an image whose PHP version meets the patch floor, pulls it, restarts, verifies, and restores ingress. It runs on a machine that stays on.
The uncomfortable summary
A critical RCE in the runtime underneath half my stack went from disclosure to exploited-in-the-wild in minutes. I did the right thing — took everything offline, verified the blackout, waited for patched images — and the most dangerous moment in the whole episode was my own assistant deciding to be helpful.
That’s the trade you make when you let software operate your infrastructure. It will act on what it can see. Your job is to make sure what it can see includes why.