PHP2Shell on a mixed fleet: auditing 5.6 to 8.4 and proving each build is actually patched
Short answer: for PHP 8.2 and newer, a patched build is provable statically. The fixed releases 8.2.35, 8.3.36, 8.4.27 and 8.5.12 introduce a new INI directive, memory_hard_limit_percent, which exists in none of their predecessors. For every branch from 5.6 through 8.1 there is no fixed release and no version change, so the only proof is behavioural: on a patched build an out-of-memory condition terminates the worker with exit code 1 and skips register_shutdown_function, where an unpatched build exits 255 and runs it.
PHP2Shell is two linked CVEs, published 21 October 2026 and fixed in releases dated the 22nd. The part that made it expensive was not the patching. It was answering, machine by machine, a question that normally takes one command: is this PHP fixed or not.
TL;DR
- Fix: stay on the branch, move to the last patch release of that branch, then apply the patch. Never patch the patch version you happen to be running — on 7.1 and 7.2 that leaves CVE-2019-11043 open, and on 7.4.5 and 8.0.7 the official patch does not even apply.
- Proof, supported branches:
php -r 'var_dump(ini_get("memory_hard_limit_percent") !== false);'. True only on 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 and later. - Proof, EOL branches: the OOM behaviour test. Exit 1 and no shutdown function means the backport is compiled in; exit 255 and a shutdown function means it is not.
- Trap: the behaviour test proves nothing on a supported branch. The upstream fix deliberately leaves normal
memory_limithandling alone, so 8.2.29 and 8.2.35 behave identically under it. I measured both. - Trap:
max_multipart_body_partsinphp -iis a valid marker on 7.1 through 7.4 and useless on 8.0 and 8.1, where the directive has existed since 8.0.28. - Trap: the mitigations that look most obvious (
RLIMIT_AS,ulimit -v, systemdLimitAS=) trigger the vulnerable allocation failure themselves.
What PHP2Shell actually is
Two bugs in request body parsing, both reachable before a single line of application code runs. The php-src advisories are the only authoritative description; several vendor write-ups have the two CVE numbers swapped, so take the GHSA pages as the reference.
- GHSA-w8hw-w9fx-j79w (CVE-2026-109618): an out-of-memory condition during body parsing aborts the parse with a
longjmp(). Half-initialised structures are later read as valid. Triggering it is a memory-amplification problem, not a size problem: a small multipart body with a large number of parts occupies orders of magnitude more in PHP’s heap than it does on the wire. - GHSA-6wmf-4rv5-p3rp (CVE-2026-109619): stale global state. A producer request that dies abnormally (fatal error, timeout, OOM) leaves residue, and a consumer request in the same worker reads it.
The two advisories are identical in their metadata, which is a hint about how closely linked the bugs are. Both are CVSS 4.0 score 9.2, critical, on the same vector CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N: high complexity and present attack requirements, but no privileges and no user interaction. Both list the same five weaknesses rather than one each: CWE-416, CWE-755, CWE-824, CWE-1325, CWE-1419. Both were published on 21 October 2026 within about twenty seconds of one another, 13:34 UTC. Both give the same four fixed versions. If you are reading a vendor summary that assigns distinct CWEs to the two CVEs, it is editorialising.
Two consequences decide the whole response. The body is parsed before routing, so authentication, CSRF tokens, maintenance mode and any framework-level filtering sit downstream of the bug and protect nothing. And the confirmed consumer path on FPM is session upload progress, which is on by default and runs without the application ever calling session_start(). I measured session.upload_progress.enabled at 1 on a stock php:8.2-cli-alpine. An application that never touches sessions is still exposed.
Fixed releases exist for four branches only: 8.5.12, 8.4.27, 8.3.36 and 8.2.35, all dated 22 October 2026 in ChangeLog-8.
The reason this could not wait for a maintenance window is the timeline. Amazon’s threat intelligence team reports a first exploitation attempt in its MadPot honeypot telemetry on 17 October, four days before disclosure and while the bugs were still under embargo, with more following from 02:00 UTC on 21 October; Sansec records Cloudflare seeing attacks at 07:43 UTC on 21 October. The advisories went public at 13:34 UTC that day. Whichever number you take, the grace period between “this is public” and “this is being attacked” was negative.
Why a mass-scan script gives you the wrong answer
A script that SSHes everywhere and runs php -v reports “no PHP” on machines where PHP is the only thing running. I had one written and threw it away.
The failure is structural. On a host whose PHP lives in a container, php -v on the host is not installed, so the script records a clean machine. On a Proxmox node the PHP is inside an LXC container that docker ps knows nothing about. On a legacy OpenVZ host you need vzlist -a. And docker exec against a container that has stopped answering hangs with no timeout at all, which parks the scan and leaves a stuck process on the host for someone to find later.
Take a registry of about two hundred entries (physical servers, VMs, LXC containers, legacy OpenVZ, cloud instances) where some are in an Ansible inventory and some exist only in a text file. Walking it by hand against a fixed checklist, one machine at a time, PHP turns up in roughly forty places across branches from 5.6 to 8.4, and the status “probably vulnerable” is never needed: wherever there is access, the exact version and SAPI are knowable.
Before any of that, reconcile the inventory against the registry. On a fleet that size expect a dozen or so entries to disagree, and each disagreement is a machine that silently drops out of the audit: containers listed in one source and absent from the other, decommissioned nodes still described as live, the same container ID apparently running on two different nodes, a “mail server” that turns out to be empty. None of those are exotic. They are what happens when two people maintain two lists for three years.
One checklist item that cost me real time: do not test SSH access with a plain ssh command. My ~/.ssh/config was substituting a personal key for every host, so “the shared key works here” was true of a key I was not testing. On one machine that made me roll back a key that had already been added correctly, then add it again. The only honest form is:
| |
The per-machine checklist, read-only
Seven items, in this order, nothing that writes:
- OS; PHP on the host; processes matching
php,php-fpm,apache2|httpd,nginx,frankenphp. Container processes are visible from the host, which is often the first hint that the host-levelphp -vwas lying. - Where PHP lives: host,
docker ps,pct listfor Proxmox,vzlist -afor OpenVZ. - The version of every SAPI that is actually serving, not the one on
$PATH.php -v,php-fpm -v, and inside containers viadocker exec. - For distribution packages, the CVE in the package changelog — a backport does not change the version number.
- For Docker, whether the running container’s image matches the image currently under that tag, and when that image was built.
- Whether it takes web requests, and what proxies it.
- Mitigations in force:
session.upload_progress.enabled,post_max_size, accounting forconf.dand pool-levelphp_admin_value.
| |
The timeout 20 is not decoration. Without it one unresponsive container stops the audit. When docker exec is hopeless you can still get the version from the image config without entering the container:
| |
Which branches have a fix at all
Every branch from 5.6 upward has a path to a fix. Having a fix and having a package are different things.
| Branch | Official | What is actually installable |
|---|---|---|
| 8.5, 8.4, 8.3, 8.2 | releases 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 | official Docker images, sury packages |
| 8.1, 8.0, 7.4 | minimal source backports from the PHP Security Team: branches PHP-{7.4,8.0,8.1}-CVE-2026-109618-109619, one commit on top of the last tag | plus sury packages for Debian 11–13; 7.4 also via Freexian ELTS and Ubuntu Pro ESM |
| 7.3, 7.2, 7.1, 5.6 | none | sury patches by Ondřej Surý; Ubuntu Pro ESM for the distro php7.2 and php5; TuxCare alt-php, paid |
The official backports are a single commit each, and the commit stats are worth knowing before you plan the work: 712e60f9edfd on the 7.4 branch touches 17 files for +240/−59, 82f8f31b16a0 on 8.0 touches 17 files for +229/−59, and 1d7dec178a09 on 8.1 touches 23 files for +311/−73. None of them touch the fpm or apache2handler SAPI directories.
The sury patch for the oldest branch documents its own limits, which is why I trust it more than the community ports. The header of 0261-Avoid-state-corruption-via-bailout.patch, by Arnaud Le Blanc, co-author of the upstream fix, states plainly: “Minimal mitigation for EOL version.” It then lists what it deliberately leaves out, including that “the max_multipart_body_parts hunk of the 7.4 patch is left out; the limit comes with the CVE-2023-0662 backport”. A patch that tells you what it is not doing is a patch you can plan around.
A third-party “universal” port covering 5.4 to 7.3 was circulating. It modifies 8 files against the official 17, with zend_hash.c, php_variables.c and the VM untouched, and its own authors tested it on the built-in CLI server. I did not use it.
The ordering rule that matters
Patch the last patch release of the branch, not the version you are running. Two independent reasons, both verified on vanilla tarballs:
- The patch frequently does not apply. The official 7.4 commit goes onto 7.4.33 and 7.4.29 cleanly and conflicts on 7.4.5 in
zend_vm_*. The 8.0 commit is clean on 8.0.30 and conflicts on 8.0.7. - Where it does apply, it can leave an older hole open. The sury 7.1 patch applies cleanly to 7.1.8, 7.1.11 and 7.1.17 — all of which predate the CVE-2019-11043 fix that landed in 7.1.33. Closing PHP2Shell on 7.1.8 buys you an unauthenticated FPM RCE you already had.
How to prove a build is patched
This is where I spent the most time, and where the published advice is thinnest. Three layers, and they fail differently.
The version number is not a proof, in either direction. A self-built “7.4.33 plus patch” reports exactly 7.4.33, the same as an unpatched one. And distributions do not even agree with each other about whether the version should move. Ubuntu backports into a frozen upstream version; Debian rebases to the fixed upstream version. From the two vendors’ own trackers:
| Vendor | Suite | Package | Fixed version | Upstream version moved? |
|---|---|---|---|---|
| Ubuntu | trusty (ESM) | php5 | 5.5.9+dfsg-1ubuntu4.29+esm18 | no |
| Ubuntu | xenial (ESM) | php7.0 | 7.0.33-0ubuntu0.16.04.16+esm22 | no |
| Ubuntu | bionic (ESM) | php7.2 | 7.2.24-0ubuntu0.18.04.17+esm14 | no |
| Ubuntu | focal (ESM) | php7.4 | 7.4.3-4ubuntu2.29+esm5 | no |
| Ubuntu | jammy | php8.1 | 8.1.2-1ubuntu2.28 | no |
| Ubuntu | noble | php8.3 | 8.3.6-0ubuntu0.24.04.13 | no |
| Debian | bookworm | php8.2 | 8.2.35-1~deb12u1, from 8.2.32-1~deb12u1 | yes |
| Debian | trixie | php8.4 | 8.4.27-1~deb13u1, from 8.4.24-1~deb13u1 | yes |
Two rows there are worth a second look. Ubuntu’s php8.1 on jammy is fixed at 8.1.2, a branch upstream declared end of life with no fixed release. The distro is maintaining it anyway. Ubuntu’s php8.3 on noble stays at 8.3.6 while upstream’s fix is 8.3.36; a scanner comparing against “8.3.36 or newer” marks a fully patched machine as vulnerable. Meanwhile on Debian bookworm the version genuinely did move to 8.2.35, so there php -v works fine.
The practical consequence is that a version check needs to know which vendor built the PHP before it can interpret the number, which is a strange thing to need and a good argument for checking something else instead. Both tables above come from machine-readable sources you can query yourself: https://ubuntu.com/security/cves/CVE-2026-109618.json and the Debian security tracker’s JSON. A scanner working from php -v alone will call a patched image vulnerable and a backported package safe, and be wrong both times.
The INI output is a proof on supported branches only, and this is new. The fixed releases add a directive that no earlier release has. I checked main/main.c across tags:
| Tag | memory_hard_limit_percent occurrences |
|---|---|
| php-8.2.29 | 0 |
| php-8.2.34 | 0 |
| php-8.2.35 | 4 |
| php-8.3.36 | 4 |
| php-8.4.27 | 4 |
| php-8.5.12 | 4 |
| php-8.1.34 | 0 |
PHP-7.4-CVE-... backport branch | 0 |
PHP-8.0-CVE-... backport branch | 0 |
PHP-8.1-CVE-... backport branch | 0 |
The directive appears exactly in the four fixed releases and in none of their predecessors. It is declared as PHP_INI_ENTRY("memory_hard_limit_percent", NULL, PHP_INI_ALL, OnChangeMemoryHardLimitPercent) and rejects nonsense with “memory_hard_limit_percent must be >= 100, or -1”. That gives one check valid on any supported branch, including a distro package whose version string refuses to move:
| |
Two things about that check, both of which I got wrong before I got them right.
The first is the !== false, which is not defensive style but the whole mechanism. The directive’s default value is NULL, so on a patched build ini_get() returns an empty string, and an empty string is falsy in PHP. Written the obvious way, the check reports every build in the fleet as unpatched:
| Image | ini_get(...) | !== false | if (ini_get(...)) |
|---|---|---|---|
php:8.2-cli-alpine3.21 (8.2.29) | false | false | false |
php:8.2-cli-alpine (8.2.35) | '' | true | false |
php:8.1-cli-alpine (8.1.34, EOL) | false | false | false |
Measured on those three images. The middle row is the one that matters: a patched build and an unpatched build are indistinguishable under a truthiness test, and the failure is silent and uniform. A scanner written that way produces a clean-looking report in which nothing is patched, which is the kind of output people believe because it matches their fears. array_key_exists('memory_hard_limit_percent', ini_get_all()) works too and is harder to get wrong.
The second is the false case. The directive is absent from all three official EOL backport branches and from all six sury EOL patch sets I unpacked, so a false on 7.x or 8.0 tells you nothing at all. It is a positive marker, not a negative one.
The behaviour test is the proof on EOL branches, and only there. The EOL backports replace the bailout with a hard exit. In the 8.0 patch the diff reads, verbatim:
| |
and the new function ends in _exit(1) after writing the fatal error to stderr directly. That is observable from userland. The whole test is this:
| |
| |
Three builds, same script, memory_limit=64M in a 512 MB container:
| Build | Exit code | Fatal error written to | Shutdown function |
|---|---|---|---|
php:8.2-cli-alpine3.21, PHP 8.2.29 (unpatched) | 255 | stdout | ran |
php:8.2-cli-alpine, PHP 8.2.35 (fixed) | 255 | stdout | ran |
sury php8.0-cli 1:8.0.30-25+0~20261021.88+debian12~1.gbpabebea, EOL backport | 1 | stderr | did not run |
The destructor ran in none of the three.
The middle row is the one that surprised me, and it corrects what I had assumed from reading the patches. The supported-branch fix leaves ordinary memory_limit handling exactly as it was (soft OOM is deferred to a safe point rather than converted into a process exit), so 8.2.29 and 8.2.35 are indistinguishable under this test. Only the EOL backports turn a memory_limit breach into _exit(1). Anyone using the OOM test as a general “is it patched” check on 8.2 will get a false negative every time.
The supported branches do have a hard path, through the new directive: zend_mm_hard_limit_error prints “Hard memory limit of %zu bytes exhausted (tried to allocate %zu bytes) in %s on line %d” and calls _exit(137). I could not reach it in testing, because the soft limit fires first even with -d memory_hard_limit_percent=100, so I am describing that one from the source rather than from a measurement.
The marker table, per branch
Putting it together, because the answer differs by branch and getting it wrong wastes a day:
| Branch | Static marker in php -i | Why |
|---|---|---|
| 8.2–8.5 | memory_hard_limit_percent | new in the fixed releases |
| 8.1, 8.0 | none | max_multipart_body_parts already exists since 8.0.28 |
| 7.4, 7.3, 7.2, 7.1 | max_multipart_body_parts | absent from every vanilla 7.x, added by the fix |
| 5.6 | max_multipart_body_parts, but only with the second sury patch | the bailout patch alone adds no directive |
I verified the second column by counting the directive in main/main.c across vanilla tags: zero in 5.6.40, 7.1.33, 7.2.34, 7.3.33 and 7.4.33, one in 8.0.28, 8.0.30 and 8.1.34.
The build assert that asserts nothing
If you compile your own, put a marker check in the build so it fails when the patch silently stops applying. My first choice was wrong: I used grep -q zend_objects_store_mark_destructed, which passes on unpatched source as well, so the assert was decorative. The rule is to check the candidate symbol against a vanilla tree first and require zero.
array_init_singleton in Zend/zend_API.h is the one I would use now. It occurs zero times in vanilla 5.6.40, 7.4.33 and 8.0.30, and is added by every one of the six EOL patch sets — one marker covering 5.6 through 8.0 rather than a different grep per branch.
| |
One gotcha if you verify a package rather than a source tree inside a container: debian:*-slim images ship /etc/dpkg/dpkg.cfg.d/docker with path-exclude /usr/share/doc/* and path-include /usr/share/doc/*/copyright. The package changelog you were told to grep for the CVE is not in the image at all; only copyright survives. I spent a while convinced a sury package had shipped without a changelog entry.
Mitigations that do not work, and one that makes it worse
Most of the obvious knobs are downstream of the bug.
file_uploads = Offdoes not help. The multipart body is still parsed.ini_set()and.user.iniare applied after the body is parsed. Too late by construction. Onlyphp.ini, pool-levelphp_admin_value, or the Apache config, followed by a full worker restart — for Apache, not agracefulone.- Blocking
multipart/*at the proxy does not close the urlencoded variant, so you need a body-size limit as well. Do not scope the block to POST either: since 8.4,request_parse_body()parses other methods too. RLIMIT_AS,RLIMIT_DATA,ulimit -vand systemdLimitAS=are actively harmful. They cause the same allocation failure the bug lives in. I would expect OpenVZ beancounters to behave the same way, but that is a guess I have not tested.memory_limit = -1, which the advisory offers as a mitigation for 109618, needs care. On a pool with a few hundred workers and no container memory limit it trades a hard-to-exploit RCE for a reliable OOM of the whole VM, and in a container sharing space with a database it hands the OOM killer the database.
The one mitigation worth applying everywhere is session.upload_progress.enabled = 0 in php.ini or the pool config. It closes the confirmed FPM path for 109619 and breaks almost nothing — upload progress bars, if the application has them.
What the patch changes in application behaviour
The EOL backports change three things you can notice from the application side, and all three are worth knowing before deploying rather than after.
- Shutdown functions stop running on OOM. The worker goes through
_exit(1), soregister_shutdown_functionnever fires, the message goes to stderr instead of the output stream, and FPM behind nginx returns 502 where it used to return 500. An application that routinely brushes againstmemory_limitwill start producing 502s, and whatever you do in a shutdown handler (flushing an error reporter, releasing a queue job, writing a last log line) stops happening. If your monitoring matches on exit code 255, it will not match on 1. - Session upload progress is disabled in code, not in configuration. The backports patch the guard to
if (!PS(rfc1867_enabled) || true), so the feature is off whilephp -istill reportssession.upload_progress.enabled => On. The supported branches took the other route: they keep the feature and add a guard,if (PG(modules_activated)) { /* request_parse_body(). Do not run here as it corrupts the session. */ return retval; }. That guard is new in the fixed release:PG(modules_activated)occurs twice inext/session/session.con 8.2.29 and 8.2.34, and three times on 8.2.35. - A new limit truncates very large forms.
max_multipart_body_partsdefaults to-1, which falls back tomax_input_vars + max_file_uploads. On stock defaults that is 1000 + 20, so 1020 parts, after which you get: “Multipart body parts limit exceeded %d. To increase the limit change max_multipart_body_parts in php.ini.” I measured the stock values onphp:8.2-cli-alpine:max_input_vars1000,max_file_uploads20,max_multipart_body_parts-1. The fallback formula is identical inmain/rfc1867.con vanilla 8.2 and in the 5.6 backport.
Looking for traces
Because exploitation preceded disclosure, retrospective hunting is part of the job. The signatures come from the mechanism, not from a particular payload:
| |
Beyond the logs: runs of large POSTs from one address in a short window, PHP_SESSION_UPLOAD_PROGRESS appearing in request bodies, an unusually long PHPSESSID in a Set-Cookie response, and shell processes such as sh -c, curl or wget parented to a PHP worker. Adding $content_type and $request_length to the nginx access log format makes the first of those searchable, and costs nothing to leave on.
Step by step
- Reconcile the registry against the inventory before touching any machine. Every disagreement is a machine that would not have been checked.
- Walk the fleet by hand against the seven-item checklist. Record the exact version and SAPI, never “probably”.
- For each branch found, decide the source of the fix from the availability table — official release, official EOL backport, or sury patch set.
- For every self-built target, apply the patch to the last patch release of the branch, and check for older CVEs in whatever you are moving off.
- Verify in three layers: patch applies, patch compiles, patch works. The third is the marker table plus the behaviour test for EOL.
- Tag self-built images and packages so the build is identifiable later — an image tag and a
LABEL, or an RPMReleasebump. - Apply
session.upload_progress.enabled = 0everywhere regardless, inphp.inior the pool config. - Run the retrospective log hunt back to 17 October before you close the incident.
Bottom line
“EOL means there is no fix” is false — every branch from 5.6 up has a usable patch, from the PHP Security Team for 7.4 through 8.1 and from the Debian maintainer for everything older. “There is a fix” is not the same as “there is a package”, and for the old branches it means a build you now maintain. Patch the last patch release of the branch, never the version you are on.
The part worth taking away is the verification. php -v cannot answer the question on any branch. On 8.2 and newer, ini_get('memory_hard_limit_percent') !== false can, and it works through a distro backport that never changes the version string. On 5.6 through 8.1 nothing static answers it and you need the OOM behaviour test — which, inverted, means that same test proves nothing on 8.2, where both the vulnerable and the fixed build exit 255 and run their shutdown functions. I measured that one the hard way.
The branch-by-branch work is written up separately: PHP 8 and the frozen Docker tags, PHP 7.1 to 7.4 with the official and sury backports, and PHP 5.6, where the only proof is behavioural.
Primary sources: GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp, php.net ChangeLog-8, php-src for the backport branches, packages.sury.org for the EOL patch sets, and docker-library/php for the historical Dockerfiles.