A critical PHP fix on a server whose Ubuntu no longer gets updates
On 20 October php.net announced a security release for the next day at 14:00 UTC. The releases came out that day: PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, fixing CVE-2026-109618 and CVE-2026-109619. Both can allow remote code execution without authentication; the demonstrated exploit targets mod_php. The advisory lists every installation that serves web requests as affected: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed, and says nothing about the CLI.
I had three servers to patch that evening. The one at home runs Ubuntu 24.04 and got Ubuntu's fixed package for 8.3 plus the ondrej PPA builds for 8.4 and 8.5. A second VPS on Ubuntu 26.04 got 8.5.4-0ubuntu1.5, except for one application in a container with its own PHP 8.5.11, which needed a rebuilt image. The third got nothing.
Why php -v does not answer "am I patched?"
On Ubuntu 24.04, after installing the fixed package, php8.3 -v still printed 8.3.6 (plain php on that machine is 8.5 from the PPA). That is correct. Ubuntu does not move PHP to 8.3.36; it backports the fix into its own package and bumps only the package revision. The Ubuntu CVE page lists the fixed versions as 8.3.6-0ubuntu0.24.04.13 for 24.04 and 8.5.4-0ubuntu1.5 for 26.04. The version you need to compare is the package one:
dpkg-query -W -f='${Package} ${Version}\n' 'php8.3-fpm'
apt-cache policy php8.3-fpm
apt-cache policy adds the installed and candidate versions and the repositories that currently offer them. That helps tell the two kinds of packages apart, because the ondrej/php PPA does the opposite of Ubuntu and follows the upstream numbers: after the update its packages report 8.4.27 or 8.5.12. On the same machine you can have one PHP that reports the upstream version and another that reports 8.3.6 and is just as patched. So I checked the package version against the Ubuntu CVE page.
One more trap: php -v is the CLI. The websites run under PHP-FPM, which can be a different package, a different version, or a container. On the second VPS the system PHP was already patched while the application in the container still ran 8.5.11.
The server that got nothing
The third server is an OVH VPS with four Laravel applications behind nginx (nine virtual hosts), on Ubuntu 25.04 with PHP-FPM 8.4.5-1ubuntu1.2. Ubuntu 25.04 reached end of life on 15 January 2026. After that no package updates are accepted for it, and the Ubuntu CVE page does not list it at all. apt had nothing newer. The ondrej PPA has no plucky distribution either: dists/plucky/Release returns 404, while dists/noble/Release returns 200.
So there were two options that night: reduce the exposure and wait, or bring in a fixed PHP from somewhere other than the system.
I did the first one immediately. The advisory lists temporary measures and says plainly that they reduce exposure but do not make an installation safe:
; FPM pool (php_admin_value, so the application cannot override it)
php_admin_value[memory_limit] = -1
; php.ini
post_max_size = 32M
session.upload_progress.enabled = 0
followed by a restart of PHP-FPM so the workers pick up the new settings (a reload would also reread them). 32M because some of the applications accept file uploads; without uploads the advisory suggests 8M. memory_limit = -1 has a cost of its own: the server had 7.6 GB of RAM and no swap, so a single request that kept allocating could wake the OOM killer, and it does not have to pick the PHP worker. In the end this lasted about twelve minutes, from 19:50 to the switch to the container at 20:02 UTC; the container runs fixed PHP with the usual memory_limit = 128M.
A container on the same socket
The official Docker image php:8.4.27-fpm-trixie was already on Docker Hub. The idea was to swap only the PHP-FPM process and leave nginx and the applications alone. nginx already talks to /run/php/php8.4-fpm.sock, so the container listens on exactly that socket.
FROM php:8.4.27-fpm-trixie
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath calendar exif ffi gd gettext igbinary redis intl mysqli opcache \
ftp pcntl pdo_mysql pdo_pgsql pgsql shmop sockets sysvmsg sysvsem sysvshm xsl zip
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini
COPY 90-host.ini /usr/local/etc/php/conf.d/90-host.ini
COPY zz-host.conf /usr/local/etc/php-fpm.d/zz-host.conf
The extension list is the one the system PHP had. 90-host.ini carries the settings copied from /etc/php/8.4/fpm, including upload_max_filesize = 12M, below post_max_size as the advisory asks. The pool file, without the pm.* lines:
[www]
user = www-data
group = www-data
listen = /run/php/php8.4-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
The file name matters. The official image sets listen = 9000 in php-fpm.d/docker.conf, with a comment that it is meant to be overridden by later files in that directory. zz-host.conf sorts after it, so its listen wins.
The container runs with --network host, so the applications reach MySQL, Postgres and Redis on 127.0.0.1 exactly as before. All of it went into one script with build, test, switch and rollback, so that going back was a single command.
The test step did not switch any traffic. It compared the extension lists of the host PHP and the container (comm -23 on two sorted php -m outputs), ran php-fpm -t, and ran php artisan about for each of the four applications inside the container to see that they boot and pick up their configuration. about only prints the configured drivers, so the connections got a separate check from the container: a select 1 through the DB facade and a Redis ping, both fine for all four. The first run caught that ftp was missing from the container, so the image was built a second time. Only after that came switch:
systemctl stop php8.4-fpm
systemctl mask php8.4-fpm
mkdir -p /run/php && chown www-data:www-data /run/php
docker run -d --name php84 --restart unless-stopped \
--network host --group-add 1000 \
-v /var/www/html:/var/www/html -v /run/php:/run/php php84-cve:8.4.27
mask on top of stop so that the system FPM does not start after a reboot and take the socket back. --group-add 1000 came from the host, where www-data is also in the deploy user's group (1000). In the container it reaches only the FPM master: workers call initgroups() before switching to www-data and take their groups from /etc/group inside the container. I checked afterwards, and the pool workers have only group 33. Writes still work for now because in all four applications storage/ belongs to group www-data and is group-writable (www-data is uid and gid 33 in both the Debian image and Ubuntu); I did not go through every file deeper down. rollback removes the container, unmasks and starts the system FPM. The sites went through the container from about 20:02 UTC, and the nginx error log stayed clean.
Queue workers, cron and Reverb stayed on the system CLI 8.4.5. Reverb is a CLI process that sits behind nginx and handles WebSocket traffic from the network, so this was a bet for one night that the advisory's list of affected SAPIs is complete. The upgrade the next morning was going to replace that PHP anyway.
The next morning: do-release-upgrade
The container fixed PHP for the websites. The rest of the system still received no updates at all, so the next morning I did the release upgrade. do-release-upgrade -c on 25.04 offered "New release '26.04.1 LTS' available" directly; 25.10 is out of support as well.
Before starting: a VPS snapshot in the OVH panel, dumps of MySQL and Postgres, a copy of /etc and of the crontabs, and 2 GB of swap that the server had never had. I also wanted to keep Postgres 17 and PHP 8.4 and clean them up by hand later, so I added /etc/update-manager/release-upgrades.d/zz-keep-obsoletes.cfg (the upgrader reads only *.cfg files there):
[Distro]
RemoveObsoletes=False
The upgrade ran in screen, non-interactively, with the log written to a file:
DEBIAN_FRONTEND=noninteractive do-release-upgrade -f DistUpgradeViewNonInteractive > /root/upgrade.log 2>&1
It took from 08:30 to 08:46 UTC, plus a reboot. Three things from the log:
- Three minutes in,
sudowas gone. Bothsudoandsudo-rswere unpacked but not yet configured, and/usr/bin/sudodid not exist. It came back once dpkg got to them. - From about 08:35 to 08:42 SSH refused connections. During that window MySQL was down, most likely because its package was being configured, and one application answered 500. The home pages of the other three answered 200.
- Postgres 17 stayed, as intended. The PHP 8.4 packages were removed anyway, despite
RemoveObsoletes=False. The queue workers in supervisor ended up inFATAL can't find command 'php'.
The workers were an easy fix. PHP 8.5.4 from 26.04 was already there and needed the pgsql, redis, gd and igbinary extensions; after installing them I started the workers again with supervisorctl, and after the reboot they came up on their own. The applications declare "php": "^8.3" (one of them ^8.2), and artisan about ran cleanly on 8.5.4. The websites still go through the 8.4.27 container, whose image the upgrade did not change.
The server now runs a supported Ubuntu, but it is not finished. Postgres 17 is a leftover package from 25.04 that Ubuntu will never update again, so it needs either a migration to 18 or packages from the PostgreSQL project's own repository. And the container is one more thing to patch by hand: the next PHP fix means bumping the FROM line and running build, test and switch again. rollback no longer has anything to fall back to, because the host has no php8.4-fpm package after the upgrade. Whether it stays as a bridge or becomes the way this server runs PHP for good, I have not decided yet.