PHP 5.6 and PHP2Shell: is it vulnerable, what fixes it, and how to prove you fixed it
Short answer: PHP 5.6 is vulnerable, and you can confirm it from the source rather than taking a vendor’s word for it: in vanilla 5.6.40 zend_mm_safe_error() ends in a zend_bailout(), which is the longjmp out of a failed allocation that CVE-2026-109618 is about, and session.upload_progress.enabled defaults to "1" with the callback registered unconditionally, which is the consumer path for CVE-2026-109619. The only free fix is Ondřej Surý’s sury patch 0261, applied to vanilla 5.6.40, and the piece it deliberately omitted shipped a day later in package revision 5.6.40-105.
PHP’s advisories cover 7.4 and newer. For 5.x there is no statement at all: not vulnerable, not unaffected, nothing. Version scanners fill the gap with “EOL, please upgrade”, which is not an answer when the code in question uses mysql_*, ereg and the /e modifier and the upgrade is a quarter of work rather than an afternoon.
The two CVEs and the fleet-wide picture are in the overview. This is the narrowest branch of the set and the one where proving the fix is hardest.
TL;DR
- Vulnerable: yes. Both code paths are present in vanilla 5.6.40, verifiable in
Zend/zend_alloc.candext/session/session.c. - Fix: sury patch
0261-Avoid-state-corruption-via-bailout.patch, by Arnaud Le Blanc, co-author of the upstream fix. It applies to vanilla 5.6.40 with line offsets only and no dependencies. - Take revision
5.6.40-105, not-104.-104on 21 October is the bailout fix alone;-105on 22 October adds the CVE-2023-0662 multipart limit that the bailout patch explicitly relies on. - Trap: with
-104alone there is no static marker whatsoever: not the version, not an ini value, not a new directive. Only a behaviour test. - Trap: the 2019 docker-library Dockerfile for 5.6 no longer builds. Everything it fetches has moved or shut down.
Is 5.6 actually vulnerable
Yes, and the useful part is that it is checkable without a PoC. Wiz and Sansec both put the start of the vulnerable code at 5.4.0. Rather than trust that, I read vanilla php-5.6.40 from php-src.
The 109618 root is zend_mm_safe_error() in Zend/zend_alloc.c. The shape of it is the same code the official EOL backports delete on 7.4 and 8.0:
| |
That zend_bailout() is a longjmp out of a failed allocation, and if the allocation failed while the request body was being parsed, it unwinds out of the middle of the parse. A detail from the same function explains the log signature worth grepping for: when the error happens before any script is executing, it sets error_filename = "Unknown" and error_lineno = 0, which is why Allowed memory size of … exhausted … in Unknown on line 0 is the line that tells you an OOM happened before user code started.
The 109619 consumer is session upload progress, and on 5.6 it is on out of the box. In ext/session/session.c the directive is declared as STD_PHP_INI_BOOLEAN("session.upload_progress.enabled", "1", ZEND_INI_PERDIR, …), default "1", and php_rfc1867_callback = php_session_rfc1867_callback; is assigned unconditionally at module startup, with the only guard being if (!PS(rfc1867_enabled)) inside the callback. The application never has to call session_start() for that path to be live.
Both entry points exist, then, and the class of bug with them. Whether the specific exploitation technique from the public write-ups works against 5.6 is not something I tested, and I would not claim it either way. Supporting evidence from a direction I did not expect: Ubuntu’s own CVE record lists php5 on trusty as released at 5.5.9+dfsg-1ubuntu4.29+esm18. That is a vendor spending paid ESM effort on a branch two minor versions older than this one, which is not something you do for a bug you think is theoretical. Note the shape of that version string too: upstream stays 5.5.9 and only the Ubuntu revision moves, so php -v on a fixed trusty box is identical to php -v on an unfixed one.
Where to get a fix
One free source, and it is a good one.
The sury php5.6 package carries 0261-Avoid-state-corruption-via-bailout.patch. The author is Arnaud Le Blanc, co-author of the upstream fix, with Ilija Tovilo credited; the header says “Backported from the PHP 7.4 version” and “Minimal mitigation for EOL version.” It applies to a vanilla 5.6.40 tarball with line offsets only, no fuzz, and no prerequisite patches at all, which is unusual, since on 7.1 through 7.3 the equivalent patch needs the ZSTR_MAX_LEN backport first.
What I like about this patch is that it documents its own gaps in the header. Verbatim, from the list of deviations:
The
max_multipart_body_partshunk of the 7.4 patch is left out; the limit comes with the CVE-2023-0662 backport (Introducemax_multipart_body_partsINI).
A patch that tells you what it is not doing is a patch you can plan around. Which brings us to the interesting part.
The rejected options, briefly. A community port for 5.6.40 exists and does include a parts limit, but its authors tested it against the built-in CLI server, so it is at best a donor for a missing piece. CloudLinux alt-php56 is paid and awkward in Docker. Official PHP backports, Freexian ELTS and Zend have nothing for 5.x.
The gap, and the fact that it closed the next day
When I first worked through this, the omission above was an open problem. The sury maintainer had confirmed on the issue tracker that 5.6 has no multipart parts limit, because the CVE-2023-0662 fix was never backported to that branch, and an update was promised with no date. The conclusion was that the patch changes what happens when an OOM occurs without limiting the input that causes it, so a proxy-level body limit stays mandatory.
Then I pulled the current source package and read the changelog. The update had shipped:
php5.6 (5.6.40-105) unstable; urgency=medium
* Backported from 8.0.28
+ [CVE-2023-0662]: DOS vulnerability when parsing multipart request body.
-- Ondřej Surý <ondrej@debian.org> Thu, 22 Oct 2026 08:02:38 +0200
php5.6 (5.6.40-104) unstable; urgency=medium
* Backported from 8.2.35
+ [CVE-2026-109618, CVE-2026-109619]: Avoid state corruption via bailout.
-- Ondřej Surý <ondrej@debian.org> Wed, 21 Oct 2026 08:55:15 +0200
Revision -105 adds 0263-Introduce-max_multipart_body_parts-INI.patch, touching main/main.c and main/rfc1867.c, and the current series has 264 patches with both 0261 and 0263 in it. The missing half exists after all, one day behind the first revision, and the two patches together are the complete minimal fix for 5.6.
One thing to expect when you go looking for it: nothing in the pool is named a bare -105. The 5.6 packages ship as git-buildpackage snapshots, so the version you will actually see is of the form 5.6.40-105+0~20261022.135+debian11~1.gbp37a17e, and the top changelog entry in the source package is marked ** SNAPSHOT build ** and UNRELEASED. Compare on the -105 part and ignore the tail. If you compare on the whole string, or expect a clean Debian revision, you will conclude the fix is not published.
Three consequences, in order of how much they matter:
- If you patched 5.6 on 21 October, you took
-104and you are not done. Same story on 7.1, where7.1.33-80carried the bailout fix and7.1.33-81added the prerequisite a day later. On EOL branches the fix can arrive in pieces, and patching early is a reason to re-check rather than to close the ticket. - The static marker problem goes away (see below).
- The parts limit now has a number.
max_multipart_body_partsis declaredPHP_INI_ENTRY("max_multipart_body_parts", "-1", PHP_INI_SYSTEM|PHP_INI_PERDIR, NULL)and the default-1falls back tomax_input_vars + max_file_uploads, which on stock defaults is 1000 + 20. Beyond that you get “Multipart body parts limit exceeded %d. To increase the limit change max_multipart_body_parts in php.ini.” That fallback formula is identical in the 5.6 backport and in vanilla 8.2’smain/rfc1867.c, so a 5.6 build with both patches now has the same effective 1020-part ceiling as a current PHP.
The proxy-level body limit is still worth keeping. One reason is that a limit at the edge costs nothing; the better reason is that blocking multipart/* alone does not close the urlencoded variant of the amplification, so a body-size cap has to exist regardless of what PHP is doing.
Trap: with the bailout patch alone there is no marker at all
This is what makes 5.6 harder than every other branch. php -v prints 5.6.40 before and after. php -i prints session.upload_progress.enabled => On even though the patch disables the feature in code. On 7.1 through 7.4 you can at least look for max_multipart_body_parts, since it is absent from every vanilla 7.x tree. I counted zero occurrences in main/main.c for php-5.6.40, php-7.1.33, php-7.2.34, php-7.3.33 and php-7.4.33. With 0261 alone, 5.6 has no new directive, so that marker does not exist either.
Taking -105 fixes this as a side effect: with 0263 applied, max_multipart_body_parts does show up in php -i, and 5.6 gets the same static marker the 7.x branches have. That is a second reason to take the later revision even if you believe your edge limits make the parts limit redundant — it makes the build auditable.
If you are stuck on the bailout patch alone, the only proof is behavioural:
| |
The patched build routes the fatal error to stderr and terminates with _exit(1), so shutdown functions never run. I confirmed that same signature independently on a sury-patched php8.0-cli build: exit 1, stderr, no shutdown function, against 255 and stdout on an unpatched one.
And in the build itself, assert on a symbol the patch introduces:
| |
I checked that count against vanilla 5.6.40, 7.4.33 and 8.0.30: zero in all three, and every one of the six EOL patch sets adds it. Do not use zend_objects_store_mark_destructed for this: it exists in unpatched sources, so the assert passes unconditionally. That was my first choice and it was useless.
Trap: the 2019 Dockerfile no longer builds
The approach that keeps the blast radius small is the same as on the other branches: build a base image from the official docker-library Dockerfile for 5.6, add the patch, change one FROM line in the application image. The right commit is fab49d4cb1c6, the last one whose tree still carries 5.6/, directly before e9320fdb752e ‘Revert “Update to 5.6.40” (this removes the EOL 5.6 series)’. The variant to take is 5.6/stretch/fpm, with ENV PHP_VERSION 5.6.40 on line 64 and the sha256 1369a51eee3995d7… on line 66 — same OS and same layout as the running image.
Then nothing it references still exists. Three separate breakages, none of them about PHP:
- Debian stretch is off the mirrors. Point at
archive.debian.organd accept expired Release files, because the archive’s are. - The tarball URL is dead.
secure.php.net/get/…/from/this/mirrorno longer resolves;www.php.net/distributions/…does. - The keyserver shut down.
ha.pool.sks-keyservers.netis gone;keyserver.ubuntu.comworks. The signature still verifies — “Good signature”, with the signing key long expired, which was already true in 2019.
| |
Keep the edits confined to source delivery. The PHP build and every configure flag stay exactly as the official image had them, which is the entire reason to start from the official Dockerfile instead of writing one.
That repack of php.tar.xz deserves a sentence. The official Dockerfile ends with docker-php-source delete, which leaves the pristine tarball behind; an application image later running docker-php-ext-install re-extracts it and compiles extensions against unpatched sources. I have not seen this break anything in practice, so it is a precaution rather than a reported bug — but it costs one line.
I rejected debian:12 plus the sury php5.6-fpm package. The fix is there and the OS keeps getting updates, which is genuinely better long-term, but the layout is different (/etc/php/5.6/, php-fpm5.6, extensions via apt) and rewriting the Dockerfile for a set of landing pages is not where the emergency budget goes. Moving to 7.4 is pointless, since 7.4 is EOL too. Moving to 8.3 is a real project, with mysql_*, ereg, the /e modifier, mcrypt, each() and PHP 4 constructors all in the way.
What changes for the application
Take a single php:5.6-fpm container fronting a dozen or so landing-page domains, with nginx next to it and fastcgi_pass php56:9000. Rebuilding that base on 5.6.40 plus the two patches leaves php -m and php-fpm -tt identical, and the only differences in php -i are session.upload_progress.enabled going to Off from the added ini file, the new max_multipart_body_parts entry, and whatever OpenSSL the final stretch update carried.
The behavioural changes are the same as on every EOL backport:
- OOM terminates the worker through
_exit(1), so shutdown functions do not run, the error goes to stderr, and nginx answers 502 where it used to answer 500. - Session upload progress is off in code, whatever
php.inisays. - Multipart parts beyond the new limit are dropped with a warning.
- Only
Content-DispositionandContent-Typeare retained on multipart parts.
For static landing pages none of that is visible. For anything with a large form or a shutdown handler doing real work, it is, and that is the thing to check before swapping the container rather than after.
Worth comparing old and new before deploying, since it is three commands:
| |
One thing worth doing in parallel, which is outside the scope of patching: a genuinely static landing page does not need PHP in front of it at all, and for the ones that do need it, an allowlist of the specific .php files is cheaper than keeping a 2019 interpreter correct forever.
Step by step
- Confirm what you have:
php -vinside the running container, and the image’s build date. - Fetch the current sury
php5.6source package and check the changelog revision. You want-105or later, not-104. - Extract
0261and0263fromdebian/patches/. - Build a base image from docker-library commit
fab49d4cb1c6, variant5.6/stretch/fpm, with the archive and keyserver edits, both patches, the marker assert, and thephp.tar.xzrepack. - Diff
php -m,php -iandphp-fpm -ttagainst the old image. - Verify:
max_multipart_body_partspresent inphp -i, plus the OOM behaviour test showing exit 1 with no shutdown function. - Change the
FROMline in the application image and addsession.upload_progress.enabled = 0. - Keep the body-size limit at the proxy. It is the layer that does not depend on a 5.6 patch being correct.
Bottom line
“End of life” does not mean “not vulnerable”, and it does not mean “nothing to apply”. For 5.6 the fix comes from the co-author of the upstream patch, applies to the last release of the branch with no dependencies, and ships in a package whose changelog names the CVEs.
The thing to get right is the revision. 5.6.40-104 is the bailout fix with a documented hole in it; 5.6.40-105, one day later, fills the hole and, as a side effect, gives 5.6 the max_multipart_body_parts marker that makes a patched build identifiable at all. Without it the only proof is an OOM exiting 1 with shutdown functions skipped, which is worth wiring into the build either way.
A minimal backport closes the mechanism, not every input to it. Whatever it leaves out stays your perimeter’s problem, and the patch header is where you find out what that is.
Companion pieces: the fleet-wide audit and verification matrix, PHP 8 and the frozen Docker tags, and PHP 7.1 to 7.4.
Primary sources: GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp, php-src at php-5.6.40, packages.sury.org, docker-library/php, and GHSA-54hq-v5wp-fqgv for CVE-2023-0662.