Skip to content
The Log

The fast part was the preparation

php2shell-the-fast-part-was-the-preparation.png

Good security notices are pretty boring. On Tuesday evening php.net posted one: a security release on Wednesday at 14:00 UTC, two critical remote code execution bugs, both rated 9.2, prepare to upgrade. No details and no fix yet, just a time and a severity, which is exactly what you need to get ready. The bad kind of notice is the one you never get, where the first you hear of a bug is somebody using it.

It wasn't a quiet run-up for them. The CVE numbers had started circulating online that afternoon, so the PHP security team brought the notice forward and moved the release up a day, from Thursday to Wednesday; releasing on the spot would have left most people without a package to install. On Wednesday they shipped at 13:34 UTC, nearly half an hour ahead of their own schedule, with advisories that said plainly who was at risk and what to do if you couldn't patch straight away, and source patches even for 7.4, 8.0 and 8.1, which have been out of support for a while. That's how a critical release should be run.

The bugs are CVE-2026-109618 and CVE-2026-109619, by now better known as PHP2Shell: on a vulnerable setup, a single unauthenticated web request can be enough to run code on the server, before the application itself does anything.

We used the time in between to get ready, not to wait: the plan and the procedure lined up, so everything could kick in the moment PHP shipped. By 15:03 our own platform was running the fix; by 15:30 so was every production customer server we manage that runs PHP. No customer opened a ticket, pressed a button or planned a maintenance window.

Two hours isn't the interesting part, though; you can get any number down by skipping steps. We didn't skip any. The fixed PHP was built, tested and reviewed, then went to our preview platform and a canary there, then our production platform, and only then customer servers: the same order as any update. The speed didn't come from rushing those steps; it came from all of them existing before PHP's fix did. That's the part you can't do on the day.

The afternoon

The process was clear; the one call left to make came at 13:45: build it ourselves, or wait for our Linux distribution to package it. For a bug like this, speed settles that, so we built it from PHP's release. The patch was up for review seven minutes later and merged at 14:40, once it had built and passed its tests. Every build was cached before a single server switched over, so servers installed a finished PHP instead of compiling one mid-rollout. From there it was the same automated rollout every update goes through: preview platform and canary, production platform, customer servers, each step checked before the next. We supervised it rather than doing it by hand. strackt is small right now, but the rollout doesn't know that; it would run the same way at a hundred times the size, with no extra work for anyone.

13:34  PHP fixed releases downloadable
13:45  decision: build it ourselves
13:52  patch up for review
14:40  merged; built and tested
14:58  preview platform and canary patched
15:03  production platform patched
15:30  every production customer server
       running PHP patched
16:32  last preview test server patched
       (more on that below)

"Patched" here means the running PHP process reports the fixed version. A finished build or a written config doesn't count; a fix that hasn't reached the process serving requests protects nobody, so that's where we stop the clock. PHP updates need a service restart, not a reboot.

From fix to every production customer server took 1 hour 56 minutes, against the 24 hours we promise for security fixes; and without our own build, we'd still be waiting for our distribution.

Under the hood

This part's for the curious. Every server we manage runs NixOS, and PHP comes from nixpkgs, its package collection. That buys us a lot: every server is built from the same declared configuration, and a change rolls back as a whole. The price is that on day one of a security release, NixOS can be slower than the big distributions. Debian and Ubuntu had fixed packages out about ten minutes after PHP's release. nixpkgs merged its fix at 14:43, but a channel only moves once the build farm has rebuilt and tested the whole package set, and as we write this, on Wednesday evening, no stable channel has it yet.

So we don't wait for the channel. We have an overlay: a small override that builds a single package from the official upstream source, ahead of the distribution. We previously used it for a MariaDB fix, which is why this time it was a procedure rather than an improvisation. It also retires itself. The override only applies while the distribution's PHP is older than the fixed version; once the channel catches up it does nothing, and an automated check flags it for removal. That's the trade we made picking NixOS, and the overlay is how we pay for it without passing the bill on to anyone else.


What went wrong, and what changes

Two things went wrong, and neither touched a production customer. What we do about them matters more.

Our alerting never saw it. Normally a security fix reaches your server because we match advisory feeds against every server automatically. This time that never fired. Part of it is the feeds: NVD published at 14:17, and OSV still has no record for either CVE. Part of it is us: we tracked PHP through a single source that only publishes once a fixed build exists, and we didn't read PHP's own advisories on GitHub, which were out at 13:34. The rollout was automated; spotting the fix wasn't. It went out because we were watching php.net after the pre-announcement, not because our alerting caught it.

For a critical release the vendor is the fastest source by hours, so it has to be one of our sources, not something a person happens to keep an eye on. So we're adding GitHub's advisories to what we poll, plus a way to start the whole match-and-patch process by hand from a CVE number, so a bug we spot before the feeds do still gets an advisory, alerts and a tracked rollout. A fix done outside the pipeline should also leave a public record. Our open numbers only count fixes that went through the automated flow, so this one doesn't show there yet; until it does, this post and our internal response log are the record.

We tripped over our own platform. The PHP update itself went fine everywhere; our own restart was the problem. Preview is our own staging environment, with test servers of our own. Two minutes after the rollout to those started, we patched the preview platform, which restarted its background workers mid-job. A job killed that way leaves no failure behind, so the platform believed the rollout was still running and refused to start it again; a routine deploy 25 minutes later did the same thing a second time. We untangled it by hand, and the last preview test server got its fix at 16:32 instead of around 15:30. Production customer servers run on a separate platform and had been done for an hour.

There are two lessons. The obvious one is not to restart the thing doing the patching while it's patching; that's a rule now, with no platform patch or deploy while a rollout is running. The less obvious one is that the rule only papers over the real bug. A job that dies should fail loudly and let go of the server, not silently hold it. Making interrupted updates recover by themselves is the real fix; it's planned, not done.

Where this leaves things

So far nobody has confirmed a successful attack. There are exploitation attempts, loads of fake proof-of-concept code, and security firms that disagree about how far it's got. History isn't very comforting, though. The last big PHP bug, in 2024, was attacked within a day of its fix and carried ransomware within two. This time the write-ups explaining exactly how both bugs work came out alongside the fix, and nearly half of all PHP sites run a branch PHP no longer ships releases for; they're relying on whoever packages their PHP to backport it. The tail on this one is going to be pretty long...

For the servers we look after, it ended at 15:30 on Wednesday.