On 21 October 2026 the PHP project released fixes for two vulnerabilities,CVE-2026-109618 and CVE-2026-109619. Both are rated 9.2 on CVSS v4.0, both are unauthenticated remote code execution, and both were reported exploited in the wild. The fixes shipped in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35.
Three days later our platform image was still running 8.4.26. No pull request had been opened. No alert had fired. Nothing was broken, nothing was red, and the dependency bot that exists precisely to catch this had been running every week the whole time.
This is a write-up of why, because the interesting part is not the CVE. It is that we had the right tool, correctly configured, watching the right file — and it was structurally incapable of ever saying anything.
First: you probably do not need PHP 8.5
The reflex on reading "fixed in 8.5.12" is to plan a minor-version upgrade. For these two, that is wasted work. PHP backported the fixes across every supported branch, so8.4.27 carries exactly the same patch as 8.5.12. If you are on 8.4, the fast path is the latest 8.4 point release, not a minor jump that brings a set of new deprecations along with it.
There is a third advisory worth knowing about, and it cuts the other way.CVE-2026-115560 was published on 23 October and is not fixed by the 21 October release. It affects fully patched builds, 8.5.12 included. The published mitigation is an ini setting:
session.upload_progress.enabled = 0So upgrading to 8.5 would not have helped there either. Two of the three are a patch release; the third is a config change on every version.
Why that mitigation has to be in ini, not in your code
It is tempting to reach for application-level middleware. You cannot, and the reason is worth internalising because it generalises.
PHP runs the upload-progress handler from the SAPI, while it is parsing a multipart request body — and it starts a session itself in order to record progress into one. That happens before your router, before your middleware, before any line of your application. An unauthenticated POST reaches it on a platform that never calls session_start() anywhere, against routes that have nothing to do with uploads, regardless of whether your own sessions are database-backed and never touch PHP's session storage.
Whity disables it in a conf.d file baked into the image. Nothing in the platform reads upload progress, so there is nothing on the other side of that switch to lose.
The actual failure: a tag with no version in it
Here is the configuration that had been in place for months:
# .github/dependabot.yml
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"And here is what it was watching:
FROM dunglas/frankenphp:1-php8.4 AS baseDependabot bumps version strings. It compares what is written in your manifest against what is published, and opens a PR when the written thing is behind. The string written here is 1-php8.4. That string is correct for 8.4.0, and for 8.4.26, and for 8.4.27, and for every 8.4 release that will ever exist. It can never be behind, so there is never anything to raise.
The watcher ran on schedule, found the tag current — accurately — and reported nothing. It opened zero pull requests in its entire operational life, and every one of those weeks looked exactly like a week in which everything was up to date.
The floating tag is not useless. It does pull the latest patch, on rebuild. The trap is the word "on rebuild": the only thing that rebuilds a release image is a release, and releases are cut for application changes, not for base-image ones. So the PHP version actually serving traffic is whenever the last feature happened to ship. Nobody chose it and nobody can read it from the repository.
A floating tag reads as "always current" and behaves as "frozen at the last deploy, and unwatchable".
This has a shape, and we had seen it before
The same repository already carried a comment in that same file, written after a previous incident: a critical advisory had been fixed in the root lockfile and closed, while the identical dependency sat unpatched for months in a sidecar service's own lockfile. The scanner kept raising the alert, because scanners read every lockfile. Dependabot never opened a PR for it, because no ecosystem/directory pair covered that directory.
One layer down, the same failure: there, a directory nothing covered; here, a tag nothing could move. Both read as "configured" on inspection. Both produce silence that is indistinguishable from good news.
That is the general form worth naming. A check that cannot fail is not a check, and the most expensive version of it is the one that is genuinely wired up, genuinely running, and genuinely has nothing to look at.
What we changed
The base image now names a patch version, which is the whole point rather than a detail:
FROM dunglas/frankenphp:1-php8.4.27 AS baseNow the version is a diff a human reviews and a string Dependabot can move. A stale exact pin is visible in the file; a stale floating tag is not visible anywhere.
Two ini settings went in alongside it, in their own file because they are not tuning:
session.upload_progress.enabled = 0— the mitigation above.expose_php = Off— PHP advertises its exact patch version inX-Powered-Byon every response, including the ones your application never sees: a fatal during boot, a request refused before the kernel runs. On its own that is not a vulnerability. During a week like this one it is a free answer to "is this host worth firing the current exploit at", and it dates the deployment without anyone having to probe for it.
The fix is a test, not a version
Bumping to 8.4.27 fixes today. It does nothing about the next one, which is the part that actually went wrong. So the change that matters is a test:
public function test_the_base_image_pins_an_exact_php_patch_version(): void
{
preg_match('/^FROM\s+dunglas\/frankenphp:(\S+)\s+AS\s+base\s*$/m', $this->dockerfile, $m);
self::assertMatchesRegularExpression('/php\d+\.\d+\.\d+/', $m[1] ?? '');
}It asserts the shape of the version and never the value. A test naming 8.4.27 would have to be edited by every future security bump — which attaches a chore to the most time-critical change in the repository, and a chore is the thing people route around at 2am. This one asks only the question the floating tag got wrong, so it stays quiet through every legitimate bump and fails the moment someone simplifies the tag back.
Because that is the realistic regression. 1-php8.4.27 looks over-specified. Someone will eventually tidy it to 1-php8.4, with good intentions, and silently switch the watcher off again. The pin and the Dependabot entry are one mechanism, not two, and now the test says so.
We verified it by mutation rather than by assumption: reverting the tag and removing both ini settings fails all three assertions; the fix passes all three. A guard nobody has watched fail is just a guard-shaped comment.
If you run PHP in a container
Three things worth doing today, in order:
- Check what is actually running, not what you think you deployed.
docker exec <container> php -v. The build date in that output is more honest than any tag. - Set
session.upload_progress.enabled = 0inconf.dand restart your workers. It mitigates the unpatched advisory and costs nothing unless you genuinely read upload progress. - Look at every floating tag you depend on and ask what would tell you if it fell behind. If the answer is "a rebuild we have no reason to trigger", that dependency is unwatched no matter what your bot configuration says.
Point three is the one that generalises past PHP. The version was easy. Finding out that nobody was going to tell us was the expensive part.
Whity v0.2.16 ships the patched base image and both ini settings. Seewhity.dev/security for the advisory and what self-hosted operators need to do.