PHP2Shell on PHP 7: patch applied is not patch compiled is not patch working
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
- Fix, 7.4: the official branch
PHP-7.4-CVE-2026-109618-109619, one commit on top ofphp-7.4.33. Apply it to 7.4.33, not to whatever you are running. - Fix, 7.3 / 7.2 / 7.1: the sury patch sets (
0139,0159,0186respectively) plus their prerequisites from the samedebian/patches/series. - Trap:
--dry-runchecks patch text, not symbol dependencies. On 7.3 and older you also need theZSTR_MAX_LENbackport (0107,0135,0167), or the compile fails. - Trap: the sury 7.1 package needed two revisions.
7.1.33-80on 21 October carried the bailout fix;7.1.33-81the next day added the CVE-2023-0662 prerequisite. If you took-80you are not finished. - Trap: patching your current patch version leaves CVE-2019-11043 open on 7.1 and 7.2, and the official patch does not even apply to 7.4.5.
- Marker:
max_multipart_body_partsappearing inphp -iis a valid static proof on 7.1 through 7.4: absent from every vanilla 7.x tree, added by the fix.
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.
| Branch | Source of the fix | What comes with it |
|---|---|---|
| 7.4 | Official minimal backport, PHP Security Team: branch PHP-7.4-CVE-2026-109618-109619, commit 712e60f9edfd | 17 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.3 | sury php7.3 7.3.33-35, patch 0139-Avoid-state-corruption-via-bailout | needs 0107 (ZSTR_MAX_LEN) and 0079 (max_multipart_body_parts) |
| 7.2 | sury php7.2 7.2.34-66, patch 0159 | needs 0135 (ZSTR_MAX_LEN) and 0105 (max_multipart_body_parts) |
| 7.1 | sury php7.1 7.1.33-81, patch 0186 | needs 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 package | Full version |
|---|---|
php7.1 | 7.1.33-81+0~20261022.128+debian12~1.gbpa88ccf |
php7.2 | 7.2.34-66+0~20261021.122+debian12~1.gbpe0bcd4 |
php7.3 | 7.3.33-35+0~20261021.144+debian12~1.gbpecf76f |
php7.4 | 1:7.4.33-31+0~20261021.121+debian12~1.gbpe9c428 |
php8.0 | 1: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:
| |
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:
| |
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.
| Branch | Bailout patch | max_multipart_body_parts patch | Already present? |
|---|---|---|---|
| 7.4 | 0164 | 0062 | yes |
| 7.3 | 0139 | 0079 | yes |
| 7.2 | 0159 | 0105 | yes |
| 7.1 | 0186 | 0188 | no, 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:
php7.1 (7.1.33-80), 21 October 2026 — the bailout fix, CVE-2026-109618 and CVE-2026-109619.php7.1 (7.1.33-81), 22 October 2026 — “Backported from 8.0.28: [CVE-2023-0662]: DOS vulnerability when parsing multipart request body.”
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.
| |
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 tag | max_multipart_body_parts |
|---|---|
| php-5.6.40 | 0 |
| php-7.1.33 | 0 |
| php-7.2.34 | 0 |
| php-7.3.33 | 0 |
| php-7.4.33 | 0 |
| php-8.0.28 | 1 |
| php-8.0.30 | 1 |
| php-8.1.34 | 1 |
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:
| |
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:
| Branch | Last commit with the branch | PHP_VERSION | Removed in |
|---|---|---|---|
| 7.4 | b9f17156020c “Update 7.4 to 7.4.33” | 7.4.33 | f2999de80bd8 “Remove PHP 7.4 (EOL 28 November 2022)” |
| 7.3 | b62fa33a7198 “Use gnu-libiconv for php iconv extension on alpine” | 7.3.33 | f2754514dafb “Remove 7.3 (#1270)” |
| 7.2 | 3dc9a6988d47 “Add "–with-pic" to configure flags” | 7.2.34 | 4908ef2763a9 “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.
| |
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.
| |
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.
- 7.1 starts from the webtatic SRPM
php71w-7.1.33-1.w6.src.rpm, still onrepo.webtatic.com/yum/el6/SRPMS/and verifiable against the webtatic key. Add the sury patches asPatch400–Patch402, bumpRelease: 1.1so the result is newer than what is installed. Build insidecentos:6pointed atvault.centos.organd an EPEL 6 archive, deliberately without remi, so dependencies come only from base and EPEL exactly as the original did. - 7.2 starts from remi’s
php72.specat the commit remi built7.2.34-1.el6.remifrom, plus the sury 7.2 patches and the sameReleasebump.
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:
| |
Four things that cost time in that environment, none of them PHP’s fault:
- There is nothing to roll back to. The SRPMs and RPMs for the older 7.1 patch versions are gone from webtatic. Before
rpm -Uvh, archive every file the installed packages own and every config, becauseyum downgradehas nowhere to go:
| |
Restoring means unpacking those archives; the rpm database stays on the new version, which is ugly but recoverable.
- PHP 7.2’s
buildconfwants autoconf 2.64 or newer and EL6 ships 2.63. Installautoconf268from EPEL and build withPHP_AUTOCONF=autoconf268 PHP_AUTOHEADER=autoheader268. - The
oniguruma5phpdependency (libonig.so.105) is missing on older remi installs; take it from the remi el6 archive. - PHP installed outside the rpm database happens on long-lived stages, and then
rpm -Uvhneeds--nodeps.
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:
apt-get update404s, because buster andbullseye-securitymoved toarchive.debian.org. Rewritesources.listand follow it with agrep -qassert so a future format change fails the build instead of quietly using the wrong mirror.- The tarball signing key import fails, because
ha.pool.sks-keyservers.netshut down.keyserver.ubuntu.comworks and the signature is still verified — do not drop the verification step, change the keyserver. - Alpine 3.12 and 3.15 packages are still on the mirrors, so those builds needed no repository edits at all.
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:
- OOM terminates the worker through
_exit(1). Shutdown functions do not run, the message goes to stderr, and nginx returns 502 where it returned 500. An application that regularly brushesmemory_limitwill start serving 502s, and monitoring that matches exit code 255 will not match 1. - Session upload progress is off in code, whatever
php.inireports. max_multipart_body_partsappears, defaulting to-1, which falls back tomax_input_vars + max_file_uploads, or 1020 parts on stock defaults. Past that, PHP emits “Multipart body parts limit exceeded %d. To increase the limit change max_multipart_body_parts in php.ini.” Forms with thousands of fields will notice.- Only
Content-DispositionandContent-Typeare retained on multipart parts.
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
- Identify the exact patch version and SAPI of every 7.x install. Not the branch, the patch version — the route depends on it.
- Pick the source per branch: official commit for 7.4, sury patch set for 7.3, 7.2 and 7.1.
- Read
debian/patches/seriesand 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. - Target the last patch release of the branch. Check what other CVEs you are leaving behind if you do not.
- Build from the last docker-library Dockerfile for the branch, or the vendor’s RPM spec. Repack
php.tar.xzafter patching. - Put a marker assert in the build that returns zero on a vanilla tree.
- Verify the result three ways:
max_multipart_body_partsinphp -i, the marker symbol in the source tree, and the OOM behaviour test. - 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.