mikepreston.org

PHP2Shell Lives in the Error Path

PHP's security releases on 21 October fixed two bugs that nobody running PHP can ignore: CVE-2026-109618 and CVE-2026-109619, collectively and inevitably branded PHP2Shell. Both are rated critical, both can lead to remote code execution, and the second also leaks process memory. Fixed builds are 8.5.12, 8.4.27, 8.3.36, and 8.2.35, and the maintainers' position is that there's no complete mitigation short of upgrading. CISA's alert covers "all PHP installations serving web requests".

If you run PHP, stop reading and go and patch, then come back. The rest of this is about why the bugs were where they were, and why "have we patched?" is a harder question than it should be.

Before your code runs

The detail that makes PHP2Shell serious isn't the CVSS score. It's where the bugs sit. Both are in PHP's own request handling: the part of the runtime that reads an incoming request, parses its body, and sets up the environment your script will run in. An attacker doesn't need a vulnerable application, a plugin, or a misconfigured route. They need a PHP process that accepts HTTP requests. Wiz demonstrated it against the stock WordPress, Drupal, and Joomla container images, which tells you roughly how much of the web that is.

The mechanism, at the level the advisories describe it, is a pattern I'd recognise in almost any long-lived runtime. PHP enforces memory_limit by abandoning the request: when an allocation would exceed the limit, it jumps straight out of whatever it was doing and into shutdown. For CVE-2026-109618, the advisory's account is that if this happens at the wrong point during request setup, mostly while parsing a multipart upload, PHP "does not properly account for the early exit and leaves the request memory in an invalid state". Cleanup then treats half-initialised data as if it were whole.

CVE-2026-109619 is the same shape, with a second request. Session upload progress, a feature that reports how much of an upload has arrived, runs before normal request initialisation. If a fatal error or an out-of-memory abort lands at that stage, the request's memory is freed while some global state still points into it. On a worker that serves many requests, which is every production SAPI worth the name, a later request can pick up those dangling pointers. That's also where the memory disclosure comes from, and why one write-up reached for "Heartbleed-style".

Apache's mod_php is the configuration PHP considers most exposed. PHP-FPM is affected too, though FPM's habit of killing a child whose startup fails breaks the simplest sequence. The advisory says no complete FPM exploit has been demonstrated, only that later paths through it remain reachable. The CLI, which doesn't parse HTTP requests, isn't the target.

Error paths are where old assumptions live

I'd take one general lesson from this, and it isn't about PHP.

The code that runs when things go wrong is the least exercised code in any system. The happy path is run billions of times a day and fuzzed for free by every user. The path that runs when an allocation fails halfway through parsing a request body is run rarely, tested less, and written on the assumption that it only has to be correct enough to exit. In a process-per-request model that assumption is nearly true, because the process goes away. In a model where the worker lives on and serves the next request, which is where PHP has been for a long time, the abort path's mess becomes the next request's starting state.

That's not a criticism of PHP's maintainers. It's an observation about where to look, in your own systems as much as anyone's. If your runtime, framework, or service has an "abandon the request and clean up" path, ask what state survives the abandonment. Connection pools, thread-locals, caches keyed per worker, globals in a C extension: anything that outlives a failed request is a candidate for this class of bug. I have, personally, shipped a service whose recovery path was more dangerous than the failure it was recovering from. It's an easy mistake, because the failure path's tests usually stop at "it didn't crash".

Wiz puts the start of the exposure at PHP 5.4, released in 2012, for at least one of the two bugs. That fits session upload progress, which arrived in that release. The official advisories are more cautious and say only "prior to" the fixed versions. Either way, the code has been there a long time, which is exactly what you'd expect of something on an error path.

Discovery got cheaper. Exploitability didn't get easier to judge.

Both bugs were found with substantial AI assistance, and the credits are more tangled than the headlines suggest. Calif, working within OpenAI's "Patch the Planet" programme, reported a version of the bug first, with OpenAI later finding a more complete form. Wiz found and reported both independently in August, and its own account names the models it used. The disclosure credits all three, and a fourth: Quarkslab.

Quarkslab's part is the one I find most telling. In 2024 it ran PHP's first external security audit in years, and while fixing one of its findings a PHP core developer noticed the first signs of the out-of-memory problem. An exploit was theorised, no concrete route to one was found, and issues with clearer impact took priority. Two years later, with models doing much of the searching, three teams found the routes.

The models' contribution seems to have been real, and so was the humans'. Wiz describes the exploit chain as being "at the edge of what human researchers could practically achieve", and the secondary accounts agree that people steered the work and did the per-target debugging while the models did much of the searching. That's the division of labour I'd expect. Finding a suspicious allocation in an error path is now cheap. Showing that an attacker can reach it, control it, and turn it into code execution on a real deployment still takes judgement and a great deal of patience, which is the expensive part.

The uncomfortable consequence is that the cost of finding old bugs has fallen for everyone at once. The coordinated disclosure here was handled well: PHP gave advance notice, Cloudflare had WAF rules in place hours before the details went public, and the release went out early after a leak of the CVE identifiers forced the issue. But the defenders' side of the equation is still a patch, a rebuild, and a redeploy for every PHP process on the internet, and the attackers' side is a diff between two releases. As of CISA's alert, nobody had seen exploitation in the wild. Cloudflare said it had seen attack traffic, and that attackers were changing payloads to get past its rules. By the time you read this, that will have resolved itself in the obvious direction.

php -v will lie to you

Here's the operational trap, and it's the part I'd most want a reader to act on.

Distributions usually backport a security fix into the version they already ship, rather than moving to the new upstream release. Ubuntu 22.04's PHP 8.1 is fixed in package 8.1.2-1ubuntu2.28, and still reports itself as 8.1.2. Ubuntu's older releases get their fixes through Ubuntu Pro, on the same pattern. Red Hat and AlmaLinux shipped errata on the same day; their newer module streams moved to the fixed upstream releases, but RHEL 9's default PHP is still 8.0.30, with the fix backported. So if your inventory, your scanner, or a colleague checks php -v against "8.3.36 or later", it'll report patched systems as vulnerable. That's the safe direction to be wrong in, until the third false alarm teaches everyone to ignore the report.

The question to ask is the package version, not the PHP version: dpkg -s php8.2 or rpm -q php, compared against the fixed version in your distribution's advisory. For containers it's both of those plus the question I keep coming back to on this site: is the image that's running the one you rebuilt? An official php:8.3-apache tag will have moved to a fixed build by now. Your image, built from it last month and pinned by digest, hasn't, and won't until you rebuild and redeploy it. That's the pinning working as designed. It's also why "we updated the base image" and "we're patched" are different statements.

The long tail is worse. PHP 8.1 and earlier get no official release, though the PHP security team has published source patches for 7.4, 8.0, and 8.1. Beyond that you're relying on a distribution's extended support, a third-party repository, or a vendor's appliance firmware. If you have PHP 7 running somewhere that nobody has touched since it was installed, and most estates of any age do, this is the week to find it.

What I'd do

Patch first, through your distribution's packages or the official releases. Then rebuild every image that contains PHP, and redeploy it, and check what's running against the fixed package versions rather than the PHP version string.

Then set session.upload_progress.enabled=0 and restart the workers, even on a patched build. On 23 October PHP disclosed a third, much less likely route through the same feature, CVE-2026-115560, which the fixed releases don't close and the next regular release will. If you can't patch immediately, that setting also closes the session paths of the original two, and on 8.5 so does disabling fatal_error_backtraces. Neither is a fix for those, and the maintainers say as much. A WAF rule helps until someone finds a payload that gets past it, and Cloudflare's own write-up says people already were.

Then, when the dust settles, go and look at your own abort paths. The next one of these probably won't be in PHP.