BitPage

PHP 5.6 and PHP2Shell: is it vulnerable, what fixes it, and how to prove you fixed it

By  ·   · 14 min read

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

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:

 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();
	} else {
		heap->overflow = 2;
	}
	zend_bailout();

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_parts hunk of the 7.4 patch is left out; the limit comes with the CVE-2023-0662 backport (Introduce max_multipart_body_parts INI).

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:

  1. If you patched 5.6 on 21 October, you took -104 and you are not done. Same story on 7.1, where 7.1.33-80 carried the bailout fix and 7.1.33-81 added 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.
  2. The static marker problem goes away (see below).
  3. The parts limit now has a number. max_multipart_body_parts is declared PHP_INI_ENTRY("max_multipart_body_parts", "-1", PHP_INI_SYSTEM|PHP_INI_PERDIR, NULL) and the default -1 falls back to max_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’s main/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:

1
2
3
docker run --rm IMG php -r 'register_shutdown_function(function(){echo "SHUTDOWN\n";}); ini_set("memory_limit","8M"); $a=str_repeat("x", 64*1024*1024);'; echo "exit=$?"
#   patched:   Fatal error: Allowed memory size ..., exit=1,   no SHUTDOWN
#   unpatched: Fatal error ..., SHUTDOWN printed, exit=255

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:

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

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
FROM debian:stretch-slim
# stretch moved to archive.debian.org in its entirety
RUN printf '%s\n' \
        'deb http://archive.debian.org/debian stretch main' \
        'deb http://archive.debian.org/debian-security stretch/updates main' \
        > /etc/apt/sources.list \
 && rm -f /etc/apt/sources.list.d/* \
 && echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99archive
ENV PHP_URL="https://www.php.net/distributions/php-5.6.40.tar.xz" \
    PHP_ASC_URL="https://www.php.net/distributions/php-5.6.40.tar.xz.asc"
# gpg --keyserver keyserver.ubuntu.com --recv-keys "$key"
COPY php2shell-5.6-0261-avoid-state-corruption.patch \
     php2shell-5.6-0263-max_multipart_body_parts.patch /usr/src/
RUN set -eux; \
    docker-php-source extract; \
    cd /usr/src/php; \
    patch -p1 --forward < /usr/src/php2shell-5.6-0261-avoid-state-corruption.patch; \
    patch -p1 --forward < /usr/src/php2shell-5.6-0263-max_multipart_body_parts.patch; \
    grep -q array_init_singleton Zend/zend_API.h; \
# repack with the patch in: docker-php-ext-install re-extracts php.tar.xz later
    tar -cJf /usr/src/php.tar.xz -C /usr/src php; \
    ./configure ...   # as in the original

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:

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:

1
2
3
4
5
6
for i in OLD NEW; do
  docker run --rm $i php -m > m-$i.txt
  docker run --rm $i php -i > i-$i.txt
  docker run --rm $i php-fpm -tt 2>&1 | sed -E 's/^\[[^]]+\] NOTICE: //' > f-$i.txt
done
diff m-OLD.txt m-NEW.txt; diff f-OLD.txt f-NEW.txt; diff i-OLD.txt i-NEW.txt

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

  1. Confirm what you have: php -v inside the running container, and the image’s build date.
  2. Fetch the current sury php5.6 source package and check the changelog revision. You want -105 or later, not -104.
  3. Extract 0261 and 0263 from debian/patches/.
  4. Build a base image from docker-library commit fab49d4cb1c6, variant 5.6/stretch/fpm, with the archive and keyserver edits, both patches, the marker assert, and the php.tar.xz repack.
  5. Diff php -m, php -i and php-fpm -tt against the old image.
  6. Verify: max_multipart_body_parts present in php -i, plus the OOM behaviour test showing exit 1 with no shutdown function.
  7. Change the FROM line in the application image and add session.upload_progress.enabled = 0.
  8. 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.

#php #cve #docker #eol #security

<< Previous Post

|

Next Post >>