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 relea
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.
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: six servers managed with RunCloud, running Ubuntu 16.04, 18.04 (two 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
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
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.082f8f31b16, for 8.11d7dec178a. -
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_LENby Remi Collet, a macro inzend_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" "$@"
#!/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 "$@"
--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
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 => //'
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'
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
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.hnot 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-rcpackage 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 usephp -vto 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:
-
lddon 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_hashwith the default algorithm. - A real PHP-FPM pool with
memory_limit = 16M, driven withcgi-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
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: copy $BK back, 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
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:
-
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.phpand/wp-admin/(200 and 302). -
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. -
The pool reports what we expect. The same temporary probe file as for the mitigation: PHP version,
upload_progressoff, SAPIfpm-fcgi.
To be sure everything survives a restart, we rebooted all six servers 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 every server was patched; only the unused 5.5 was disabled instead.
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.
What this does not fix
- The next PHP bug. Held packages get nothing from RunCloud anymore. We keep the chroots, configure commands and scripts, so the next round is a rebuild, not a research project. But it stays our job.
- 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.
If you run RunCloud, Ploi, Forge or any panel with its own PHP builds on an older Ubuntu, the approach carries over: same Ubuntu in a rootless chroot, the panel'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 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.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.