PHP2Shell on an EOL Stack: How We Patched PHP 8.1 on Ubuntu 20.04 arm64

TL;DR
- The problem: PHP fixed PHP2Shell (CVE-2026-109618 and CVE-2026-109619) on 21 Oct 2026, in 8.2 to 8.5 only. PHP 8.1 is end-of-life. Ondřej Surý's PPA backported the fix to 8.1, but it no longer builds for Ubuntu 20.04, so our client's arm64 web tier had nothing to install.
- The order:
- Mitigate within hours:
session.upload_progress.enabled=offandmemory_limit=-1, per FPM pool. - Take every vendor fix that exists.
- Rebuild what's left: the PPA's own signed 22.04 source, unchanged, for 20.04.
- Mitigate within hours:
- The trap: a build against 20.04's own libraries passed every test, but it would have broken the zip extension, and it silently lost GD's AVIF functions and pcre2's
\K-in-lookaround support. Build against the same libraries the PPA used, and gate on API parity: the shipped build matched all 9,216 checks. - Are you affected? On Ubuntu 22.04 and 24.04, every PHP version has a fixed package. On 20.04 there's no free fix outside rebuilds like ours. Check
php8.x-fpm, not justphp8.x. - The result: production was patched about a day after the release, one node at a time, with zero 5xx errors.
- The lesson: don't run
apt-get upgrade 'php*'. Upgrade only the packages built from thephp8.xsource. - The tooling is open source, with prebuilt bundles for 20.04 and 26.04 on arm64 and amd64, or build your own: clarity-tech/php-focal-backports.
On 21 October 2026, the PHP project shipped 8.5.12, 8.4.27, 8.3.36 and 8.2.35 to fix two critical remote code execution bugs, CVE-2026-109618 and CVE-2026-109619. The security press quickly dubbed the pair PHP2Shell. If you run a supported PHP on a supported distro, that night was simple: install the update, restart PHP, go to bed.
If you run PHP 8.1 on Ubuntu 20.04, there was nothing to install. That's exactly where the main web and worker tier of a client's production Laravel platform on AWS Graviton (arm64) was sitting.
This post walks through how we closed that gap in about a day, and why we did things in that order. Credit first: we didn't write the fix. The fix comes from the PHP security team, and the 8.1 packaging from Ondřej Surý (ppa:ondrej/php). We built the plumbing to rebuild their work safely for a platform they no longer target. The lessons apply to anyone stuck on an EOL PHP or an EOL distro.
What PHP2Shell Actually Is
Both bugs come down to what happens when PHP bails out of a request partway through, and both are rated 9.2 under CVSS v4.0. From the PHP security advisory:
- CVE-2026-109618, OOM corruption. Out-of-memory handling can interrupt PHP while internal request data is only partly initialized, and request cleanup may then treat that invalid data as valid.
- CVE-2026-109619, stale global state. Request-owned state can outlive the memory behind it, so a later request on the same worker may use it.
The advisory puts every PHP installation that serves web requests at risk, PHP-FPM included. mod_php is the worst case: there, an unauthenticated request can trigger the attack before any application code runs.
PHP 8.1 reached end of life on 31 December 2025, so the project ships no release for it.
Is Your Server Affected?
Most setups have a fixed package by now. This is where things stood on 24 October 2026, for arm64 and amd64 alike unless the table says otherwise:
| Ubuntu | Ubuntu's own PHP | Other PHP versions, 7.4 to 8.5 |
|---|---|---|
| 20.04 | 7.4.3, fixed only through Ubuntu Pro (USN-8948-2) | No free fixed package. The PPA dropped 20.04 in 2025. Our builds cover 8.1, see below |
| 22.04 | 8.1.2, fixed (USN-8948-1) | Fixed in the PPA |
| 24.04 | 8.3.6, fixed (USN-8948-1) | Fixed in the PPA |
| 26.04 | 8.5.4, fixed (USN-8948-1) | packages.sury.org: 8.2 and 8.3 still unfixed on arm64, fixed on amd64 |
| 26.10 | 8.5.9, fixed (USN-8948-1) | No third-party packages |
Three things catch people out:
- Checking the wrong package. Where packages.sury.org's arm64 builds lag, the
php8.2andphp8.3packages already show the fixed version, butphp8.2-fpm,-cliand-commondon't. A check that only looks atphp8.xwill call the server patched. On 22.04 and 24.04, switching to the PPA gets you the fix today, because its version ranks higher. - 20.04 with the PPA's PHP 7.4. Ubuntu Pro's fix won't install over it, because the PPA's 7.4.33 outranks Ubuntu's 7.4.3.
- 26.04 with sury's PHP 8.5 on arm64. sury's unfixed 8.5.11 outranks Ubuntu's fixed 8.5.4, so apt keeps the unfixed one until sury catches up.
Check the packages that actually run PHP:
dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm' 'php*-cli' 'php*-common' 'libapache2-mod-php*' 2>/dev/null
Fixed means at least 8.5.12, 8.4.27, 8.3.36 or 8.2.35. For the PPA's end-of-life branches, it's 8.1.34-9, 8.0.30-25 or 7.4.33-31. For Ubuntu's own packages, compare against the version in the USN.
The Gap: EOL PHP, on an EOL Distro, on arm64
Most people who run PHP on Ubuntu use Ondřej Surý's PPA, and the PPA did carry the fix. php8.1 8.1.34-9 landed on release day with 0083-Avoid-state-corruption-via-bailout.patch, which is the PHP team's minimal backport for EOL branches.
But the PPA only builds for Ubuntu 22.04 (jammy) and 24.04 (noble). It dropped 20.04 in 2025 when focal left standard support. Its sister repository, packages.sury.org, has no 20.04 suite either.
So for the client's 20.04 arm64 tier, there was no free, public package that carried the fix.
Check your paid options first. Commercial PHP LTS programs exist for 20.04, and the PPA's end-of-life notice points to one. If you have one, confirm that it covers your PHP branch and your CPU architecture.
Step 1: Mitigate First
You can't rebuild PHP in five minutes, but you can change its config. The advisory lists settings that reduce exposure while you work on the real fix, and we applied two of them about five and a half hours after the release, per FPM pool, one production web node at a time, with zero 5xx:
; In each PHP-FPM pool file, e.g. /etc/php/8.1/fpm/pool.d/www.conf
; php_admin_* values can't be overridden by ini_set() or .user.ini
php_admin_flag[session.upload_progress.enabled] = off
php_admin_value[memory_limit] = -1
# Test the config, then restart so that every worker picks it up
sudo php-fpm8.1 -t && sudo systemctl restart php8.1-fpm
Keep three points from the advisory in mind:
- This isn't a fix. In the advisory's words, these measures "reduce exposure to specific attack paths but do not make an installation safe." Only an update is a complete fix.
- Where you set it matters.
session.upload_progress.enabledmust be off before PHP parses the request, so set it inphp.ini, the FPM pool or the server config, never withini_set()or.user.ini. Then restart every worker. If you're fuzzy on how the FPM master and its workers relate, The Life of a PHP Process covers it. memory_limit = -1has a cost. With no per-request ceiling, a runaway request can take down the whole box. The advisory suggests a container or cgroup limit, ideally one that kills only the affected worker. It also warns againstRLIMIT_ASandRLIMIT_DATA, which can cause the very allocation failure you're trying to avoid. That cost is why this setting was only ever temporary.
Before you remove the mitigation, read PHP's 23 October informational advisory. It recommends session.upload_progress.enabled=0 until the next regular bugfix release, because of a separate issue, CVE-2026-115560. That issue only affects apps that set session.serialize_handler to php_binary or php_serialize, then switch it to php with ini_set() before session_start(). We checked: the platform uses the default handler and never switches it, so the feature could stay on. Check your own config and code the same way, and if in doubt, keep that one line.
Step 2: Take Vendor Fixes Wherever They Exist
It's tempting to build one clever pipeline that patches everything. Don't. Every host that a stock vendor package can fix is a host where someone else owns the fix. So that same night, we moved everything we could onto vendor fixes: containers onto 8.5.12 and 8.4.27, Ubuntu 24.04 onto Ubuntu's security update, and Ubuntu 22.04 onto the PPA's fixed builds, rolled out host by host, non-production first.
That left the 20.04 tier, plus a new Ubuntu 26.04 base image whose arm64 packages hadn't caught up yet.
Step 3: Rebuild Trusted Source, Don't Patch It Yourself
When there's no package, you have two options:
- Take the upstream patch, apply it to your own PHP 8.1 tree, and build that.
- Take a package that a trusted maintainer has already built, tested and signed for a neighbouring platform, and rebuild it, unchanged, for yours.
Option 1 makes you the maintainer of a security backport in the middle of an incident. With option 2, the only new variable is the build environment. We chose option 2: rebuild the PPA's own signed jammy source, 8.1.34-9, for 20.04, and do the same for 26.04. Keeping a rebuild "unchanged" is harder than it sounds, so we split the job into three checks.
Same Source: Verify the Signature Against a Pinned Key
Every .dsc must verify with gpgv against the pinned fingerprint of the PPA's upload key. If it doesn't, the build stops. That rules out a tampered mirror, a man-in-the-middle, and simply grabbing the wrong upload.
The repo's scripts/build.sh does exactly this before it builds anything.
Same Libraries: The Subtle Trap
This part nearly caught us, and it's the most reusable lesson here.
PHP's extensions don't just link against system libraries. Many of them decide at compile time which features exist, based on the library headers they find. Build the same PHP source against different library versions and you get a different PHP.
Our first build compiled against 20.04's own libraries, and it passed every functional test. What stopped it was the canary's install dry run: php8.1-zip : Depends: libzip5. Our build wanted 20.04's libzip, while the hosts run the PPA's newer libzip 1.7.3, so the zip extension would have broken. That sent us to the PPA's Launchpad build logs to compare, and the comparison turned up two features that had silently gone missing:
- GD's AVIF functions.
imageavif()andimagecreatefromavif()need a libgd built with AVIF support. 20.04 ships libgd 2.2.5, which has none, so those functions simply didn't exist. The PPA ships libgd 2.3.3. - pcre2's
\K-in-lookaround option. PCRE2 10.38 began rejecting\Kinside lookarounds by default, and added an option to allow it again. PHP 8.1 turns that option on, but only inside#ifdef PCRE2_EXTRA_ALLOW_LOOKAROUND_BSK, which is settled when PHP is compiled. 20.04's pcre2 headers are older than 10.38, so a build against them never sets the option, and on a host whose runtime pcre2 is 10.38 or newer, a\Kinside a lookaround is rejected.
No test caught those two, because no test called imageavif() or put \K in a lookaround. That's the trap: functional tests check what your app does today, not what the binary can do.
The fix is to compile against the same libraries the PPA does: on 20.04, that's the PPA's own builds of libzip, libgd, pcre2, libxml2 and argon2, not 20.04's. You don't have to guess. The PPA's Launchpad build logs list every build dependency at its exact version, and they show its 20.04 builds used them.
Same API: A Parity Gate
Libraries are only one way for a rebuild to drift, so we added a gate that catches drift of any kind. It dumps every function, class, method and constant of PHP's extensions, plus the compiled-in library versions, from the running hosts and from the candidate build, then diffs the two. Any difference fails the build.
The shipped build matched all 9,216 checks exactly.
The real script is scripts/parity_dump.php, and it's short enough to read in a minute. Take a baseline on a host running the PHP you're replacing, then diff the candidate against it:
php8.1 scripts/parity_dump.php > baseline.txt # on a current host
php8.1 scripts/parity_dump.php > candidate.txt # on a throwaway box with the new build
diff -u baseline.txt candidate.txt && echo "parity OK"
We built the gate after the library scare. It flags the missing AVIF functions on the first diff, and for pcre it records the pcre2 version PHP was compiled against and runs a direct \K-in-lookaround probe, so a build against 20.04's headers shows up on both.
Step 4: Install Like You Don't Trust Yourself
A correct package can still be installed badly. The installer we wrote is boring on purpose. It:
- checks the sha256 of the bundle and of each package, plus the architecture;
- refuses a partial upgrade, so all the
php8.1packages move together or none do; - refuses any apt plan that removes or changes anything outside the bundle;
- keeps local config edits;
- after the restart, verifies that the FPM master is running the new binary, and that HTTP status codes match a run taken before the upgrade.
It's in the repo as host/install.sh.
On top of the installer came the usual discipline: a canary first, snapshots, then one node at a time. Production's 20.04 tier went through its PHP-FPM restarts with zero 5xx, about three and a half hours after we started the rebuild and about a day after upstream released the fix.
The Mistake Worth Sharing: Don't Blanket-Upgrade php*
Not everything went cleanly. In non-production, a blanket apt-get upgrade 'php*' pulled in a 2.x MongoDB extension that the app's pinned 1.x library can't use. MongoDB features broke for about 90 minutes, and we fixed it the same day.
The glob is the problem: php* matches far more than PHP. Extensions such as php-mongodb are separate source packages with their own major-version jumps, and ext-mongodb 2.x pairs with the 2.x mongodb/mongodb library, not 1.x. The security fix lived entirely in the php8.1 source package, so upgrade only the packages built from it:
# Upgrade only the binary packages built from the php8.1 source package
pkgs=$(dpkg-query -W -f='${binary:Package} ${source:Package}\n' \
| awk '$2 == "php8.1" {print $1}')
apt-get -s install --only-upgrade $pkgs # read the plan before dropping -s
Use It Yourself
We've open-sourced the tooling under the MIT licence: clarity-tech/php-focal-backports. It rebuilds the PPA's signed php8.1 8.1.34-9, unchanged, for Ubuntu 20.04 (focal) and Ubuntu 26.04 (resolute), on arm64 or amd64.
Install a prebuilt bundle
Each release has a bundle for each Ubuntu version and CPU, with its sha256. GitHub Actions built them from the public repo, and each one carries a build provenance attestation, so you can check where it came from before you install it:
gh attestation verify <bundle>.tar --repo clarity-tech/php-focal-backports
Then install it with the guarded installer. Simulate first, and go one node at a time:
curl -fsSLO https://github.com/clarity-tech/php-focal-backports/releases/latest/download/install.sh
sudo bash install.sh simulate '<bundle URL from the release>' <sha256 from the release>
sudo bash install.sh apply '<bundle URL from the release>' <sha256 from the release>
Snapshot every production node before you apply. The PPA has deleted its old focal packages, so there's nothing to downgrade to.
Or build it yourself
If you'd rather not trust anyone's binaries, ours included, build from the maintainer's signed source. You need Docker and about 4 GB of disk:
git clone https://github.com/clarity-tech/php-focal-backports.git
cd php-focal-backports
# Recommended: a parity baseline from a host running the PHP you're replacing
# php8.1 scripts/parity_dump.php > targets/focal.parity
make build test package ARCH=arm64 # Ubuntu 20.04: about 20-40 minutes on 8 cores
make build test package TARGET=resolute ARCH=arm64 # Ubuntu 26.04
The bundle lands in dist/. Host it somewhere your servers can reach privately, then install it with the same install.sh commands. The README covers the rest, including how to move to the next fix.
Final Thoughts
A rebuild like this buys time. It doesn't replace getting off an EOL PHP and an EOL distro.
But when the clock is running, the safest code is code you didn't write. The PHP security team wrote a minimal backport for branches they no longer support. Ondřej Surý packaged it for 8.1 on release day, as he has done for years. Our job was to carry their work, unchanged, to a platform they don't target, and to prove at every step that we hadn't changed it.
The tooling is yours to use. Even if you write your own, the three ideas behind it are the part worth copying: same source, same libraries and same API.