BitPage

PHP2Shell on a mixed fleet: auditing 5.6 to 8.4 and proving each build is actually patched

By  ·   · 20 min read

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

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.

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:

1
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/<key> -p <port> root@<host> true && echo OK

The per-machine checklist, read-only

Seven items, in this order, nothing that writes:

  1. 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-level php -v was lying.
  2. Where PHP lives: host, docker ps, pct list for Proxmox, vzlist -a for OpenVZ.
  3. The version of every SAPI that is actually serving, not the one on $PATH. php -v, php-fpm -v, and inside containers via docker exec.
  4. For distribution packages, the CVE in the package changelog — a backport does not change the version number.
  5. For Docker, whether the running container’s image matches the image currently under that tag, and when that image was built.
  6. Whether it takes web requests, and what proxies it.
  7. Mitigations in force: session.upload_progress.enabled, post_max_size, accounting for conf.d and pool-level php_admin_value.
1
2
3
4
5
6
7
8
# What PHP is really serving, host plus containers, with a timeout for hung containers
ps -eo user,comm | grep -E 'php|apache2|httpd|nginx|frankenphp' | sort | uniq -c
for c in $(docker ps --format '{{.Names}}'); do
  v=$(timeout 20 docker exec "$c" sh -c \
      'for b in php php-fpm frankenphp; do command -v $b >/dev/null && printf "%s: %s; " $b "$($b -v | head -1)"; done' \
      </dev/null 2>/dev/null)
  echo "$c | $(docker inspect -f '{{.Config.Image}}' "$c") | ${v:-no php}"
done

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:

1
docker image inspect <image:tag> --format '{{range .Config.Env}}{{println .}}{{end}}' | grep PHP_VERSION

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.

BranchOfficialWhat is actually installable
8.5, 8.4, 8.3, 8.2releases 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35official Docker images, sury packages
8.1, 8.0, 7.4minimal 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 tagplus sury packages for Debian 11–13; 7.4 also via Freexian ELTS and Ubuntu Pro ESM
7.3, 7.2, 7.1, 5.6nonesury 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:

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:

VendorSuitePackageFixed versionUpstream version moved?
Ubuntutrusty (ESM)php55.5.9+dfsg-1ubuntu4.29+esm18no
Ubuntuxenial (ESM)php7.07.0.33-0ubuntu0.16.04.16+esm22no
Ubuntubionic (ESM)php7.27.2.24-0ubuntu0.18.04.17+esm14no
Ubuntufocal (ESM)php7.47.4.3-4ubuntu2.29+esm5no
Ubuntujammyphp8.18.1.2-1ubuntu2.28no
Ubuntunoblephp8.38.3.6-0ubuntu0.24.04.13no
Debianbookwormphp8.28.2.35-1~deb12u1, from 8.2.32-1~deb12u1yes
Debiantrixiephp8.48.4.27-1~deb13u1, from 8.4.24-1~deb13u1yes

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:

Tagmemory_hard_limit_percent occurrences
php-8.2.290
php-8.2.340
php-8.2.354
php-8.3.364
php-8.4.274
php-8.5.124
php-8.1.340
PHP-7.4-CVE-... backport branch0
PHP-8.0-CVE-... backport branch0
PHP-8.1-CVE-... backport branch0

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:

1
2
3
php -r 'var_dump(ini_get("memory_hard_limit_percent") !== false);'
# true  -> this build is 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 or newer
# false -> unpatched, or an EOL backport, or a partial distro backport

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:

Imageini_get(...)!== falseif (ini_get(...))
php:8.2-cli-alpine3.21 (8.2.29)falsefalsefalse
php:8.2-cli-alpine (8.2.35)''truefalse
php:8.1-cli-alpine (8.1.34, EOL)falsefalsefalse

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-	heap->overflow = 1;
-	zend_try {
-		zend_error_noreturn(E_ERROR, format, limit, size);
-	} zend_catch {
-	}  zend_end_try();
-	heap->overflow = 0;
-	zend_bailout();
-	exit(1);
+	(void) heap;
+	zend_mm_safe_error_ex(format, limit, size);

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:

1
2
3
4
5
<?php
register_shutdown_function(function () { fwrite(STDERR, "SHUTDOWN-RAN\n"); });
ini_set('memory_limit', '64M');
$a = [];
while (true) { $a[] = str_repeat('x', 100000); }
1
2
docker run --rm --memory 512m -v "$PWD/oom.php:/oom.php:ro" <image> php /oom.php
echo "exit=$?"

Three builds, same script, memory_limit=64M in a 512 MB container:

BuildExit codeFatal error written toShutdown function
php:8.2-cli-alpine3.21, PHP 8.2.29 (unpatched)255stdoutran
php:8.2-cli-alpine, PHP 8.2.35 (fixed)255stdoutran
sury php8.0-cli 1:8.0.30-25+0~20261021.88+debian12~1.gbpabebea, EOL backport1stderrdid 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:

BranchStatic marker in php -iWhy
8.2–8.5memory_hard_limit_percentnew in the fixed releases
8.1, 8.0nonemax_multipart_body_parts already exists since 8.0.28
7.4, 7.3, 7.2, 7.1max_multipart_body_partsabsent from every vanilla 7.x, added by the fix
5.6max_multipart_body_parts, but only with the second sury patchthe 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.

1
grep -c array_init_singleton Zend/zend_API.h   # 0 before the patch, >=1 after

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.

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.

  1. Shutdown functions stop running on OOM. The worker goes through _exit(1), so register_shutdown_function never 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 against memory_limit will 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.
  2. 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 while php -i still reports session.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 in ext/session/session.c on 8.2.29 and 8.2.34, and three times on 8.2.35.
  3. A new limit truncates very large forms. max_multipart_body_parts defaults to -1, which falls back to max_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 on php:8.2-cli-alpine: max_input_vars 1000, max_file_uploads 20, max_multipart_body_parts -1. The fallback formula is identical in main/rfc1867.c on 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:

1
2
3
4
5
# OOM before the script even starts: the parse never reached user code
grep -h 'Allowed memory size.*Unknown on line 0' /var/log/php*/*.log
# worker crashes
grep -h 'AH00052' /var/log/apache2/error.log        # mod_php segfault
grep -h 'exited on signal 11' /var/log/php*-fpm.log  # FPM

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

  1. Reconcile the registry against the inventory before touching any machine. Every disagreement is a machine that would not have been checked.
  2. Walk the fleet by hand against the seven-item checklist. Record the exact version and SAPI, never “probably”.
  3. For each branch found, decide the source of the fix from the availability table — official release, official EOL backport, or sury patch set.
  4. 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.
  5. Verify in three layers: patch applies, patch compiles, patch works. The third is the marker table plus the behaviour test for EOL.
  6. Tag self-built images and packages so the build is identifiable later — an image tag and a LABEL, or an RPM Release bump.
  7. Apply session.upload_progress.enabled = 0 everywhere regardless, in php.ini or the pool config.
  8. 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.

#php #cve #docker #security #eol

<< Previous Post

|

Next Post >>