DEV Community

Cover image for Patching PHP 5.6 to 8.1 for PHP2Shell on old Ubuntu servers by rebuilding RunCloud's PHP
Seppe Gadeyne
Seppe Gadeyne

Posted on Edited on

Patching PHP 5.6 to 8.1 for PHP2Shell on old Ubuntu servers by rebuilding RunCloud's PHP

On October 21, 2026, PHP shipped fixes for PHP2Shell, but only for PHP 8.2 and newer. Our hosting panel, RunCloud, only patches those versions too. Most of our older WordPress sites run on PHP 5.6 to 8.1, on Ubuntu releases as old as 16.04. So that evening we rebuilt PHP ourselves: every old version, for every Ubuntu release on our servers, with RunCloud's own build flags, and swapped in only the patched binaries. This article shows how, what broke on the way, and how we checked that nothing changed for the sites.

Everything below was done on October 21, 2026: builds and tests on our own workstation, deploys and checks on our servers. Where a fact comes from someone else, I link the source. Server and client names are left out.

Update, October 24, 2026: all 25 builds, the patches, RunCloud's configure commands and the build scripts are now public: seppegadeyne/php2shell-runcloud-builds. You can download a tarball for your Ubuntu release or rebuild it yourself with one command. Details in Use our builds, or build them yourself below.

Later that day: not on RunCloud? The repository now also has a generic mode for a PHP that you or your host compiled from source. It reads the configure command from your server, builds the newest release of the same PHP branch with the fix, inside your server's Ubuntu release, and swaps it in with the same checks. See Not on RunCloud: the generic mode. The full step-by-step guide is in a separate article: Patch PHP2Shell on PHP 7.4 and 8.1 compiled from source, without a panel.

The problem: two critical bugs and a panel that stops at 8.2

PHP2Shell is two bugs, CVE-2026-109618 and CVE-2026-109619. They allow remote code execution without authentication, PHP-FPM is affected, and every known trigger is a multipart/form-data request. The announcement on the php.net homepage lists the fixed releases: 8.2.35, 8.3.36, 8.4.27 and 8.5.12. Sansec has a good technical write-up and timeline.

Our setup at Straffe Sites: seven servers managed with RunCloud, running Ubuntu 16.04, 18.04 (three servers), 20.04 (two) and 22.04. RunCloud installs its own PHP builds under /RunCloud/Packages/phpXXrc from a per-release apt repository, release.runcloud.io. That repository told us most of what we needed to know:

  • RunCloud only patches 8.2 and newer, as their community statement says.
  • The 18.04 repository stopped getting builds at 8.3.17 and 8.2.27, so even the supported branches would not get a fix there.
  • The 16.04 repository tops out at PHP 8.0. No branch there would ever be patched.

Many of our older WordPress sites are pinned to PHP 7.3 or 7.4 on purpose: old page-builder themes and plugins that break on PHP 8. Moving them is a project, not an evening. We counted 71 FPM pools, and 62 of them had no official patch coming.

First: make sure the mitigation is actually active

Before building anything, check the stopgap. RunCloud dropped /etc/phpXXrc/conf.d/99-cve-2026-109618.ini on every server in the morning:

session.upload_progress.enabled = Off
Enter fullscreen mode Exit fullscreen mode

The PHP-FPM masters had been running since days before that, so the setting was not active in a single worker. A new conf.d file only applies after FPM reloads. We tested the config and reloaded every running FPM service, along these lines:

for s in $(systemctl list-units --type=service --state=running --no-legend | awk '/php[0-9]+rc-fpm/{print $1}'); do
  v=${s%-fpm.service}
  /RunCloud/Packages/$v/sbin/php-fpm -t -y /etc/$v/php-fpm.conf && systemctl reload "$s"
done
Enter fullscreen mode Exit fullscreen mode

Then we confirmed it through a real pool, for every PHP version that had a site that would serve a stray PHP file (all but two; for those we checked the ini file and the worker restart): a temporary PHP file that prints PHP_VERSION and ini_get('session.upload_progress.enabled'), fetched through the local nginx with curl --resolve, and deleted right after. If your host says "we applied the mitigation", ask whether they also reloaded PHP-FPM.

That is a mitigation, not a fix. So, the real thing.

Where the patches come from

Nobody ships PHP 5.6 to 8.1 builds for Ubuntu 16.04 to 20.04 anymore. But the source patches exist:

  • PHP 7.4, 8.0 and 8.1: the PHP project published minimal backports in php-src branches named PHP-7.4-CVE-2026-109618-109619 (and the same for 8.0 and 8.1). For 7.4 it is a single commit, 712e60f9ed "Avoid state corruption via bailout" by Arnaud Le Blanc, co-authored by Ilija Tovilo; for 8.0 82f8f31b16, for 8.1 1d7dec178a.
  • PHP 5.6 to 7.3: Ondřej Surý ported the same fix for his packages.sury.org packages. His builds only target Ubuntu 22.04, 24.04 and Debian, but the Debian source packages contain the patches as plain files in debian/patches/. For 7.0 to 7.3 the fix needs one earlier backport first, backport ZSTR_MAX_LEN by Remi Collet, a macro in zend_string.h.
PHP Patches applied on top of the php.net tarball
5.6.40 Surý 0261-Avoid-state-corruption-via-bailout
7.0.33 Surý 0211-backport-ZSTR_MAX_LEN + 0230-Avoid-state-corruption-via-bailout
7.1.33 Surý 0167 (ZSTR_MAX_LEN) + 0186 (bailout)
7.2.34 Surý 0135 (ZSTR_MAX_LEN) + 0159 (bailout)
7.3.33 Surý 0107 (ZSTR_MAX_LEN) + 0139 (bailout)
7.4.33 php-src 712e60f9ed
8.0.30 php-src 82f8f31b16
8.1.34 php-src 1d7dec178a

What the patch does, in short: on an out-of-memory error the worker now calls _exit(1) instead of unwinding with longjmp (FPM simply starts a fresh worker), the session upload-progress callback is switched off in code (if (!PS(rfc1867_enabled) || true)), and request state is reset harder in main.c, SAPI.c, rfc1867.c and php_variables.c. It does not change ZEND_MODULE_API_NO. That matters: the existing compiled extensions on the server can keep loading without a rebuild. In our tests every build loaded the server's full set of extensions, with the same module count and no startup warnings.

Every patch applied with patch -p1 against the official tarballs without a single failed hunk; some hunks needed a line offset, none needed fuzz. PHP 5.5 was the exception: hunk 10 of the 5.6 patch fails in Zend/zend_alloc.c. No server used 5.5, so we disabled it instead of patching (more on that below).

Build on the same Ubuntu, without root or Docker

A PHP binary built on a modern system will not run on Ubuntu 16.04: newer glibc, newer OpenSSL, newer ICU. The binary has to be built against the same libraries the server has. The cleanest way is to build inside the same Ubuntu release.

We used the official ubuntu-base root filesystems (16.04.6, 18.04.5, 20.04.5, 22.04.5) from cdimage.ubuntu.com, checked against their SHA-256 sums, and entered them as a rootless chroot with a user namespace. No sudo, no Docker daemon:

#!/bin/bash
# enter.sh <rootfs-dir> <command...>
HERE=$(dirname "$(realpath "$0")")
R=$(realpath "$1"); shift
exec unshare --user --map-root-user --map-users=auto --map-groups=auto \
  --mount --pid --fork --kill-child "$HERE/_inner.sh" "$R" "$@"
Enter fullscreen mode Exit fullscreen mode
#!/bin/bash
# _inner.sh: runs as "root" inside the new namespaces
R="$1"; shift
mount --rbind /dev "$R/dev" 2>/dev/null || true
mount -t proc proc "$R/proc"
mount -t tmpfs tmpfs "$R/tmp"
cp /etc/resolv.conf "$R/etc/resolv.conf"
exec chroot "$R" /usr/bin/env -i HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
  LANG=C.UTF-8 DEBIAN_FRONTEND=noninteractive "$@"
Enter fullscreen mode Exit fullscreen mode

--map-users=auto needs a range for your user in /etc/subuid and /etc/subgid; without it, apt cannot switch to its _apt user. Inside the chroot, apt-get install works as usual. The 16.04 build deps were:

apt-get install -y --no-install-recommends build-essential autoconf bison re2c pkg-config \
  ca-certificates curl xz-utils patch libxml2-dev libssl-dev libsqlite3-dev zlib1g-dev \
  libcurl4-openssl-dev libgmp-dev libffi-dev libenchant-dev libsystemd-dev libedit-dev \
  libicu-dev libpcre3-dev libselinux1-dev libfcgi0ldbl
Enter fullscreen mode Exit fullscreen mode

That gives each release its original toolchain:

Ubuntu GCC OpenSSL ICU PHP versions built
16.04 5.4.0 1.0.2g 55 5.6, 7.0, 7.1, 7.2, 7.3, 7.4, 8.0
18.04 7.5.0 1.1.1 60 7.0 to 7.4, 8.0, 8.1, plus 8.2.35 and 8.3.36
20.04 9.4.0 1.1.1 66 7.2, 7.3, 7.4, 8.0, 8.1
22.04 11 3.0 (and RunCloud's 1.1.1b) 70 7.4, 8.0, 8.1

On 18.04 we also built the official 8.2.35 and 8.3.36 releases, because RunCloud will not ship them for that release anymore.

Use RunCloud's own configure flags

The goal is a drop-in replacement, so the new binary must be built exactly like RunCloud's. You do not have to guess: php -i prints the full configure command.

/RunCloud/Packages/php74rc/bin/php -i | grep '^Configure Command' | sed 's/^Configure Command =>  //'
Enter fullscreen mode Exit fullscreen mode

For PHP 7.4 that starts like this (shortened):

'./configure' '--build=x86_64-linux-gnu' '--prefix=/RunCloud/Packages/php74rc'
'--sysconfdir=/etc/php74rc' '--with-config-file-path=/etc/php74rc'
'--with-config-file-scan-dir=/etc/php74rc/conf.d' '--disable-cgi' '--disable-all'
'--with-openssl=yes' '--with-password-argon2' '--enable-fpm' '--enable-cli'
'--enable-pdo=shared' '--enable-mysqlnd=shared' '--enable-xml=shared' ...
'--with-fpm-systemd' '--with-gmp' '--with-libedit=shared,/usr'
Enter fullscreen mode Exit fullscreen mode

Two things stand out. RunCloud builds a small core with --disable-all and ships the rest (mysqli, gd, intl, curl, and so on) as separate shared extensions. And --with-password-argon2 links against RunCloud's own libargon2 package, which you can download from the same repository for each Ubuntu release (release.runcloud.io/dists/<codename>/main/binary-amd64/Packages.gz, checked against the SHA-256 in that index) and install in the chroot with dpkg -i.

We saved the configure command per version and Ubuntu release, and one generic script built everything:

#!/bin/bash
# build-generic.sh <full-version> <rc-tag e.g. 74> <patch|none> [mode] [ubuntu codename]   (shortened)
set -euo pipefail
FV=$1; V=$2; PATCH=$3; MODE=${4:-build}; DIST=${5:-xenial}
cd /root && rm -rf php-$FV && tar -xJf /src/php-$FV.tar.xz && cd php-$FV
if [ "$PATCH" != none ]; then
  patch -p1 --dry-run -F3 < /src/$PATCH > /root/patch$V.log 2>&1 || { grep -E "FAILED|Hunk" /root/patch$V.log; exit 2; }
  [ "$MODE" = dryrun ] && { echo DRYRUN_OK; exit 0; }
  patch -p1 -F3 < /src/$PATCH >/dev/null
fi
# only needed if a patch touches configure.ac (none of ours did)
grep -q "configure.ac" /src/$PATCH 2>/dev/null && ./buildconf --force >/dev/null 2>&1 || true
eval "$(cat /src/configure-php${V}rc-$DIST.txt)" > /root/configure$V.log 2>&1
make -j"$(nproc)" > /root/make$V.log 2>&1
rm -rf /stage$V && make install INSTALL_ROOT=/stage$V > /root/install$V.log 2>&1
P=/stage$V/RunCloud/Packages/php${V}rc
strip --strip-unneeded $P/bin/php $P/sbin/php-fpm
cd /stage$V && tar -czf /src/php${V}rc-$FV-php2shell-$DIST.tar.gz RunCloud
Enter fullscreen mode Exit fullscreen mode

make install INSTALL_ROOT=/stage74 installs into a staging directory with the full /RunCloud/Packages/php74rc path underneath, so the tarball unpacks to the right place.

Pitfalls we hit

  • gmp.h not found on PHP 5.6. On multiarch Ubuntu the header lives in /usr/include/x86_64-linux-gnu/. A symlink fixes it: ln -sf /usr/include/x86_64-linux-gnu/gmp.h /usr/include/gmp.h.
  • Surý's 7.0 to 7.3 patches need ZSTR_MAX_LEN. The bailout patch uses that macro, which 7.0 to 7.3 do not have. Apply the earlier backport first, then the bailout patch.
  • On 22.04, RunCloud's 7.4 and 8.0 link against OpenSSL 1.1.1b, which RunCloud ships as its own libssl1-rc package under /RunCloud/Packages/libssl1-rc. Ubuntu 22.04 itself only has OpenSSL 3. Install that package in the chroot and build against it, like the original.
  • The version number does not change. A patched 7.4 still says PHP 7.4.33. Do not use php -v to prove anything; check the behavior and the file hash (below).

Test in the chroot before anything touches a server

For each build we copied the server's real extension directory and conf.d into the chroot, put the new core next to them and checked:

  • ldd on every .so: no "not found" libraries.
  • php -m: the same number of modules as on the server (for example 67 for 7.4 on 16.04), and no startup warnings.
  • A smoke test: mysqli, gd, curl, mbstring and password_hash with the default algorithm.
  • A real PHP-FPM pool with memory_limit = 16M, driven with cgi-fcgi: one normal request, one request that allocates 64 MB, then another normal request.

The out-of-memory case shows the patch at work. Here is the patched 7.4.33 on 16.04, with a shutdown function registered:

$ php -d memory_limit=8M -r 'register_shutdown_function(function(){echo "shutdown-ran\n";}); $a=str_repeat("x", 64*1024*1024);'
Fatal error: Allowed memory size of 8388608 bytes exhausted (tried to allocate 67108896 bytes) in Command line code on line 1
$ echo $?
1
Enter fullscreen mode Exit fullscreen mode

The shutdown function never runs and the process exits with 1. In FPM, that worker dies and the master starts a new one; the third request in the pool test was served normally.

Deploy: swap only the core, keep a way back

We did not replace the whole RunCloud package. Only the files the patch touches are swapped: bin/php, sbin/php-fpm and the six extensions that are built from the core source as shared modules (mysqlnd, pdo, xml, xmlreader, xmlwriter, readline). RunCloud's other extensions and all conf.d files stay as they are. The deploy script, shortened:

#!/bin/bash
# deploy-core.sh <74|83|...> <tarball> [rollback]   (run as root on the server)
set -euo pipefail
V=$1; TB=$2; MODE=${3:-deploy}
P=/RunCloud/Packages/php${V}rc
EXTDIR=$(ls -d $P/lib/php/extensions/no-debug-non-zts-*)
BK=/root/php${V}rc-orig-before-php2shell
FILES="bin/php sbin/php-fpm"
CORE_EXT="mysqlnd.so pdo.so xml.so xmlreader.so xmlwriter.so readline.so"

# (rollback branch: put $BK back by rename, apt-mark unhold, restart FPM)

T=$(mktemp -d); tar -xzf "$TB" -C $T
N=$T/RunCloud/Packages/php${V}rc
# 1) back up the originals once, never overwrite them
if [ ! -d $BK ]; then mkdir -p $BK/bin $BK/sbin $BK/ext
  for f in $FILES; do cp -a $P/$f $BK/$f; done
  for e in $CORE_EXT; do [ -f $EXTDIR/$e ] && cp -a $EXTDIR/$e $BK/ext/$e; done
fi
# 2) the new binary must start cleanly with the real php.ini
$N/bin/php -c /etc/php${V}rc/php.ini -m >/dev/null 2>$T/err || true
grep -i warning $T/err && { echo "ABORT: startup warnings"; exit 1; }
# 3) swap by rename, so running processes keep their old inode
for f in $FILES; do install -m 755 $N/$f $P/$f.new && mv -f $P/$f.new $P/$f; done
for e in $CORE_EXT; do [ -f $N/lib/php/extensions/*/$e ] && install -m 755 $N/lib/php/extensions/*/$e $EXTDIR/$e.new && mv -f $EXTDIR/$e.new $EXTDIR/$e; done
# 4) stop RunCloud from putting the unpatched package back
apt-mark hold php${V}rc >/dev/null
# 5) service inactive (no pools)? sanity-check the binaries and exit without starting it
if ! systemctl is-active --quiet php${V}rc-fpm; then
  $P/sbin/php-fpm -n -v >/dev/null && $P/bin/php -m >/dev/null || exec bash $0 $V $TB rollback
  exit 0
fi
# 6) config test, restart (a new binary needs a restart, not a reload), roll back on failure
$P/sbin/php-fpm -t -y /etc/php${V}rc/php-fpm.conf || exec bash $0 $V $TB rollback
systemctl restart php${V}rc-fpm; sleep 2
systemctl is-active php${V}rc-fpm >/dev/null || exec bash $0 $V $TB rollback
Enter fullscreen mode Exit fullscreen mode

Installed PHP versions without any pool got the same new binaries, but their FPM service was not started. Someone switching a site to that version in the panel later would otherwise land on an unpatched PHP.

apt-mark hold is the important trade-off. It keeps RunCloud's agent from reinstalling the old package, but it also means these versions get no RunCloud updates at all from now on. For 8.2 and newer on 20.04 and 22.04 we did not hold anything: RunCloud released 8.2.35, 8.3.36, 8.4.27 and 8.5.12 for those Ubuntu releases later that evening, and we installed them with apt-get install --only-upgrade.

Verify: same pages, new binary

Three checks per server:

  1. HTTP before and after. Before each deploy we requested every domain on the server through the local nginx (curl --resolve domain:443:127.0.0.1) and saved the status code per domain. After the deploy we ran it again and diffed the two lists. No differences on any server. For a few sites per PHP version we also checked /wp-login.php and /wp-admin/ (200 and 302).
  2. The running process uses the new file. Compare the hash of the binary on disk with the one the FPM master actually runs: md5sum /RunCloud/Packages/php74rc/sbin/php-fpm /proc/$(systemctl show -p MainPID --value php74rc-fpm)/exe.
  3. The pool reports what we expect. The same temporary probe file as for the mitigation: PHP version, upload_progress off, SAPI fpm-fcgi.

To be sure everything survives a restart, we rebooted the six servers we had patched that night one by one just before midnight and repeated the HTTP comparison: 184 domains, no differences. The 22.04 server's 7.4 to 8.1 builds went out right after that. By the end of the night every installed PHP version from 5.6 to 8.5 on those six servers was patched; only the unused 5.5 was disabled instead. The seventh server, also on 18.04, followed the next day with the same 18.04 builds and the same checks.

The one we did not patch: PHP 5.5

One server still had PHP 5.5.38 installed, without any site using it. No patch exists, and the 5.6 patch does not apply. Removing the package with apt would also remove RunCloud's agent, because it depends on it. So we moved the php and php-fpm binaries out of the way, put the package on hold, and added a systemd drop-in with ConditionPathExists so the service cannot start.

Use our builds, or build them yourself

This article got more readers than we expected, and several of them run the same setup. So we published everything in one repository: github.com/seppegadeyne/php2shell-runcloud-builds.

  • The release v2026.10.21 has the 25 tarballs we deployed, with a SHA256SUMS file: 16.04 (5.6 to 8.0), 18.04 (7.0 to 8.1, plus the official 8.2.35 and 8.3.36), 20.04 (7.2 to 8.1), 22.04 (7.4 to 8.1) and 24.04 (8.1).
  • deploy-core.sh is the full version of the script above, in English, with the rollback branch and an extension API check.
  • patches/ has the eight patches from the table, unchanged, with their origin and authors.
  • configure/ has RunCloud's configure command per PHP version and Ubuntu release, copied from php -i.
  • generic/ builds a patched PHP for servers without RunCloud, from the server's own configure command (see below).
  • build.sh rebuilds any of them from scratch:
git clone https://github.com/seppegadeyne/php2shell-runcloud-builds.git
cd php2shell-runcloud-builds
./build.sh focal          # every version for 20.04
./build.sh xenial 56 74   # only php56rc and php74rc for 16.04
Enter fullscreen mode Exit fullscreen mode

It downloads the ubuntu-base root filesystem and the php.net tarballs, checks them against pinned SHA-256 sums, fetches RunCloud's libargon2 or libssl1-rc package (checked against RunCloud's own index), and builds in the rootless chroot. A patch that does not apply cleanly, or would need fuzz, stops the build. Before publishing, we rebuilt versions for every Ubuntu release from an empty directory with this script and compared them with the deployed tarballs: same files, same linked libraries.

One detail we only wrote down while cleaning up: RunCloud does not use the same libargon2 everywhere. On 18.04, PHP 7.2 to 8.0 link RunCloud's own libargon2.so.1, while 8.1 and newer link Ubuntu's libargon2.so.0. The build script handles that per version; if you build by hand, check the original with readelf -d /RunCloud/Packages/php81rc/sbin/php-fpm | grep argon2.

The usual warning applies: these are unofficial builds, not affiliated with RunCloud or the PHP project, provided as is. Match both the PHP version and the Ubuntu release, read the script before you run it as root, and try one server first.

Not on RunCloud: the generic mode

The release tarballs only fit RunCloud. They unpack to /RunCloud/Packages/phpXXrc and link RunCloud's own libraries. But the problem is not specific to RunCloud. Plenty of servers run a PHP that someone compiled into /usr/local or /opt years ago, or a panel's own build, on an Ubuntu release that no longer gets PHP fixes. For those, the repository now has a generic/ directory. It follows the same steps as above, but it starts from your server instead of from RunCloud:

# 1) on the server (read-only): record every PHP CLI, FPM and CGI binary
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                      # or: bash collect.sh /usr/local/sbin/php-fpm
# -> php2shell-profile-<host>-<date>.tar.gz

# 2) on the build machine (same requirements as build.sh)
generic/build.sh php2shell-profile-web1-20261024-120000.tar.gz
# -> out/generic/web1/php7.4.33-php2shell-<job>.tar.gz + SHA256SUMS + SKIPPED.txt
# copy the tarball and SHA256SUMS to /root/php2shell on the server

# 3) back on the server, as root, in /root/php2shell
sha256sum -c --ignore-missing SHA256SUMS
bash deploy.sh php7.4.33-php2shell-<job>.tar.gz check      # test only
bash deploy.sh php7.4.33-php2shell-<job>.tar.gz            # swap
bash deploy.sh php7.4.33-php2shell-<job>.tar.gz rollback   # undo
Enter fullscreen mode Exit fullscreen mode
  • collect.sh changes nothing. It looks for PHP binaries in the usual places (/usr/local, /opt, cPanel's ea-php, Plesk, LiteSpeed, RunCloud), or takes the paths you give it. For each binary, it saves the configure command from php -i, the extension API number, the built-in modules, the linked libraries, the SHA-256 and the package that owns the file. It also saves the server's /etc/os-release. It warns about libraries linked from outside /lib and /usr/lib. What the generic mode cannot rebuild, it reports and skips: an Apache mod_php and LiteSpeed's lsphp.
  • build.sh builds the newest release of the same branch with that configure command, verbatim, inside the server's own Ubuntu release. A 7.4.30 becomes 7.4.33 with the patch; 8.2 to 8.5 get the fixed php.net release. Staying in the same branch keeps the extension API the same, so the server's other shared extensions keep loading. The build stops if the extension API differs or a built-in module is missing. Build dependencies come from the release's own PHP source package (apt-get build-dep), plus a list of libraries that configure flags often ask for. Libraries the server keeps in /opt go in with --extra <tarball>.
  • deploy.sh refuses a server that changed. Every target binary must still have the SHA-256 from the profile. The new CLI must start with the server's own php.ini without a warning, and for patched builds, an out-of-memory error must no longer run shutdown functions (the same test as above). Then it backs up the originals once to /root/php2shell-generic-backup/<job>/, swaps by rename, puts an owning package on hold, and config-tests and restarts every systemd PHP-FPM service whose main process runs a replaced binary. A failed test or start rolls everything back. An FPM outside systemd is not restarted; restart it yourself.

It leaves two kinds of builds alone on purpose. Distribution packages (Ubuntu's own php8.1, Debian, Surý) do not show a configure command in php -i. For those, collect.sh searches the package changelog for the two CVE numbers and reports whether the vendor already shipped the fix. The answer there is apt-get install --only-upgrade or a question to the vendor, not a rebuild. And the SAPIs it cannot rebuild: move mod_php sites to PHP-FPM first, and patch LiteSpeed's lsphp through LiteSpeed's own packages.

One detail came up while testing. The 8.1 fix also changes ext/opcache. If the server loads a shared opcache.so, keeping the old file would leave unpatched code in use, so the build has to produce a new opcache.so from the patched source. A configure command that builds no opcache is refused, with a hint to add --enable-opcache=shared.

Only Ubuntu 16.04 to 24.04 is automated. For another distribution, you can build by hand in a matching root filesystem with generic/chroot-build.sh; generic/chroot-deps.sh shows which libraries to install, but it uses Ubuntu package names. And unlike the RunCloud builds, the generic mode has not run on a production server yet. We tested it in chroots on our build host:

  • a PHP 7.4.30 compiled into /usr/local with a shared opcache.so and php-cgi on Ubuntu 20.04: collect, build 7.4.33 with the patch, check, deploy, PHP-FPM answering a FastCGI request with the new binary, out-of-memory exit 1, and a rollback to the original SHA-256
  • the same for a PHP 8.1.30 on Ubuntu 22.04, built as 8.1.34 with the patch, including the rebuilt opcache.so
  • Ubuntu 22.04's own php8.1 package, which it skipped because the changelog shows the vendor fix

So run check first, and try one server before the rest.

The whole procedure, with real output, the checks and how to verify the running binary, is in its own article: Patch PHP2Shell on PHP 7.4 and 8.1 compiled from source, without a panel.

What this does not fix

  • The next PHP bug. Held packages get nothing from RunCloud anymore. The scripts are in the repository now, so the next round is a rebuild, not a research project. But it stays our job. The next one is already announced: on October 23, PHP published CVE-2026-115560, a separate session bug that the October 21 releases do not fix. It only affects applications that switch session.serialize_handler to php with ini_set() before session_start(), and the mitigation is the same session.upload_progress.enabled = Off. We checked it on every PHP version on our servers. The fix comes in the next regular release; when it lands, we will rebuild.
  • Old operating systems. Ubuntu 16.04 to 20.04 are past standard support. A patched PHP on an unpatched OS is still an old server. Our real fix is moving the old sites to a new Ubuntu 24.04 server, where Surý's packages cover the old versions we need (5.6, 7.0, 7.3, 7.4 and 8.1) with this fix.
  • The mitigations stay on. session.upload_progress.enabled = Off, Cloudflare's managed rules for proxied domains, and phpMyAdmin behind an IP allowlist. A patch is not a reason to remove them.

The approach carries over to any server with its own PHP build on an older Ubuntu: same Ubuntu in a rootless chroot, the server's own configure command from php -i, upstream or Surý patches, swap only the core, hold the package, and compare the HTTP output before and after. For a self-compiled PHP, the generic mode now does most of those steps for you. The HTTP comparison is still your job.

For site owners rather than sysadmins, I wrote a plain-language version of what PHP2Shell means for a WordPress site: The PHP vulnerability and your WordPress site.

Top comments (0)