On 21 October 2026 the PHP project fixed two critical bugs, CVE-2026-109618 and CVE-2026-109619, quickly nicknamed php2shell. Both can allow unauthenticated remote code execution. In the first, an out-of-memory error can interrupt PHP while internal request data is only partially initialized; request parsing is the easiest way to get there, but not the only one. In the second, request-owned state survives in a persistent worker after its memory has been freed. Full RCE was demonstrated on Apache mod_php; on PHP-FPM the exposure is more limited and depends on the configuration, but "limited" is not "safe".
The fixed releases were 8.2.35, 8.3.36, 8.4.27 and 8.5.12, and the PHP project published minimal source backports for 7.4, 8.0 and 8.1. Anything older depends on distributions and LTS providers. AWS saw a first exploitation attempt against its honeypots on 17 October, four days before disclosure (AWS).
If you run PHP from deb.sury.org on Debian 9 stretch or Debian 8 jessie, there is no patched package for you. Upgrading the operating system is the real fix, but it is weeks of work, and a hole like this one needs to be closed in days.
This post shows how to do that: find a patched source, rebuild it for a distribution its maintainer no longer supports, avoid the traps along the way, rehearse the installation on a copy of a production server, and roll it out while checking that the pages you serve do not change. The examples use php7.2 7.2.34-66; other branches need their own compatibility checks.
Step 0: read what you actually run
Before choosing anything, read every host, read-only:
grep PRETTY_NAME /etc/os-release; uname -r
php -v | head -1; php7.2 -v | head -1 # `php` may point to another branch
dpkg-query -W -f='${Package} ${Version}\n' 'php7.2*' 'libicu*' 'libsodium*' php-common
php-fpm7.2 -i | grep -E '^(session.upload_progress.enabled|memory_limit)' # FPM defaults, before pool overrides
ls /var/cache/apt/archives | grep php7.2 # rollback material
cat /etc/apt/sources.list /etc/apt/sources.list.d/*.list
php-fpm7.2 -i does not show what a pool or the web server overrides (php_admin_value, PHP_VALUE): read the pool configuration too.
Three questions decide everything else:
- Is there a package to install? As of 26 October 2026, sury's repository answers 404 for stretch and buster. The patched builds exist only for Debian 11, 12 and 13:
| Debian | 7.2 | 8.2 | 8.3 | 8.4 |
|---|---|---|---|---|
| 9 stretch, 10 buster | 404 | 404 | 404 | 404 |
| 11, 12, 13 | 7.2.34-66 | 8.2.35 | 8.3.36 | 8.4.27 |
-
Does apt still work? On an old host,
sources.listoften still points at Debian mirrors that stopped serving stretch long ago, so anyapt-get -f installfails. Whatever you install has to bring its dependencies with it. -
Is the apt cache still there? Some hosts keep the exact
.debfiles of every PHP package they run. That is your rollback, if it is there: check files, versions and architecture rather than assuming.
Two cheap mitigations can go in right away:
-
session.upload_progress.enabled = Offinphp.inior in the pool configuration (not withini_set: it must apply before the request is parsed), then restart the workers. One of the documented exploitation paths of the second CVE goes through it. On 23 October PHP also recommended keeping it off until the next release because of a further, unlikely vector, CVE-2026-115560. - If a virtual host only ever needs to answer GET and HEAD requests, make the web server refuse other methods and cap request bodies before PHP sees them:
client_max_body_size 1k;
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
This limits bodies to 1 KB, it does not reject them. Add OPTIONS only if that virtual host needs it (for example for CORS preflight), and check your access logs first, to be sure nobody legitimately sends anything else.
These reduce exposure; they do not fix anything. The advisories list further temporary measures and their limits.
The options
- Mitigations only. Necessary, not sufficient: they do not close every path to either CVE.
- Paid extended support. Freexian publishes patched PHP for old Debian releases, from Debian 9 up; jessie is not on their list (Freexian PHP LTS). A perfectly good option, if it covers your exact combination and fits your budget.
-
Containers on the old hosts. The newest Docker CE in Docker's stretch archive is 19.03.15. With the old Docker we tried, the default seccomp profile broke thread creation (
clone3) in images based on recent glibc. Test your exact runtime and image before betting on it. - Jump to PHP 8.x on stretch, compiled from source. stretch's libraries meet PHP 8.3's basic build requirements (8.4 needs libcurl ≥ 7.61; stretch has 7.52), but every extension you use has its own. And your application may need changes, or may not run on PHP 8 at all: that is a project, not a patch.
-
Rebuild sury's patched package for your old Debian. On 21 October sury published
php7.2 7.2.34-66with a backport of the fix, and the source package is public. If it builds on stretch, it installs over your current 7.2 with the same package names, the same/etc/php/7.2and the samephp7.2-fpmservice: no application change. That is the route described here.
What is inside sury's source package
dpkg-source -x php7.2_7.2.34-66+0~20261021.122+debian12~1.gbpe0bcd4.dsc php7.2-src
wc -l php7.2-src/debian/patches/series # 159
Do not add --no-check: it also disables the checksum checks. And a download over HTTPS is not authentication. This .dsc carries no signature of its own, so authenticate the files through the repository's signed metadata: the InRelease file (verified with sury's archive key), then the Sources index it lists, then the checksums of the source files. Keep that metadata next to the files.
PHP 7.2.34 plus 159 patches: six years of security backports and packaging changes. The last one is the fix:
0159-Avoid-state-corruption-via-bailout.patch
Minimal mitigation for EOL version.
Fix GHSA-w8hw-w9fx-j79w (CVE-2026-109618)
Fix GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)
Backported from the PHP 7.4 version
It touches 16 files in Zend/, main/rfc1867.c, main/SAPI.c and ext/session/session.c.
Before building anything, check the two things most likely to make the idea fail, in a throwaway debian:stretch container: do all patches apply, and do the build dependencies resolve on stretch?
dpkg-source -x php7.2_*.dsc php7.2-src # applies all patches
cd php7.2-src
mk-build-deps -i -r -t 'apt-get -y --no-install-recommends' debian/control
dpkg-checkbuilddeps && echo "BUILD-DEPS OK"
Both pass (stretch's debhelper 10 is new enough). Ten minutes to know the plan is viable.
The backport is not upstream's fix
"Minimal mitigation for EOL version" means what it says. Read the patch before shipping it:
- when a request hits
memory_limit(and on a few other allocation failures), the patched allocator prints the fatal error to stderr and ends the process with_exit(1): no normal request shutdown, no shutdown handlers, no session write, and the message does not go through PHP's error log. Under FPM the worker dies and the master replaces it; enablecatch_workers_outputin the pool if you want to see those messages; - the session upload progress callback returns unconditionally (
|| 1), so that feature is off even ifphp.inisaysOn. Ordinary file uploads are a different thing and keep working.
A comparison of ordinary pages does not exercise either change. Test uploads, error handling and FPM recovery on the replica.
The telemetry patch
Patch 0144 is a surprise: "Add minimal privacy-preserving secure DNS telemetry to check current version", added on 12 February 2026. It adds main/secure_dns_telemetry.h and hooks it into the FPM and mod_php startup:
- at every start it builds
v1|<package>|<version>|<timestamp>, encrypts it with a public key, and sends it as a DNS query (UDP 53) to the configured host; - a reply may carry an advisory, which is only logged if it says "high", "critical" or "emergency" (and both the FPM and the
mod_phphooks define the logging macro as empty); - nothing received is executed.
The next day's changelog entry says "The secure DNS telemetry is disabled at the compile-time", and debian/rules says "not enabled in build by default". The hostname is not in the patch: the startup call is compiled in only when TELEMETRY_HOST is defined, and debian/rules picks it up, if present, from /usr/lib/php/php-common.mk, shipped by the php-common installed where the package is built (it is a build dependency). In Debian's own php-common that file is empty. In the one published on packages.sury.org it contains (checked on 26 October 2026):
-DTELEMETRY_HOST='"telemetry.sury.org"' \
-DTELEMETRY_PORT='"53"' \
-DTELEMETRY_PK='"…"'
So packages built against sury's php-common have the call compiled in, and Debian's own builds do not. We found telemetry.sury.org in an official php-fpm8.1 binary from sury's repository. Check your own binaries (strings /usr/sbin/php-fpm7.2 | grep telemetry). A project that lives on donations has good reasons to want to know how many installations exist, and the code is careful. We dropped the patch from our build anyway: it is not a security fix, builds from before February 2026 never had it, and even with the call disabled the header is included and FPM is linked against libsodium, which breaks the build on stretch (it needs libsodium ≥ 1.0.14; stretch has 1.0.11). No later patch touches its files, so removing it is clean.
Building for stretch, and the ICU trap
The builder is a Dockerfile on debian:stretch, with the archived repositories. This is the core of it, not the complete build (the ICU part follows):
FROM --platform=linux/amd64 debian:stretch
RUN printf '%s\n' \
'deb http://archive.debian.org/debian stretch main contrib non-free' \
'deb http://archive.debian.org/debian-security stretch/updates main contrib non-free' \
> /etc/apt/sources.list && \
echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/10no-check-valid-until && \
apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl dpkg-dev devscripts \
equivs fakeroot quilt xz-utils
ARG SURY_VERSION=7.2.34-66+0~20261021.122+debian12~1.gbpe0bcd4
ARG SURY_POOL=https://packages.sury.org/php/pool/main/p/php7.2
WORKDIR /build
RUN curl -fsSO "$SURY_POOL/php7.2_${SURY_VERSION}.dsc" && \
curl -fsSO "$SURY_POOL/php7.2_7.2.34.orig.tar.xz" && \
curl -fsSO "$SURY_POOL/php7.2_${SURY_VERSION}.debian.tar.xz" && \
dpkg-source --skip-patches -x "php7.2_${SURY_VERSION}.dsc" php7.2-src && \
cd php7.2-src && \
sed -i '/^0144-Add-minimal-privacy-preserving-secure-DNS-telemetry-.patch$/d' debian/patches/series && \
QUILT_PATCHES=debian/patches quilt push -a --fuzz=0
RUN cd php7.2-src && apt-get update && \
mk-build-deps -i -r -t 'apt-get -y -q --no-install-recommends' debian/control
Check-Valid-Until "false" only accepts the archive's expired release dates; signatures are still checked. If you authenticated the source files elsewhere (see above), copy them in instead of downloading them here.
The build itself gives the packages a local revision, so they are recognizable on the servers and sort above the old ones (check with dpkg --compare-versions against what the hosts run, and never reuse a revision for different contents):
dch -b -v "7.2.34-66+stretch2" -D stretch "Rebuilt for Debian 9 stretch, without the DNS telemetry patch (0144), intl against ICU 64."
DEB_BUILD_OPTIONS="nocheck parallel=$(nproc)" dpkg-buildpackage -b -uc -us
nocheck skips PHP's own test suite: a package that compiles is not a tested package, so the checks later in this post are not optional.
Our first package built, installed cleanly and served pages. One page was different: Italian dates had turned from 7 ott 2026, 17:12 into 07 ott 2026, 17:12.
The PHP being replaced had not been linked against stretch's ICU 57. sury's 2019 builds for stretch used a backported ICU 64, together with backported pcre, libsodium, libargon2, libgd and libtidy. ICU 57 carries older locale data (CLDR), which formats Italian medium dates differently. The same library drives localized dates and number formats, and the Transliterator that many applications use to generate URL slugs, so this is not cosmetic: a slug generated with the new build could differ from the one the old build generated for the same title. Check which ICU your current intl.so declares before you build (objdump -p … | grep NEEDED), and record what PHP reports at runtime too.
We could not find ICU 64 development packages for stretch in any repository or archive we checked, not even in sury's pool. What worked:
- Install the
libicu64runtime package your servers already have in the builder (copy it from a host's apt cache). - Build ICU 64.1 from upstream only for its headers and pkg-config files. Check the tarball's GPG signature against unicode-org's
KEYSfile (it is signed by ICU's release manager) and pin its SHA-512 in the Dockerfile. - Replace the libraries of that ICU build with links to the packaged ones, so anything linking "ICU 64" links the library the servers already have.
- Point
PKG_CONFIG_PATHat it, because PHP'sconfigureasks pkg-config first.
COPY prod-debs/libicu64_*.deb /tmp/
RUN dpkg -i /tmp/libicu64_*.deb
RUN curl -fsSL -o /tmp/icu.tgz "$ICU_URL" && echo "$ICU_SHA512 /tmp/icu.tgz" | sha512sum -c - && \
tar -xzf /tmp/icu.tgz -C /tmp && cd /tmp/icu/source && \
./configure --prefix=/opt/icu64 --disable-samples --disable-tests && make -j"$(nproc)" && make install && \
for lib in /opt/icu64/lib/libicu*.so*; do rm "$lib"; done && \
for lib in /usr/lib/x86_64-linux-gnu/libicu*.so.64; do \
name=$(basename "$lib" .64); ln -s "$lib" "/opt/icu64/lib/$name"; ln -s "$lib" "/opt/icu64/lib/$name.64"; \
done
ENV PKG_CONFIG_PATH=/opt/icu64/lib/pkgconfig
configure duly reported checking for location of ICU headers and libraries... found 64.1. The package still declared Depends: libicu57. Most likely, the linker had resolved -licuuc through the development symlinks of libicu-dev 57 (/usr/lib/x86_64-linux-gnu/libicuuc.so → .so.57), which come before our directory on its search path: headers from 64, library from 57. Either way, that package was unusable. A post-build check that only reads Depends would not catch the cases where the two disagree; this one does:
mkdir /x && dpkg-deb -x php7.2-intl_*.deb /x
objdump -p /x/usr/lib/php/20170718/intl.so | grep NEEDED | grep icu # must say .so.64
dpkg-deb -f php7.2-intl_*.deb Depends # must say libicu64
Make the build script fail if either says 57, then check on the replica that the module loads and formats as expected. Our first version of that check also had a bug: it called dpkg-deb on the Mac, where it does not exist, and fell back to a Docker command that passed the * unexpanded. Test your tests.
Retargeting those development symlinks to 64 and moving the ICU 57 headers away fixes the link, and breaks the next build: libxml2's own headers include <unicode/ucnv.h>, so ext/libxml stops compiling. The final fix points /usr/include/unicode at the ICU 64 headers instead of removing it. At runtime libxml2 keeps using ICU 57 and intl uses ICU 64, both loaded in the same process, exactly as with the old build: these ICU builds export their symbols with a version suffix (ucol_open_64), so the two did not clash. Check your own binaries rather than assuming every ICU build does the same.
Each build took about 50 minutes under amd64 emulation (Rosetta) on an 8-core Apple Silicon laptop, and three of ours failed: telemetry and libsodium, ICU linked to 57, libxml2 headers. Put everything that can fail fast at the beginning of the Dockerfile, and give each rebuild a new revision (+stretch1, +stretch2) so a bad package can never be mistaken for a good one.
A production replica to rehearse on
Before touching a server, build a debian:stretch container that is one of your servers, as far as PHP is concerned:
- the exact production
.debfiles, copied (read-only) from a server's apt cache: PHP packages, pecl extensions and backported libraries, with their checksums; - the production
/etc/php, copied the same way, with only the changes needed to keep the replica away from production: sessions, databases, queues, mail, and any other outbound connection; - the same web server version as production, with development virtual hosts.
ssh your-server "cd /var/cache/apt/archives && tar -cf - php7.2-* php-apcu_* …" \
| tar -xf - -C prod-debs
Mount the application code and point it at a development database, and the replica serves real pages. A container shares the host's kernel and usually does not run systemd: use it for packages and application behavior, and check service start, reload and log rotation on a real host.
Then compare. Capture a list of representative pages (home, a few dynamic pages, login, an error page, pages with localized dates), flush the application cache, capture again. Measure the noise before comparing anything. Two captures with the same PHP will differ in things like debug toolbars, CSP nonces, CSRF tokens, last-modified metadata, timestamps embedded in HTML or JavaScript, and lists whose content varies on its own. Normalize only those fields, keep the raw captures, and compare the rest byte by byte:
normalize() {
sed -E \
-e 's/nonce="[^"]*"/nonce=""/g' \
-e 's/(name="csrf_token" value=")[^"]*/\1/g' \
"$1"
}
normalize before/page.html > before/page.norm.html
normalize after/page.html > after/page.norm.html
cmp before/page.norm.html after/page.norm.html
Adapt the patterns to your pages, do not squeeze whitespace (it can matter), look at the raw diff too, and check that tokens still work rather than just hiding them. With the right ICU, every page we sampled was identical after normalization, Italian dates included. That is a bounded regression check, not a proof that nothing else changed.
Six bugs the rehearsal found in our own install script
The rehearsal is not only about the package. Running the exact installation procedure on the replica found six bugs in ours before it ever touched production:
-
apt-get install debs/x.deb(relative path) is read as a package name. apt wants./debs/x.debor an absolute path. - macOS
taradds Apple extended attributes that GNU tar on the server warns about:--no-xattrs --no-mac-metadata. - Missing rollback packages produced a warning and the procedure carried on. Now it stops.
-
installran even after a failedpreflight. Nowpreflightleaves a token thatinstallrequires and consumes, tied to the host and to the exact package set. -
preflightrewrote the "state to roll back to" every time it ran: after one install and one more preflight, a rollback would have "rolled back" to the new version. Now the original state is written once. - The rollback looped over
dpkg-queryoutput, which also lists virtual names such asphp7.2-apcu(provided byphp-apcu) with an empty version, and stopped on the first one.
And one difference between a replica and production that matters: official Debian Docker images delete downloaded packages after every apt run (/etc/apt/apt.conf.d/docker-clean). A rollback that relies on the apt cache can work on real servers and silently have nothing to work with in the container. Remove that file in the replica, and on production keep an explicit rollback bundle instead of trusting the cache.
Installing without breaking things
The first, naive installation attempt on the replica used dpkg -i. A dependency was missing (libargon2-0, which stretch has but the host did not), and dpkg did what dpkg does: it unpacked everything and left the packages unconfigured. The PHP CLI broke immediately:
php: error while loading shared libraries: libargon2.so.0: cannot open shared object file
Anything that runs the PHP CLI, such as cron jobs and queue workers, would be down, and package scripts can restart PHP-FPM too. So the installation goes through apt, with apt allowed to see only the local .deb files and the installed packages:
APT_LOCAL=(
-o Dir::Etc::SourceList=/dev/null
-o Dir::Etc::SourceParts=/dev/null
-o Dir::State::Lists=/tmp/apt-empty-lists
-o Dpkg::Options::=--force-confold
)
apt-get install -s --no-install-recommends --no-remove "${APT_LOCAL[@]}" "${debs[@]}" # simulation
apt-get install -y --no-install-recommends --no-remove "${APT_LOCAL[@]}" "${debs[@]}"
If a dependency is missing, apt refuses during the solver phase, before unpacking anything; --no-remove makes it refuse instead of removing packages. That does not make the installation atomic: a full disk or a failing maintainer script can still stop it halfway. Compute the list of packages per host: the new version of every php7.2-* package installed there, nothing more, plus the runtime libraries that travel with them.
preflight
preflight must succeed before install agrees to run. It:
- backs up
/etc/php/7.2; - records the state: package versions, the modules loaded by the CLI and by FPM, and the output of a few intl calls that would expose an ICU change:
$d = new DateTime("2026-10-07 17:12:00", new DateTimeZone("Europe/Rome"));
foreach ([IntlDateFormatter::SHORT, IntlDateFormatter::MEDIUM, IntlDateFormatter::LONG, IntlDateFormatter::FULL] as $s)
echo (new IntlDateFormatter("it_IT", $s, IntlDateFormatter::SHORT, "Europe/Rome"))->format($d), PHP_EOL;
echo (new NumberFormatter("it_IT", NumberFormatter::CURRENCY))->formatCurrency(1234.5, "EUR"), PHP_EOL;
echo Transliterator::create("Any-Latin; Latin-ASCII; Lower()")->transliterate("Perché l’università è così?"), PHP_EOL;
- writes the rollback target once, and checks that every package needed to go back is available (copying it from your own set when the host lacks it);
- checks free disk space;
- simulates the installation and stops on any planned downgrade or removal.
After install, the recorded state must differ only in what you expect: version numbers, and the behavior changes of the backport described above. Anything else is a reason to roll back.
What real hosts can still teach you
-
A newer library than yours. A host may already have sury's 2019
libargon2-0, newer than the stretch one you ship. apt refuses the downgrade, withPackages were downgraded and -y was used without --allow-downgrades, and touches nothing. Install dependencies only when missing or older, and run the downgrade check inpreflight. (Our first version of that check died silently: adpkg --compare-versions … && echoreturning 1 inside awhileloop underset -e -o pipefail. It failed closed, which is the right way to fail.) -
A missing rollback package. Some hosts have nothing in their apt cache, or have packages installed that your rollback set does not cover, such as a
-dbgsymdebug package.preflightshould stop; add the missing package to the rollback set, taken from another host's cache. - A full disk. Check free space before installing, not halfway through.
-
php-fpm.confgets replaced where it is still the stock file (dpkg updates untouched conffiles), while customizedphp.inifiles are kept by ucf, with the new version saved asphp.ini.ucf-dist.--force-confolddoes not govern ucf. Diff/etc/phpagainst the backup after the first host, not after the last. -
A new syslog line: the new systemd unit runs
ExecStartPost=-/usr/lib/php/php-fpm-socket-helper, which an olderphp-commondoes not ship. The leading-makes systemd ignore the failure, but the socket alias that helper creates does not exist. Harmless only if nothing uses it.
Rolling it out
Start with a single host. Capture pages twice before installing (the two captures must be identical after normalization), install, capture again. Then compare the patched host with an unpatched one at the same moment, on pages generated per request (Cache-Control: no-cache, private), including pages with localized dates. Make sure every request really reached PHP on the host you think it did, past any cache.
Then one host at a time. Take it out of the load balancer, wait until its connections drop to zero, capture, preflight, install, check that the old workers are gone and the new binary is serving, capture again, compare, put it back, and listen for a few minutes: connections, load, PHP-FPM state, web server errors by type, not by count.
Two things worth knowing:
-
Health checks can lag. If your health check is a PHP script toggled by a flag in its source, OPcache may keep serving the old version for up to
opcache.revalidate_freqseconds (whenopcache.validate_timestampsis on). A flag read from a file or a cache has its own rules. Retry for a while before declaring the host back. - Load goes up for a few minutes after each FPM restart, because OPcache and APCu start empty. Expect it, and check it settles without new errors.
Some special cases:
- Hosts you cannot drain. Reduce what PHP is exposed to first (the request-method rule above), plan for a short interruption, then install in place and compare on real requests, body and significant headers.
- Old packages that no longer exist. If the exact packages a host runs are gone everywhere, you have no exact rollback. Pick a recovery target you can install with the same configuration, test that it works, and remember it is just as vulnerable as the original.
- CLI-only hosts. Patch them too if they run the same application: with a different PHP and ICU, their jobs would generate slugs and dates that differ from the website's. Running processes keep the old binary and extensions in memory until they exit: restart long-running workers, and watch jobs that start during the installation itself.
Debian 8 jessie
The same source builds for jessie, with more surgery:
- remove the
tidyandzipextensions if your application does not need them (jessie's libtidy and libzip are older than required): delete theirdebian/rules.d/ext-*.mkfiles, anddebian/controlis regenerated without them; - disable Argon2 password hashing (jessie has no libargon2), after checking that no stored password hash uses it;
- take
debhelperandlibsodium-devfromjessie-backports, and nothing else, so the packages depend on libraries the server actually has; - remove one compiler flag: jessie's gcc 4.9 does not know
-Wno-error=incompatible-pointer-types, and with itconfigurecannot compile anything; - jessie's
dpkg-buildpackagewrites no.buildinfofile: adjust whatever copies the results.
jessie's apt (1.0) cannot install local .deb files. A tiny local repository solves it:
dpkg-scanpackages . /dev/null > Packages # in the builder, next to the .deb files
echo 'deb [trusted=yes] file:/path/to/debs ./' > local.list
apt-get -o Dir::Etc::SourceList=local.list -o Dir::Etc::SourceParts=/dev/null \
-o Dir::State::Lists=lists -o Dir::Cache=cache update
apt-get … install -s php7.2-common php7.2-cli php7.2-fpm … # simulation, then the real one
[trusted=yes] skips apt's signature check for that directory: fine for files you already verified and keep in a protected place, never for a repository on the network.
If the host runs an older minor version, such as 7.1, PHP 7.2 can go in next to it: different package names, separate FPM services, separate sockets. That is still an application upgrade, so test it, and check what happens to the php CLI alternative too. Nothing changes for visitors until you point the web server's virtual host from php7.1-fpm.sock to php7.2-fpm.sock and reload. To be sure requests really go to the new PHP, check the new pool's status page or access log; the I/O counters of the two FPM pools around a few requests are a quick extra signal (the new one goes up, the old one does not move). Rolling back is the same line and another reload; once you are confident, remove the old version, which is still vulnerable.
A difference we almost reported as a bug
After the upgrade, php-fpm.conf contained:
;pid = /var/run/php/php@PHP_MAJOR_VERSION@.@PHP_MINOR_VERSION@-fpm.pid
Commented out, placeholders unsubstituted, while the 2019 package wrote pid = /run/php/php7.2-fpm.pid and Debian's own packages (8.2 on bookworm, 8.4 on trixie) still do. We traced it to the packaging (a sed that only rewrites an uncommented pid line) and drafted a bug report with a one-line fix.
Then we looked for the project's tracker. The GitHub repository is archived; the tracker moved to Codeberg, and there the change had already been discussed (issue #113). It is deliberate: the changelog calls it "Do not create obsolete PID file", in the same May 2026 release that hardened the systemd unit. Without a pid file, systemd does not care (Type=notify, reload via $MAINPID), and log reopening was rewritten to use systemctl kill (issue #117). Only the old init.d scripts need it, and the maintainer said he would rather drop them than patch them. No report needed.
The same release hardened the systemd unit (ProtectSystem=full, PrivateDevices=true, RestrictNamespaces=true and more; debian/NEWS lists them and warns that less standard FPM setups may need changes). ProtectSystem=full makes /usr, /boot and /etc read-only for PHP-FPM, so an application that writes there will break, and an older systemd ignores the options it does not know, with a warning in the journal. Test start, restart, reload and log rotation on the target.
Takeaways
-
An end-of-life OS turns a five-minute
apt upgradeinto a project. Budget the migration before the next CVE does it for you. -
Read what you run, pools included.
php-fpm -iand the pool configuration, not justgrep php.ini. - Read the backport, not just its title. A minimal mitigation can change behavior.
- Measure the noise before you compare. Normalize only what you proved is noise, then compare byte by byte.
-
Check what a binary actually loads (
NEEDED), not only what its package declares. - Rehearse the installation procedure, not only the package, on a replica built from the production packages and configuration. Ours found six bugs.
- Install through apt with local files only, simulate first, refuse downgrades and removals, keep the rollback packages next to you.
- Read the patches you ship. One of them may phone home.
- Check the upstream tracker, and where it lives now, before filing. It may be a decision, not a bug.
None of this replaces upgrading the operating system: it buys you time. But it can close a critical hole in days, with the pages you checked unchanged.
Top comments (0)