AxisOps Harbour
Labs

Insights

Mitigation code available CVE-2026-109618-109619

The patch took an afternoon. The inventory will take the week

PHP released 8.5.12, 8.4.27, 8.3.36 and 8.2.35 at 14:00 UTC today, alongside the disclosure.

CVE-2026-109618 and CVE-2026-109619, collectively php2shell, are unauthenticated remote code execution on anything serving web requests, CVSS 9.2 from PHP's own security team. CISA published an alert this afternoon telling organisations that cannot update immediately, or that are still on PHP 8.1 or earlier, to take their PHP systems offline until they can (CISA alert, 21 October). That is not language CISA reaches for often. Read the alert in order, though, and the instruction before it is the one that will cost people their week: inventory every system running any version of PHP, including PHP embedded in other software. Nobody is going to do that by the end of the day.

What the bugs are

Both live in request handling, and both need an earlier failure to get going.

CVE-2026-109618 is memory corruption reached through an out-of-memory condition. A request crosses memory_limit partway through setup, PHP bails out of native code before it has finished building its internal state, and the cleanup path then treats a half-initialised structure as a valid one. Getting a request to run out of memory on demand is the easy half: a multipart body with no Content-Length can slip past post_max_size, the header count is unbounded and max_file_uploads does not count empty files (SANS ISC, 21 October).

CVE-2026-109619 is a use-after-free in the same neighbourhood. Per-request global state is not always reset before the memory behind it is released, so a worker that handled a failed request can act on the remains of it when it handles the next one. It wants an error first, then a warning during request initialisation, which a per-directory override such as an .htaccess will happily supply.

So an attacker who can make your application fail gets to influence what the worker does on somebody else's request afterwards. Every PHP serving requests is in scope: php-fpm, Apache with mod_php, php-cgi, LiteSpeed, FrankenPHP. PHP rates mod_php worst, because there the corruption can land before any of your own code runs. An ordinary urlencoded form post reaches it, so the obvious "block multipart at the proxy" workaround does not cover that case.

Patched releases exist for 8.2 through 8.5 and that is all the good news. There is no release for 8.1, 8.0 or 7.4. Upstream has published minimal source backports as git branches for people who build their own, and everyone else is waiting on a vendor. Below 7.4 there is nothing, so a great deal of the PHP on the public internet has no fix tonight at any price.

The workarounds all have a sting

session.upload_progress.enabled = 0 closes the confirmed session path and leaves the rest open. It has to be set somewhere that applies before request parsing: php.ini, an FPM pool or the server config. An application calling ini_set() is far too late, and so is .user.ini. That is the sort of detail that produces a host recorded as mitigated that isn't.

memory_limit = -1 is the strange one. Removing the cap removes the predictable failure the working exploit depends on, so upstream suggests it, administratively, where the application cannot override it. You are deleting your only guard against a single runaway request eating the box, so it needs a cgroup limit underneath it before you touch it, and an allocation failure can still reach the vulnerable path anyway.

Both are the trade we keep making. With regreSSHion in 2024 the workaround swapped remote root for a denial of service. Here you are choosing between an availability risk you have measured and a remote code execution you have not. Take the trade, then get off it.

The question that has no quick answer

You cannot patch what you cannot enumerate, and PHP is close to the worst case.

php -v answers for one binary on the PATH. A web host runs several PHP versions at once by design, one per customer; a server runs php-fpm for the application and a CLI for cron, usually different builds; containers carry their own, invisible to anything reading the host's packages. Behind that sits the category CISA names and nobody tracks: PHP embedded in other software, appliances and vendor bundles with a copy nobody chose.

Then the version string lies to you. Distributions backport the patch without touching the upstream number, so a Debian or Ubuntu host with the fix installed still prints the old version. The only thing that decides is the package revision against that vendor's fixed version for that release, and for 8.1 and older there is no vendor answer yet to compare against.

So the verdict on a lot of hosts tonight is unknown, and that is the verdict worth reporting. A false "vulnerable" sends somebody to remove a memory limit on a box that was already safe. A false "fixed" is worse.

There is a role, and it mostly counts things

We are writing php2shell_mitigation tonight, same shape as the five before it, and it will go up at github.com/axisops/CVE-2026-109618-109619 like the rest. It is an Ansible role that deploys one Python detector, runs it read-only and runs it again with --apply only where a published fix exists and is not installed. The fixed-version table is the artefact that takes the time. It lives in the role's defaults/main.yml, keyed by os-release ID and codename, one row per release carrying the vendor's first fixed package and where it was read from. Tonight most of its 8.1 rows will be null: affected, nothing shipped.

Discovery leads, because that is where the work is. Every SAPI rather than one: php-fpm masters and their pools, mod_php inside Apache, php-cgi, the CLI and the versioned binaries beside it. For each, the owning package against that release's fixed version, with dpkg, rpm and apk comparison implemented in Python so one host can judge a container running another distribution. Then memory_limit, session.upload_progress.enabled and fatal_error_backtraces read per SAPI through php-fpm -i and the pool config rather than from php.ini, because a value set in the wrong place is the difference between mitigated and recorded as mitigated.

Containers get a shell script piped through docker exec, and are reported rather than changed, because a package upgraded inside a running container dies with it. That probe fails usefully: an image with no shell comes back unknown and named instead of counting as clean. no_fix and unknown are the verdicts carrying the value here, and refusing to infer either from the upstream version is what makes them worth having.

Three things it will not do. A PHP compiled into /usr/local or baked into an appliance has no package to judge, so it is reported rather than guessed at. A patched package on disk is not a patched service: php-fpm masters and Apache carry the old code until they restart, so the role names every process still mapping a replaced binary and restarts nothing unless asked, because a restart is an outage and the operator picks the hour. And there is no rollback, because a downgrade here reinstalls an unauthenticated remote code execution. The backup is a record of what was replaced, not an undo button.

Which is the uncomfortable part. Most of the people patching tonight will spend it working out where PHP is, for the third time in five years against a different dependency. Applying the fix is an afternoon. Knowing what PHP you have, from what package, on what release, in what container, is the week, and it is the same week every time, because nobody writes the answer down in a form that survives to the next disclosure. Everything else is archaeology with a deadline. We are going looking, again.

Ours is declared, because Microcelium holds the declaration, so tonight was the afternoon's work without the week in front of it. The week was spent years ago by somebody who wrote the answer down in a form that survived, and that is the whole of the advantage. There is a write-up of the run on the company site.