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.