PHP2Shell is fixed in PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12. If your server runs PHP 5.6 to 8.1 that you or your host compiled from source, there is no release to install. Your 7.4.33 is the last 7.4 release ever, and it is vulnerable. This guide shows how to patch that PHP in place with three scripts. The first reads how your PHP was built. The second rebuilds it with the fix on another machine. The third swaps the new binaries in, with checks and a rollback.
No control panel is needed, no Docker, and nothing is compiled on the server itself.
Earlier this week I wrote about patching PHP 5.6 to 8.1 on our RunCloud servers. Several readers asked the same question: what if I'm not on RunCloud? This article is the answer. It covers the generic mode of the same repository, seppegadeyne/php2shell-runcloud-builds, which works for any PHP that reports its own build flags.
Contents
- Is this guide for your server?
- First: turn on the mitigation today
- What the fix is and where it comes from
- Step 1: record how your PHP was built
- Step 2: build the patched PHP on another machine
- Step 3: check, swap and roll back
- Verify that the server runs the new binary
- What the scripts refuse to do
- FAQ
Is this guide for your server?
Run this on the server:
php -i | grep -E '^(PHP Version|Server API|Configure Command)'
You are in the right place if all of these are true:
- The version is 5.6 to 8.1, or an 8.2 to 8.5 release older than the fixed ones.
-
Configure Commandshows a line that starts with'./configure'. That means PHP was compiled from source: into/usr/local, into/opt, or by a hosting setup that did it for you years ago. - The server runs Ubuntu 16.04, 18.04, 20.04, 22.04 or 24.04. The build is automated for those releases only.
- PHP runs as PHP-FPM, CGI or the CLI.
You are in the wrong place if PHP comes from a distribution package. Ubuntu's own php8.1, Debian's packages and Ondřej Surý's PPA do not print a configure command, and their vendors ship their own fixes. Update those with apt-get install --only-upgrade 'php*' and reload PHP-FPM. The collect script below also checks this for you: for a binary that belongs to a package, it searches the package changelog for the two CVE numbers.
Apache's mod_php and LiteSpeed's lsphp are out of scope too. The scripts report them and skip them. Move mod_php sites to PHP-FPM first, and patch lsphp through LiteSpeed's own packages.
First: turn on the mitigation today
Do this today, before you build anything. Every known PHP2Shell trigger is a multipart/form-data request, and the stopgap that hosts and panels rolled out on October 21 turns off the session upload-progress feature:
; for example /usr/local/etc/php/conf.d/99-php2shell.ini
session.upload_progress.enabled = Off
A new ini file does nothing until PHP-FPM reloads. A master that has been running for weeks still uses the old settings in every worker. Test the config, then reload:
/usr/local/sbin/php-fpm -t && systemctl reload php-fpm
Use your own binary path and service name. Then confirm it through a real request. A pool can load a different php.ini than the CLI, so php -i on the command line proves nothing about FPM. Put a temporary file in a site's web root that prints ini_get('session.upload_progress.enabled'), request it, and delete it right away. It should print an empty string.
That is a mitigation, not a fix. The rest of this guide is the fix.
What the fix is and where it comes from
PHP2Shell is two bugs, CVE-2026-109618 and CVE-2026-109619. They allow remote code execution without authentication against PHP-FPM. Sansec has a good technical write-up, and the php.net homepage lists the fixed releases.
The PHP project only released fixes for 8.2 and newer. But the patches for older branches exist:
| PHP branch | Built as | Patch |
|---|---|---|
| 5.6 | 5.6.40 | Ondřej Surý's port from his Debian source packages |
| 7.0 to 7.3 | 7.0.33, 7.1.33, 7.2.34, 7.3.33 | Surý's port, plus a ZSTR_MAX_LEN backport by Remi Collet |
| 7.4, 8.0, 8.1 | 7.4.33, 8.0.30, 8.1.34 | the PHP project's own backport branches in php-src |
| 8.2 to 8.5 | 8.2.35, 8.3.36, 8.4.27, 8.5.12 | none: the official fixed release |
The repository has each patch as a file in patches/, with its origin and authors.
What the patch changes: on an out-of-memory error, PHP now ends the worker with _exit(1) instead of unwinding and running more code. The upload-progress callback is switched off in code, and request state is reset harder between requests. PHP-FPM starts a fresh worker, so sites keep working.
One thing the patch leaves alone matters a lot here: the extension API number. A 7.4 extension built years ago for 7.4.21 still loads in the patched 7.4.33. So you only replace the PHP core. The server's own redis.so, imagick.so or ioncube loader stays where it is.
That is also why the build stays inside your branch. A server on 7.4.21 gets 7.4.33 with the patch, not 8.1. Moving to a newer branch is a migration project; this is a security fix.
Step 1: record how your PHP was built
On the server, as root. The script reads and changes nothing:
mkdir -p /root/php2shell && cd /root/php2shell
RAW=https://raw.githubusercontent.com/seppegadeyne/php2shell-runcloud-builds/main/generic
curl -fLO $RAW/collect.sh
curl -fLO $RAW/deploy.sh # needed in step 3
bash collect.sh
Read both scripts before you run them as root. They are plain Bash, a few hundred lines in total.
Without arguments, collect.sh checks the usual paths: /usr/local/bin, /usr/local/sbin and /usr/local/php*, /usr/bin and /usr/sbin, one or two levels under /opt, Remi's collections, cPanel's ea-php, Plesk and LiteSpeed. If your PHP lives elsewhere, name the binaries yourself, and name all of them, because the search is then skipped: bash collect.sh /srv/php74/bin/php /srv/php74/bin/php-cgi /srv/php74/sbin/php-fpm.
Output from our test server, a PHP 7.4.33 compiled into /usr/local on Ubuntu 20.04 (host name changed):
found: /usr/local/bin/php PHP 7.4.33 (Command Line Interface)
found: /usr/local/bin/php-cgi PHP 7.4.33 (CGI/FastCGI)
found: /usr/local/sbin/php-fpm PHP 7.4.33 (FPM/FastCGI)
profile: /root/php2shell/php2shell-profile-web1-20261025-072514.tar.gz (3 binaries)
The profile is a small tarball. For each binary, it holds:
- the version, the SAPI and the configure command
- the extension API number and the built-in modules
- the
php.inipaths and the extension directory - the linked libraries and the SHA-256
- the owning package, if any
It also stores /etc/os-release, so the build machine knows which Ubuntu to build for.
Watch the output for two kinds of notes. links libraries outside /lib and /usr/lib means your PHP uses, for example, its own OpenSSL in /opt. You will need to hand that library to the build in step 2. NOTE: ... mod_php or SAPI 'LiteSpeed' means a binary this method cannot replace. On LiteSpeed, the php CLI next to lsphp can be rebuilt, but the lsphp handler that serves your sites stays as it is.
Copy the profile to your build machine.
Step 2: build the patched PHP on another machine
The build machine is any Linux x86_64 box: a workstation, a spare VM, a CI runner. It needs unshare from util-linux 2.38 or newer with user namespaces enabled, newuidmap from the uidmap package, curl, tar and xz. Ubuntu 24.04, Debian 12, Fedora and Arch qualify; Ubuntu 22.04 ships an older util-linux. It also needs about 5 GB of disk per Ubuntu release and a subordinate ID range for your user. No root, no sudo, no Docker:
grep "^$USER:" /etc/subuid /etc/subgid # must print a range in both files
# if not: sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
git clone https://github.com/seppegadeyne/php2shell-runcloud-builds.git
cd php2shell-runcloud-builds
generic/build.sh ~/php2shell-profile-web1-20261025-072514.tar.gz
The script unpacks the official ubuntu-base root filesystem for your server's release and enters it as a rootless chroot. So the result links against the same glibc, OpenSSL and ICU as your server. Inside, it installs the build dependencies of that release's own PHP source package, plus a list of libraries that configure flags often ask for. It checks the ubuntu-base image, the php.net tarball and the patch against SHA-256 sums pinned in the repository, applies the patch, and runs your server's configure command word for word.
The first lines show the plan:
plan b1 /usr/local/bin/php: PHP 7.4.33 -> 7.4.33 (php-7.4-php2shell.patch) job j92646cb03e
plan b2 /usr/local/bin/php-cgi: PHP 7.4.33 -> 7.4.33 (php-7.4-php2shell.patch) job j92646cb03e
plan b3 /usr/local/sbin/php-fpm: PHP 7.4.33 -> 7.4.33 (php-7.4-php2shell.patch) job j92646cb03e
DEPS_OK (cached)
=== j92646cb03e: PHP 7.4.33 for b1 b2 b3 (patch: php-7.4-php2shell.patch)
patch applied: php-7.4-php2shell.patch
BUILD_OK j92646cb03e PHP 7.4.33
OUT .../out/generic/web1/php7.4.33-php2shell-j92646cb03e.tar.gz
Three binaries with the same version and configure command make one build job. Two PHP versions on one server make two jobs and two tarballs. With the root filesystem already set up, this run took 1 minute 34 seconds on a 16-core desktop. A first run on a new Ubuntu release takes longer, because it downloads and installs the build dependencies once.
The build stops instead of guessing in these cases:
- The patch does not apply cleanly or would need fuzz.
- The extension API of the build differs from the server's.
- A built-in module is missing in the build. A site would lose a function.
-
The server loads a shared
opcache.sobut the configure command builds none. The 8.1 fix also changes opcache, so the old file would leave unpatched code running. The fix is to add'--enable-opcache=shared'tobin/<id>/configure.txtin the profile, pack it again and rerun the build.
If collect reported libraries outside /lib and /usr/lib, pack them from the server (for example tar -czf extra.tar.gz /opt/openssl-1.1) and pass them with --extra extra.tar.gz. The build unpacks them at the same path inside the chroot. That build root is reused for later builds on the same Ubuntu release, so keep it for one kind of server, or delete work/rootfs-generic-<codename> before you build for another.
Copy the tarball and SHA256SUMS from out/generic/<host>/ to /root/php2shell on the server. Use the same commit of the repository for deploy.sh and the build; both come from main in this guide.
Step 3: check, swap and roll back
Back on the server, as root, in /root/php2shell:
sha256sum -c --ignore-missing SHA256SUMS
bash deploy.sh php7.4.33-php2shell-j92646cb03e.tar.gz check # test only
bash deploy.sh php7.4.33-php2shell-j92646cb03e.tar.gz # swap
bash deploy.sh php7.4.33-php2shell-j92646cb03e.tar.gz rollback # undo
Look for the OK line after sha256sum -c; the old coreutils on Ubuntu 16.04 can exit 0 without checking anything. Then always run check first. It changes nothing and runs the tests from points 1 to 3 below; only the PHP-FPM config test in point 7 needs a real deploy:
check: new CLI loads 37 modules, no warnings
CHECK OK (j92646cb03e)
Here is what deploy.sh does, in this order:
- It refuses a server that changed. Every binary it will replace must still have the SHA-256 from the profile. If someone updated PHP after you ran collect, the deploy stops: collect and build again.
-
It starts the new CLI with your real configuration. That is your own
php.ini,conf.dfiles and shared extensions. A CLI that does not run, or that prints a warning or error while it loads them, aborts the deploy. This catches an extension that does not load. Compare the module count it prints withphp -m | grep -c .on the old binary: they should match. The test uses the CLI's configuration; an FPM pool with its ownphp.iniis only tested when PHP-FPM restarts. - It tests the fix itself (5.6 to 8.1, the patched branches). It runs a script with an 8 MB memory limit that allocates 64 MB and registers a shutdown function. The patched binary must exit without running that function.
-
It backs up the originals once to
/root/php2shell-generic-backup/<job>/. A second deploy never overwrites that backup. -
It swaps by rename. Running processes keep the old file until they restart, and no process ever sees a half-written binary. The shared extensions from the PHP core, such as
opcache.so, are replaced too. That happens in the extension directory your server uses, and only for files it already has. -
It holds the owning package, if a package owns the binary. Then
apt-get upgradecannot quietly put the unpatched version back. -
It restarts PHP-FPM. It finds every running systemd service whose main process runs a replaced binary, tests its config with the service's own
--fpm-config, and restarts it. If a config test or a start fails, it puts the original files back and restarts the same services. If one of them still does not come up, the rollback says so and exits with an error: checksystemctl statusfor that unit.
On our test server, the deploy and the rollback looked like this:
check: new CLI loads 37 modules, no warnings
swapped: 3 binaries, 1 shared extensions from the core
no running PHP-FPM service uses these binaries (nothing restarted; restart it yourself if it runs outside systemd)
DEPLOY OK (j92646cb03e): 7.4.33, patch php-7.4-php2shell.patch
ROLLBACK OK (j92646cb03e)
The test chroot has no systemd, hence the "nothing restarted" line. On a normal server you see restarted: <your-fpm-service> instead. If your PHP-FPM runs under supervisord, runit or a script in /etc/rc.local, restart it yourself after the swap. Until you do, the old master keeps running the old binary.
After the rollback, all files had the same SHA-256 as before the deploy.
Verify that the server runs the new binary
php -v cannot tell a patched 7.4.33 from an unpatched one, so it proves nothing. Compare file hashes instead. Use your own unit name and paths:
# the file on disk and the binary of the running FPM master
sha256sum /usr/local/sbin/php-fpm /proc/$(systemctl show -p MainPID php-fpm | cut -d= -f2)/exe
# the file in the tarball
tar -xzOf php7.4.33-php2shell-j92646cb03e.tar.gz ./usr/local/sbin/php-fpm | sha256sum
All three hashes must match. If the /proc hash differs, the master still runs the old binary: restart PHP-FPM.
Then the behavior test, the same one the deploy ran for 5.6 to 8.1:
/usr/local/bin/php -d memory_limit=8M \
-r 'register_shutdown_function(function(){echo "shutdown-ran\n";}); $a=str_repeat("x", 64*1024*1024);'
echo $? # 1, and "shutdown-ran" is not printed
Finally, the sites. Before the deploy, request every site through the local web server and save the status codes:
for d in example.com shop.example.com; do
printf '%s %s\n' "$d" "$(curl -sk -o /dev/null -w '%{http_code}' --resolve "$d:443:127.0.0.1" "https://$d/")"
done > before.txt
Run the same loop into after.txt after the deploy and diff before.txt after.txt. On our RunCloud servers, this comparison is what told us nothing had changed for the sites.
What the scripts refuse to do
The scripts are careful on purpose, so it helps to know where they stop:
-
Only Ubuntu 16.04 to 24.04 is automated. For Debian, Rocky or AlmaLinux, you can build by hand in a matching root filesystem with
generic/chroot-build.sh.generic/chroot-deps.shlists the libraries to install, with Ubuntu package names. The swap is then manual too:deploy.shonly takes tarballs frombuild.sh. -
No distribution packages. If the binary has no configure command in
php -i, the build skips it and writes the vendor's fix status toSKIPPED.txt. - No
mod_phpand nolsphp. - No other branch. A server on 7.2 gets a patched 7.2.34, not 7.4.
-
No restart outside systemd. Under supervisord, runit or an init script, restart PHP-FPM yourself and check the
/prochash below.
And one honest limit: the RunCloud builds from my earlier article run on our production servers, but this generic mode does not yet. We tested it in chroots on our build host:
- a PHP 7.4 compiled into
/usr/localon Ubuntu 20.04, with a sharedopcache.soandphp-cgi - a PHP 8.1.30 on Ubuntu 22.04, built as 8.1.34 with the patch, including a rebuilt
opcache.so - Ubuntu 22.04's own
php8.1package, which it skipped correctly
Both builds went through the full cycle: collect, build, check and deploy. PHP-FPM answered a FastCGI request with the new binary, the out-of-memory test exited with 1, and the rollback restored the original hashes. Run check first, and try one server before the rest. If something breaks on your setup, open an issue with the collect.sh output.
A patched PHP 7.4 is a way to survive this bug, not a plan. These builds get no further updates. The next PHP bug is your job again, and Ubuntu 16.04 to 20.04 are past standard support. Keep session.upload_progress.enabled = Off in place: it also covers CVE-2026-115560, a separate session bug announced on October 23 that these builds do not fix. And plan the move to a supported PHP on a supported OS.
FAQ
Does php -v show that the patch is installed?
No. A patched 7.4.33 and an unpatched 7.4.33 report the same version. Compare the SHA-256 of the running binary with the file in the tarball, and run the out-of-memory test above.
Will my existing PHP extensions keep working?
Yes, within the same branch. The patch does not change the extension API number, and the deploy aborts if the new CLI prints a warning while it loads your extensions. Only opcache.so and the other extensions built from the PHP core are replaced.
Can I build on the server itself?
You can, if it has the disk space and user namespaces. But a separate machine keeps compilers and build dependencies off the server, and the chroot matches the server's Ubuntu release anyway.
Is session.upload_progress.enabled = Off enough on its own?
It is the stopgap everyone recommends, but it is a setting. A config that sets it back, or a pool that loads another php.ini, removes the protection without a warning. The patch fixes the code.
What about PHP from Ondřej Surý's PPA or Ubuntu's own packages?
Update the package. Those vendors ship their own fixes, and collect.sh reports whether the installed package's changelog already mentions the two CVE numbers.
Top comments (0)