BitPage

PHP2Shell on PHP 7: patch applied is not patch compiled is not patch working

By  ·   · 19 min read

Short answer: PHP 7 has no fixed release and still has a fix for every branch: the official minimal backport 712e60f9edfd for 7.4 (17 files, +240/−59) and Ondřej Surý’s sury patch sets for 7.3, 7.2 and 7.1. The trap is that a clean patch --dry-run proves nothing: the backported fix uses ZSTR_MAX_LEN, a macro that does not exist in vanilla 7.3 or earlier, so the patch applies with offsets only and then the build dies with error: 'ZSTR_MAX_LEN' undeclared. Verify three separate things: that the patch applies, that it compiles, and that it works.

PHP 7.4 went end of life in 2022 and 7.1 long before that. There is no release coming, docker-library stopped building php:7.* images in November 2022, remi for EL6 froze in 2020, and moving an old Laravel or Yii2 project to 8.x is a project in its own right rather than a weekend. None of that means there is no patch.

The two CVEs and the fleet-wide verification matrix are covered in the overview. This one is the 7.x mechanics.

TL;DR

Where to get a fixed PHP 7.x

Every branch has a source. They differ in who maintains them and in how much work lands on you.

BranchSource of the fixWhat comes with it
7.4Official minimal backport, PHP Security Team: branch PHP-7.4-CVE-2026-109618-109619, commit 712e60f9edfd17 files, +240/−59: Zend/*, main/rfc1867.c, main/SAPI.c, main/main.c, main/php_variables.c, ext/session/session.c. fpm and apache2handler untouched
7.3sury php7.3 7.3.33-35, patch 0139-Avoid-state-corruption-via-bailoutneeds 0107 (ZSTR_MAX_LEN) and 0079 (max_multipart_body_parts)
7.2sury php7.2 7.2.34-66, patch 0159needs 0135 (ZSTR_MAX_LEN) and 0105 (max_multipart_body_parts)
7.1sury php7.1 7.1.33-81, patch 0186needs 0167 (ZSTR_MAX_LEN) and 0188 (max_multipart_body_parts)

If you intend to assert those versions in a config-management check, read the Version: field from the .dsc rather than the filename, because Debian strips the epoch out of filenames and the epochs here are not uniform:

Source packageFull version
php7.17.1.33-81+0~20261022.128+debian12~1.gbpa88ccf
php7.27.2.34-66+0~20261021.122+debian12~1.gbpe0bcd4
php7.37.3.33-35+0~20261021.144+debian12~1.gbpecf76f
php7.41:7.4.33-31+0~20261021.121+debian12~1.gbpe9c428
php8.01:8.0.30-25+0~20261021.88+debian12~1.gbpabebea

The 1: epoch is on 7.4 and 8.0 and on neither of the three older branches. A check written as a string comparison against 7.4.33-31 therefore fails on exactly the branch where the official fix exists, which is a satisfying way to waste an hour. Compare on the upstream-plus-revision part and let the distro suffix vary.

The sury patches say where they came from. 0139 for 7.3 is headed “Backported from 8.2.35”; the series for the older branches is attributed to Arnaud Le Blanc, co-author of the upstream fix, and the 5.6 version of the same patch describes itself as a “Minimal mitigation for EOL version.” Those are the patches that go into the Debian PHP packages, with the CVE numbers written into debian/changelog, which is why I chose them over the alternatives.

The alternatives, for completeness. A community port covering 5.4 to 7.3 touches 8 files against the official 17, with zend_hash.c, php_variables.c and the VM left alone, and its authors tested it against the built-in CLI server; I rejected it. An independent MIT-licensed port for 7.3 with ready-made images on ghcr.io looked more serious but still less authoritative than the Debian maintainer’s. Paid routes exist and work. TuxCare backported the fix to every alt-php branch from 5.2 to 8.5, and their own FAQ states the shape of it plainly: “alt-php74 stays at 7.4.33 and changes from release -79 to -81 on EL7.” That is the same version-number-does-not-move problem as the sury packages, from a different vendor, and it is why their documented verification route is a CVE tracker and the RPM release number rather than php -v. Freexian ELTS covers php7.4 on Debian 11.

Before paying anyone, check whether your distro already did the work. Ubuntu’s CVE record lists every 7.x branch it ships as released, with the upstream version untouched in each case: php7.0 on xenial at 7.0.33-0ubuntu0.16.04.16+esm22, php7.2 on bionic at 7.2.24-0ubuntu0.18.04.17+esm14, php7.4 on focal at 7.4.3-4ubuntu2.29+esm5. If your PHP came from the distro rather than a PPA, the fix may be an apt upgrade away under a subscription you already have.

One gap worth naming because it has no answer: sury php7.4 on Ubuntu 18.04. The ondrej PPA dropped bionic in 2023, and Ubuntu’s bionic fix is for the distro php7.2, not for a PPA-installed php7.4. A PPA package is not covered by ESM no matter what the subscription says. If you have that combination, the fix is to change something structural: the OS, the branch, or the exposure.

A side benefit of the packages over hand-patching the official commit: the sury EOL revisions carry much more than PHP2Shell. The 7.2.34-66 and 7.3.33-35 changelog entries lead with libgd, an SQL injection through E'...' backslash breakout and a recursive-symlink crash, all backported from 8.2.33; 7.4.33-31 leads with an IPv6 ACL bypass in FastCGI listen.allowed_clients and mysqlnd packet overreads. Across each of those changelogs there are between forty and sixty CVE lines. Hand-applying one commit closes one bug; taking the package closes the backlog.

Patch the last patch release of the branch

Two reasons, and the second one is the one people skip.

The patch often does not apply to older patch versions. The official 7.4 commit goes onto 7.4.33 and 7.4.29 cleanly and conflicts on 7.4.5 in zend_vm_*. Applied to 7.3.33 it leaves rejected hunks in six files; to 7.2.34 it rejects 46 hunks and the whole VM; on 7.1 the same.

Where it does apply, it can leave an older RCE untouched. The sury 0186 patch applies cleanly to 7.1.8, 7.1.11 and 7.1.17, every one of which predates the fix for CVE-2019-11043, an unauthenticated RCE in php-fpm behind nginx with fastcgi_split_path_info, fixed in 7.1.33, 7.2.24 and 7.3.11. Closing PHP2Shell on 7.1.8 and leaving that open is not a fix, it is a reshuffle. The only safe route is the last release of the branch plus the patch.

Trap 1: the dry run is clean and the build fails

Take 7.3.33 from a vanilla tarball with the sury pair 0079 and 0139. Both apply with line offsets only, no fuzz, exit status zero. The compile then stops:

zend_vm_execute.h:4971: error: 'ZSTR_MAX_LEN' undeclared

--dry-run checks whether the patch text matches the file, and nothing else. It has no opinion about whether the identifiers in the patch exist. The fix was backported from 8.2.35 and uses a macro that 7.3 never had. In the sury package it arrives as a separate, earlier patch: 0107-backport-ZSTR_MAX_LEN.patch, by Remi Collet, dated 22 November 2024, three lines in Zend/zend_string.h:

1
2
+#define ZSTR_MAX_OVERHEAD (ZEND_MM_ALIGNED_SIZE(_ZSTR_HEADER_SIZE + 1))
+#define ZSTR_MAX_LEN (SIZE_MAX - ZSTR_MAX_OVERHEAD)

I pinned the boundary so you do not have to guess which branches need it. ZSTR_MAX_LEN is present in vanilla Zend/zend_string.h on PHP 7.4 and 8.0 and absent on 7.3 and everything older. The 7.3 bailout patch references it 38 times and never touches zend_string.h itself. That is exactly the shape of failure that passes every pre-flight check and dies in the compiler: the text matches, the symbol does not exist.

Which is why 7.4 needs no prerequisite patch and 7.3, 7.2 and 7.1 all do: 0107, 0135 and 0167. The 7.x notes I started from had this confirmed only for 7.3, with 7.2 and 7.1 inferred from the same code; the vanilla boundary closes that inference.

The general rule is cheap to apply. Before building, read the patch for identifiers it uses but does not define, and find where they come from:

1
2
3
curl -sSfO https://packages.sury.org/php/pool/main/p/php7.3/<php7.3_7.3.33-35…debian.tar.xz>
tar -xJf php7.3_*.debian.tar.xz
grep -l -E 'state corruption via bailout|max_multipart_body_parts|define ZSTR_MAX_LEN' debian/patches/*.patch

There is a shortcut hidden in the numbering, and it also tells you something the changelog does not. Compare the prerequisite’s patch number to the bailout patch’s number. A lower number means the prerequisite was already in the package before PHP2Shell and comes along for free. A higher number means it was added after the fix, in a later revision — which means the first revision shipped incomplete.

BranchBailout patchmax_multipart_body_parts patchAlready present?
7.401640062yes
7.301390079yes
7.201590105yes
7.101860188no, added later

Trap 2: the 7.1 package needed a second revision the next day

The numbering above is not trivia. I read the debian/changelog out of the published source packages, and 7.1 has two revisions where the others have one:

CVE-2023-0662 is the multipart-parts limit. It was fixed upstream in 8.0.28, which means 7.1 never received it in its supported life, which means the memory-amplification input that PHP2Shell depends on was unlimited on that branch until the second revision. For 7.2, 7.3 and 7.4 the limit had already been backported years earlier, so one revision was enough.

If you patched 7.1 within the first day, you took -80 and you are missing the prerequisite. The same pattern applies to 5.6, which I cover separately. The operational lesson is not about these two packages. It is that for EOL branches the fix can arrive in pieces over several days, and “I patched on day one” is a reason to check again rather than a reason to stop.

Trap 3: the build assert that asserts nothing

Put a marker check in the build so it fails loudly when the patch stops applying. Then make sure the marker is real.

Mine was not. I used grep -q zend_objects_store_mark_destructed after the patch step, the build passed, and I believed it. That symbol exists in unpatched sources too, so the assert passed unconditionally. It was a comment that happened to compile. I counted it afterwards across all six EOL patch sets: zero occurrences in any of them. It was never going to work.

The test for a candidate marker is one command: it must occur zero times in the vanilla tree and at least once after the patch.

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

array_init_singleton is the one I would standardise on. It occurs zero times in vanilla 5.6.40, 7.4.33 and 8.0.30, and every one of the six EOL patch sets adds it, so a single marker covers 5.6 to 8.0 instead of a different grep per branch. zend_new_array_singleton in Zend/zend_hash.h also works, but only on 7.3 and newer, so it needs a per-branch table. Do not reach for zend_mm_safe_error_ex, which the EOL backports add and the supported branches do not have at all — useful for telling an EOL backport apart from upstream, useless as a general patch marker.

Trap 4: the patch is in and the version and ini say otherwise

php -v reports 7.4.33, 7.3.33, 7.2.34 or 7.1.33 identically with and without the patch, and php -i still prints session.upload_progress.enabled => On even though the patch disables the feature in code. A scanner reading version plus ini will call a patched build vulnerable, and an ini showing Off does not prove a patch is present.

On 7.x there is a static marker, and this is the branch range where it works. max_multipart_body_parts is absent from every vanilla PHP 7 tree and added by the fix. I counted the directive in main/main.c:

Vanilla tagmax_multipart_body_parts
php-5.6.400
php-7.1.330
php-7.2.340
php-7.3.330
php-7.4.330
php-8.0.281
php-8.0.301
php-8.1.341

That makes php -i | grep max_multipart_body_parts a usable answer on 7.1 through 7.4, and useless from 8.0 on, where the directive arrived in 8.0.28 independently of PHP2Shell.

The reliable check on any EOL branch is behavioural. The backports replace the bailout with a hard exit, which you can see from userland:

1
2
3
4
T='register_shutdown_function(function(){echo "SHUTDOWN-CALLED\n";}); $a=str_repeat("x", 20*1024*1024);'
php -d memory_limit=8M -r "$T"; echo "exit=$?"
#   patched:   Fatal error ..., exit=1,   no SHUTDOWN-CALLED
#   unpatched: Fatal error ..., exit=255, SHUTDOWN-CALLED

I confirmed that signature independently against a sury php8.0-cli 1:8.0.30-25 build: exit 1, the fatal error on stderr rather than stdout, shutdown function skipped. One warning carried over from the overview: this test does not work on supported branches. On 8.2 the vulnerable 8.2.29 and the fixed 8.2.35 both exit 255 and both run shutdown functions, because the upstream fix leaves ordinary memory_limit handling alone. It is an EOL test.

Docker: your own base image, from the last official Dockerfile

For the Docker installs the recipe is the same on every branch: take the last docker-library Dockerfile that still had the branch, add the patches, repack the source tarball.

Finding the right commit matters more than it sounds, because you want the Dockerfile as it was, not a reconstruction. Ask git for the file’s own history rather than eyeballing the log (git log --oneline -- 7.3/bullseye/fpm/Dockerfile), then take the commit directly below the removal:

BranchLast commit with the branchPHP_VERSIONRemoved in
7.4b9f17156020c “Update 7.4 to 7.4.33”7.4.33f2999de80bd8 “Remove PHP 7.4 (EOL 28 November 2022)”
7.3b62fa33a7198 “Use gnu-libiconv for php iconv extension on alpine”7.3.33f2754514dafb “Remove 7.3 (#1270)”
7.23dc9a6988d47 “Add "–with-pic" to configure flags”7.2.344908ef2763a9 “Remove 7.2 (EOL)”

The variants survive as you would hope: 7.4/bullseye/fpm and 7.3/bullseye/fpm plus 7.3/alpine3.15/fpm, and for 7.2 the OS is one generation back: 7.2/buster/fpm and 7.2/alpine3.12/fpm. Note that none of those three last commits is a version bump except on 7.4; on 7.3 and 7.2 the final touch was an unrelated build-flag change, which is exactly why guessing from the log’s subject lines gets you the wrong tree.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
FROM debian:bullseye-slim
RUN sed -i 's#http://deb.debian.org/debian-security#http://archive.debian.org/debian-security#' /etc/apt/sources.list \
 && grep -q 'archive.debian.org/debian-security bullseye-security' /etc/apt/sources.list
# ... unchanged; GPG_KEYS imported from keyserver.ubuntu.com ...
COPY php2shell-7.3-0079-max_multipart_body_parts.patch \
     php2shell-7.3-0107-zstr_max_len.patch \
     php2shell-7.3-0139-avoid-state-corruption.patch /usr/src/
RUN set -eux; \
    apt-get update; apt-get install -y --no-install-recommends patch <build-deps>; \
    docker-php-source extract; \
    cd /usr/src/php; \
    for p in 0079-max_multipart_body_parts 0107-zstr_max_len 0139-avoid-state-corruption; do \
        patch -p1 --forward < /usr/src/php2shell-7.3-$p.patch; done; \
    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

Because the layout stays identical to the official image (/usr/local/etc/php, docker-php-ext-install, pecl, zz-docker.conf), the application Dockerfile changes one line, and one base image per branch serves every project on that branch.

1
2
FROM php73-p2s/php-base:7.3.33-p2s-fpm-bullseye
COPY zz-php2shell.ini /usr/local/etc/php/conf.d/zz-php2shell.ini   # session.upload_progress.enabled = 0

Keep the OS family you already had. An Alpine project stays on Alpine, because moving to Debian changes musl to glibc and swaps the ImageMagick build underneath an imagick extension that was built against the old one. A Debian project moving buster to bullseye is fine and sometimes necessary, since buster is entirely on archive.debian.org. For anything integrating with a payment gateway that still negotiates legacy ciphers, bullseye is also the last stop with OpenSSL 1.1.1 — going to bookworm means OpenSSL 3 and that handshake needs testing before, not after.

The zz-php2shell.ini is redundant with the patch, which disables upload progress in code. Set it anyway, because it makes the state visible to whoever audits the box next.

For 7.4, the whole patch step collapses to a single git format-patch 712e60f9edfd, and a successful run prints 17 patching file … lines with no offsets and no fuzz.

CentOS 6: your own RPM, and nothing to roll back to

For hosts where PHP 7.1 or 7.2 came from webtatic or remi on CentOS 6, the equivalent move is a rebuilt RPM from the same vendor’s spec, so dependencies and layout stay as the installed packages expect.

Apply the patches unconditionally, outside any %if block, with --fuzz=0, and put the marker grep in %prep so the build fails rather than silently producing an unpatched RPM:

1
2
3
4
5
6
7
8
9
Release: 1.1%{?dist}
Patch400: php2shell-7.1-0167-zstr_max_len.patch
Patch401: php2shell-7.1-0186-avoid-state-corruption.patch
Patch402: php2shell-7.1-0188-max_multipart_body_parts.patch
# in %prep, after the vendor patches:
%patch400 -p1 --fuzz=0
%patch401 -p1 --fuzz=0
%patch402 -p1 --fuzz=0
grep -q array_init_singleton Zend/zend_API.h

Four things that cost time in that environment, none of them PHP’s fault:

1
2
3
4
5
6
B=/root/php-backup; mkdir -p $B
tar -czf $B/etc.tar.gz /etc/php.ini /etc/php.d /etc/php-fpm.conf /etc/php-fpm.d
rpm -qa 'php71w*' | sort > $B/packages.txt
rpm -ql $(rpm -qa 'php71w*') | while read f; do [ -f "$f" ] || [ -L "$f" ] && echo "$f"; done \
  | tar -czf $B/files.tar.gz -T -
rpm -Uvh --test *.rpm && rpm -Uvh *.rpm

Restoring means unpacking those archives; the rpm database stays on the new version, which is ugly but recoverable.

One pleasant surprise: rpm -Uvh runs the condrestart scriptlet itself, so php-fpm comes back without a separate restart and the gap is a second or two.

Traps in the old build environment

Reviving a Dockerfile from 2019 or 2020 breaks on infrastructure, not on code:

Keep the edits confined to source delivery. The PHP build itself and every configure flag stay exactly as the official image had them, which is the whole reason for starting from the official Dockerfile.

What changes for the application

The behavioural changes are identical across all the EOL ports, because they are the same patch:

What I did not test is whether the exploit worked before and stopped working after. No PoC was run. The claim here is that the official and maintainer patches are applied, compiled and behaving as their diffs say, which is a different and weaker claim than “verified not exploitable”. It is worth being precise about that.

Two more things I am not claiming. Whether memory_limit = -1 under OpenVZ runs into beancounters the way it runs into RLIMIT_AS is a guess — the shape of the failure is the same, but I have not tested it. And the docker-php-ext-install repack is a precaution; I never saw it cause a real failure.

Step by step

  1. Identify the exact patch version and SAPI of every 7.x install. Not the branch, the patch version — the route depends on it.
  2. Pick the source per branch: official commit for 7.4, sury patch set for 7.3, 7.2 and 7.1.
  3. Read debian/patches/series and take the prerequisites, not just the bailout patch. Check whether the prerequisite’s number is higher than the bailout patch’s; if it is, make sure you have the later package revision.
  4. Target the last patch release of the branch. Check what other CVEs you are leaving behind if you do not.
  5. Build from the last docker-library Dockerfile for the branch, or the vendor’s RPM spec. Repack php.tar.xz after patching.
  6. Put a marker assert in the build that returns zero on a vanilla tree.
  7. Verify the result three ways: max_multipart_body_parts in php -i, the marker symbol in the source tree, and the OOM behaviour test.
  8. On CentOS 6, archive the installed files before rpm -Uvh. There is no package to go back to.

Bottom line

For an EOL branch, “no release” is not “no fix”. The official backport for 7.4 and the sury patch sets for 7.1 through 7.3, applied on top of the last docker-library Dockerfile or the vendor’s RPM spec, produce a fixed PHP on the same branch. The application changes one FROM line or installs packages one Release newer.

The part that actually goes wrong is verification, and it has three independent layers. The patch’s own dependencies: --dry-run is clean on 7.3 and the build dies on ZSTR_MAX_LEN, which exists in 7.4 and 8.0 and nowhere below. The marker in the tree: it has to be a symbol the patch introduces, which zend_objects_store_mark_destructed is not and array_init_singleton is. And behaviour: exit code 1 with shutdown functions skipped, which is the signature of an EOL backport and not of a fixed supported branch.

Then patch the last release of the branch rather than the version you are on, because on 7.1 and 7.2 there is a second unauthenticated RCE underneath this one, and on 7.4.5 the official patch simply does not apply.

Companion pieces: the fleet-wide audit and verification matrix, PHP 8 and the frozen Docker tags, and PHP 5.6.

Primary sources: GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp, php-src, packages.sury.org, docker-library/php, php.net ChangeLog-7.

#php #cve #docker #centos #security

<< Previous Post

|

Next Post >>