Patching PHP2Shell on Ubuntu 20.04
The ondrej/php PPA no longer publishes for Ubuntu 20.04, so there is no apt upgrade to PHP 8.3.36. Here is how to rebuild the fixed packages on 20.04 yourself: the script, how it verifies what it downloads, and the two traps we hit.
On 21 October the PHP project released 8.5.12, 8.4.27, 8.3.36 and 8.2.35 to fix PHP2Shell (CVE-2026-109618 and CVE-2026-109619), two remote code execution flaws in PHP itself. On most servers the fix is one apt upgrade away.
On Ubuntu 20.04 with PHP 8 from Ondřej Surý's PPA, it is not. The PPA stopped publishing for 20.04 ("focal") in June 2025, and its focal index is now empty. Sury's new repository at packages.sury.org covers Ubuntu 22.04, 24.04 and 26.04, but not 20.04. Ubuntu Pro patches the PHP 7.4 that ships with 20.04, not the PHP 8 packages you installed from the PPA. So apt upgrade reports nothing to do, and the server stays vulnerable.
There are three ways out:
- Upgrade the OS to 22.04 or 24.04, where the packages are still published. This is the right long-term answer.
- Buy extended support. Freexian, who work with Ondřej, sell PHP LTS packages for older Debian and Ubuntu releases. Check that yours is covered. Buying it also supports the free repositories the rest of us rely on.
- Rebuild the fixed release yourself, if neither of those is happening this week. That is what this post is about.
The idea
Sury still publishes PHP 8.3.36 for Ubuntu 22.04 ("jammy"), and the PPA carries the source package alongside the binaries. That packaging builds cleanly on 20.04 with 20.04's own compiler and libraries. So you download the jammy source, verify it, give it a 20.04 version number, and build it on a 20.04 machine.
You get the same packages, with the same names, file paths, config files and systemd unit as the php8.3 packages you already have, so they upgrade in place. Your php.ini and FPM pool config stay as they are. The packages only depend on 20.04's own archive, so they also install on a plain 20.04 machine that never had the PPA.
What you do not get:
- Extensions that Sury packages separately. The source package builds PHP itself: php8.3-cli, -fpm, -common, -mysql, -curl, -mbstring, -xml, -zip, -intl, -gd and the rest, 36 packages in all. Extensions with their own source, such as php8.3-redis, -imagick, -apcu or -xdebug, are not rebuilt. The ones you already have keep working, because the extension interface does not change within PHP 8.3.
- The upstream test suite. The script skips it (
nocheck) to keep the build to about 20 minutes. Removenocheckif you want it. - Someone else doing the next one. Every PHP 8.3 security release means running the script again, until you upgrade the OS. PHP 8.3 gets security fixes until the end of 2027.
This is not an official build and comes with no warranty. Try it on a staging server first.
What you need
- A throwaway Ubuntu 20.04 machine to build on. Not your web server: the build installs a long list of development packages you do not want in production. We used an 8-core cloud VM with 40 GB of disk, and the build took 18 minutes.
- The same CPU architecture as your servers. Build on x86_64 for x86_64 servers, and on ARM for ARM servers. We have built it on both: the ARM64 build took about 10 minutes on 8 cores.
Step 1: Get the PPA signing key
Everything the script downloads is checked against the PPA's signing key, so start by getting the right one. Launchpad publishes its fingerprint:
sudo apt-get update && sudo apt-get install -y curl gnupg
curl -s https://api.launchpad.net/devel/~ondrej/+archive/ubuntu/php \
| grep -o '"signing_key_fingerprint": *"[^"]*"'
# "signing_key_fingerprint": "B8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6"
Then download that key from the Ubuntu keyserver into the keyring the script reads:
curl -fsS "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xB8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6" \
| gpg --dearmor > /tmp/ondrej-php.gpg
Step 2: Build
Save the script below as build-php-focal-debs.sh on the builder and run it with the PHP version you want:
chmod +x build-php-focal-debs.sh
./build-php-focal-debs.sh 8.3.36
The packages land in ~/build. Here is the whole script:
#!/bin/bash
# Rebuild Ondřej Surý's PHP 8.3 packages for Ubuntu 20.04 (focal).
#
# The ondrej/php PPA stopped publishing for focal in June 2025. This takes the PPA's Ubuntu 22.04
# (jammy) source package for a release, verifies it, and builds it natively on focal. Same package
# names, paths, config files and systemd unit, so it upgrades the installed php8.3-* packages in place.
#
# Run it on a THROWAWAY Ubuntu 20.04 machine, not on a web server: the build dependencies are not
# something you want in production. Fetch the PPA signing key first:
# curl -fsS "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xB8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6" \
# | gpg --dearmor > /tmp/ondrej-php.gpg
# Then: ./build-php-focal-debs.sh 8.3.36 → ~/build/*.deb
#
# Trust chain: pinned PPA key → jammy InRelease → Sources.xz → .dsc → tarballs (dpkg-source).
set -euo pipefail
# sudo drops DEBIAN_FRONTEND from the environment, so pass it on each apt-get.
APT='sudo DEBIAN_FRONTEND=noninteractive apt-get'
UPSTREAM=${1:?usage: $0 <php version, e.g. 8.3.36> [sury revision, default 1]}
REV=${2:-1}
V=${UPSTREAM}-1+ubuntu22.04.1+deb.sury.org+${REV}
NEWV=${UPSTREAM}-1+ubuntu20.04.1+rebuild.${REV}
PPA=https://ppa.launchpadcontent.net/ondrej/php/ubuntu
# ondrej/php signing key (Launchpad API `signing_key_fingerprint`, checked 2026-10-25).
PPA_KEY=B8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6
KEYRING=/tmp/ondrej-php.gpg
W=$HOME/build
[ -s "$KEYRING" ] || { echo "Put the PPA signing key in $KEYRING first (see the top of this script)"; exit 1; }
mkdir -p "$W" && cd "$W"
$APT update -q
$APT install -yq dpkg-dev devscripts equivs gnupg curl xz-utils
echo '== Fetch + verify'
curl -fsSO $PPA/dists/jammy/InRelease
# InRelease is dual-signed (current key + retired 14AA40EC…), so gpgv always exits 1 on the retired
# half. Require a VALIDSIG from the pinned key instead.
GPG_STATUS=$(gpgv --status-fd 1 --keyring "$KEYRING" InRelease 2>/dev/null || true)
grep -q "^\[GNUPG:\] VALIDSIG $PPA_KEY " <<<"$GPG_STATUS" \
|| { echo "InRelease: no valid signature from $PPA_KEY"; echo "$GPG_STATUS"; exit 1; }
echo "InRelease: valid signature from $PPA_KEY"
curl -fsSO $PPA/dists/jammy/main/source/Sources.xz
SRC_SHA=$(sed -n '/^SHA256:/,/^[A-Z]/p' InRelease | awk '$3=="main/source/Sources.xz"{print $1}')
echo "$SRC_SHA Sources.xz" | sha256sum -c -
DSC=php8.3_${V}.dsc
for f in "$DSC" "php8.3_${UPSTREAM}.orig.tar.xz" "php8.3_${V}.debian.tar.xz"; do
curl -fsSO "$PPA/pool/main/p/php8.3/$f"
done
DSC_SHA=$(xzcat Sources.xz \
| awk -v v="$V" '/^Package: /{p=($2=="php8.3")} p&&/^Version: /{ok=($2==v)} p&&ok&&/^Checksums-Sha256:/{c=1;next} c&&/^ /{print; next} {c=0}' \
| awk -v d="$DSC" '$3==d{print $1}')
echo "$DSC_SHA $DSC" | sha256sum -c -
rm -rf "php8.3-${UPSTREAM}"
dpkg-source -x "$DSC" # checks the tarballs against the dsc's checksums
cd "php8.3-${UPSTREAM}"
DEBFULLNAME="${DEBFULLNAME:-Local rebuild}" DEBEMAIL="${DEBEMAIL:-root@localhost}" \
dch -b -v "$NEWV" -D focal --force-distribution "Rebuild of ${UPSTREAM} for focal."
echo '== Build deps'
$APT build-dep -yq ./
# Recent packaging adds a DNS version check when FPM starts. It only compiles in when
# /usr/lib/php/php-common.mk defines TELEMETRY_HOST. Stock focal php-common ships no such file.
if grep -qs TELEMETRY_HOST /usr/lib/php/php-common.mk; then
echo 'php-common.mk defines TELEMETRY_HOST: refusing to build with the version check'; exit 1
fi
echo '== Build'
# `dh: error: Unknown sequence /usr/lib/php/php-common.mk` in the log is harmless: make tries to
# remake the missing -include'd file.
time DEB_BUILD_OPTIONS="nocheck parallel=$(nproc)" dpkg-buildpackage -b -uc -us -j"$(nproc)" > ../build.log 2>&1 \
|| { tail -80 ../build.log; exit 1; }
if grep -q -- '-DTELEMETRY_HOST' ../build.log; then echo 'TELEMETRY_HOST got into the build'; exit 1; fi
ls -la ../*.deb
What it does:
- Downloads the jammy
InReleasefile and checks it carries a valid signature from the key you pinned. - Checks
Sources.xzagainst the hash in the signedInRelease, and the.dscagainst the hash inSources.xz. - Unpacks the source with
dpkg-source, which checks the tarballs against the.dsc. - Adds a changelog entry with a 20.04 version number,
8.3.36-1+ubuntu20.04.1+rebuild.1. - Installs the build dependencies from 20.04's own archive and builds.
Step 3: Install on your servers
Copy the .deb files to each server and upgrade only the PHP packages that are already installed:
cd ~/debs # wherever you copied the .deb files
installed=$(dpkg-query -W -f='${Package} ${Status}\n' 'php8.3*' libapache2-mod-php8.3 libphp8.3-embed 2>/dev/null \
| awk '/install ok installed/{print $1}')
debs=()
for p in $installed; do
f=$(ls ./${p}_*.deb 2>/dev/null | head -1 || true)
if [ -n "$f" ]; then debs+=("$f"); else echo "Skipping $p (not built from php8.3 source)"; fi
done
sudo apt-get install -y -o Dpkg::Options::=--force-confold -o Dpkg::Options::=--force-confdef "${debs[@]}"
--force-confold keeps every config file you have edited, and --force-confdef takes the new default for the ones you never touched. Then check it:
php -v # PHP 8.3.36
php -m # the same modules as before
sudo php-fpm8.3 -t # config test
sudo systemctl restart php8.3-fpm && systemctl is-active php8.3-fpm
Trap 1: FPM never starts
The 8.3.36 packaging no longer sets pid in /etc/php/8.3/fpm/php-fpm.conf. The packaged systemd unit does not need it. But if you keep your own copy of php8.3-fpm.service, or a drop-in, that sets PIDFile=/run/php/php8.3-fpm.pid, systemd waits for a file FPM never writes, and gives up after 90 seconds:
php8.3-fpm.service: Can't open PID file /run/php/php8.3-fpm.pid (yet?) after start-post: Operation not permitted
php8.3-fpm.service: Start-post operation timed out. Stopping.
Fix it either way. Remove PIDFile= from your unit, since Type=notify does not need it. Or add the line back to php-fpm.conf, under [global]:
pid = /run/php/php8.3-fpm.pid
If your config management or deploy writes the unit file, fix it there too, or the next deploy brings the problem back.
Trap 2: the signature check always fails
The PPA's InRelease file is signed twice: by the current key and by a retired one (14AA40EC…). gpgv cannot check the retired signature, so it exits with an error even when the current signature is good:
[GNUPG:] VALIDSIG B8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6 ...
[GNUPG:] ERRSIG 4F4EA0AAE5267A6C ...
[GNUPG:] NO_PUBKEY 4F4EA0AAE5267A6C
A script that trusts gpgv's exit code fails every time, and the tempting fix is to ignore the result, which throws away the whole point of checking. The script instead looks for a VALIDSIG line from the exact key you pinned, and refuses to continue without one.
Warnings you can ignore
dpkg-source: warning: failed to verify signature on ./php8.3_….dscappears because the.dscis also signed with Ondřej's personal key, which is not in your keyring. That is fine: by then the script has already checked the.dscagainst the hash in the signed index.dch warning: new version (…ubuntu20.04.1+rebuild.1) is less than the current versionis expected. A 20.04 version number sorts below the 22.04 one it is rebuilt from, and the script passes-bto tell dch that is intended.dh: error: Unknown sequence /usr/lib/php/php-common.mkappears inbuild.log, and is harmless. make tries to rebuild a file the packaging includes only if it exists, and 20.04's php-common does not ship it.
The TELEMETRY_HOST check
Recent packaging adds a DNS version check when FPM starts. It is only compiled in when the build machine's php-common defines a TELEMETRY_HOST. Stock 20.04 does not, so the rebuilt FPM does not make that call. The script checks this so a change on the build machine cannot switch it on without you noticing.
When the next release comes out
Run the script again with the new version, such as ./build-php-focal-debs.sh 8.3.37. If Sury publishes a second build of the same release (a version ending in deb.sury.org+2), pass that number as well: ./build-php-focal-debs.sh 8.3.37 2. Keep doing this until you are on a newer Ubuntu, and keep an eye on the PHP 8 changelog for security releases.
Ondřej is moving everything from the PPA to packages.sury.org. That repository also publishes jammy source packages, signed with a different key, so if the PPA stops carrying them, the script needs pointing there instead. We have not tried that yet.
Thanks
The hard work here is Ondřej Surý's. The packaging this script rebuilds is his, and his repositories have kept PHP current on Debian and Ubuntu for years. If your business runs on them, consider supporting him, or buying support from Freexian.