The setup

A small personal website: Nginx and PHP-FPM on a Debian 13 VPS, mostly static pages with a few PHP pages and tools. Next to it, at home, a Raspberry Pi runs a development copy in a Docker container (the official php:8.2-apache image) and has PHP installed for command-line scripts. How Nginx hands requests to PHP-FPM is explained in FastCGI and PHP-FPM.

What happened

The PHP security team gave advance notice of a security release on Wednesday 21 October 2026 at 14:00 UTC (16:00 in the Netherlands), for two critical vulnerabilities, both rated 9.2 in CVSS version 4 (what that score means). The notice added that the release would come earlier if details became public or if there was evidence of active exploitation.

The details did become public: on 20 October they leaked before the embargo ended. A security firm confirmed that both are remote code execution flaws in the core of PHP, and security news reported the first attacks on the 21st. At 16:00 PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 came out, each fixing both flaws, with the words "can allow unauthenticated remote code execution". Every supported branch of PHP got a fix.

Timeline

All times are Dutch time (CEST, UTC+2), 21 October 2026.

Time Event
Day before Technical details of both flaws leak.
Early afternoon Inventory: PHP 8.4.16 on the VPS, 8.2.28 in the Docker container, 8.2.32 on the Pi (command line only).
Early afternoon The package lists on the VPS turn out to be months old. After apt update, other updates are waiting too.
By 14:53 Waiting updates installed (PHP to 8.4.26, OpenSSL, Nginx, a new kernel); reboot; services checked.
About 14:55 Reports of attacks; decision to stop PHP-FPM until the patch is out. PHP pages return 502.
Before 16:00 Nginx serves a static maintenance page with status 503 for every PHP page.
16:00 PHP releases the fixed versions.
16:09 8.4.27-1~deb13u1 is available from Debian's security repository.
About 16:15 Upgraded, PHP-FPM started, checks pass. The site is back.
Shortly after The Docker container is rebuilt: the official image only has 8.2.34 so far. Left as a follow-up, on the assumption that it was only reachable from the local network. That assumption turned out to be wrong: an old port forward still reached it. The forward has since been removed and the container updated.

Why it was serious

Remote code execution means an attacker can make the server run commands of their choosing. In a PHP setup that is about as bad as it gets, because of where PHP sits: every request for a .php page goes from Nginx straight into PHP-FPM, so a flaw in the core of PHP is reachable by anyone who can open the web page. No login, no special page, no plugin needed. And because PHP runs a large share of the web, a working attack can be pointed at millions of servers at once, which is why mass scanning starts within hours of a leak.

On Debian, PHP-FPM's workers run as the unprivileged user www-data by default, not as root. That limits the damage, but not enough: that user can read the site's files, including any API keys in its configuration, and it is a foothold for the next step.

Preparing: can the server receive the patch?

The first thing to check before a patch is whether the machine can receive it. On the VPS, apt reported the installed PHP as the newest version:

$ apt-cache policy php8.4-fpm
php8.4-fpm:
  Installed: 8.4.16-1~deb13u1
  Candidate: 8.4.16-1~deb13u1

That looked fine, but it was not. apt only knows what its package lists say, and those lists are only refreshed by apt update. The date of the files in /var/lib/apt/lists/ showed they were months old: nothing on the server refreshed them automatically. This is a very common situation on small servers that simply keep working. After sudo apt update, the candidate for PHP was 8.4.26, and updates for OpenSSL, Nginx and the kernel were waiting as well. The difference between update and upgrade is explained in update, upgrade and full-upgrade.

Those updates went in first, with sudo apt upgrade, keeping the local version of every configuration file when asked so the server's own Nginx rules stayed in place. The new kernel only takes effect after a reboot, so the server was rebooted and checked with uname -r, php -v, systemctl is-active php8.4-fpm nginx ssh and sudo nginx -t. Doing this before the release had a second benefit: at 16:00 only PHP would change, so if anything broke, the cause would be obvious.

Mitigation: taking PHP offline

8.4.26 did not contain the fix. With the details public, attacks reported and more than an hour to go, there were two options: stay online and hope, or switch off the part that was vulnerable. In vulnerability management that second option is a compensating control: something that lowers the risk until the real fix can be applied.

The whole server did not have to go down. Stopping only PHP-FPM leaves Nginx running: static pages, images and scripts keep working, and every .php request fails before any PHP code runs.

$ sudo systemctl stop php8.4-fpm
$ curl -s -o /dev/null -w '%{http_code}\n' https://example.org/
502

In a bigger organization, stopping a production service like this is an emergency change: done first, reviewed afterwards (see standard, normal and emergency changes).

A maintenance page instead of a 502

Visitors now saw Nginx's bare "502 Bad Gateway". That status is also the wrong message: 502 says the server behind the proxy is broken, while 503 Service Unavailable says the site is temporarily unavailable. Search engines treat a short 503 as temporary: they come back later instead of treating the pages as gone.

The page itself has to be plain HTML with its styles inline. It is shown precisely when PHP is not running, so it cannot depend on PHP. Two lines in the server block do the rest:

root /var/www/example.org;
error_page 502 503 504 =503 /offline.html;
location = /offline.html { internal; }

error_page replaces the error with the page, and =503 sets the status the visitor gets. internal means the page is only shown through such an error: requested directly, it gives 404. Before changing the configuration, the old one was copied as a backup, and sudo nginx -t && sudo systemctl reload nginx only reloads when the test passes.

$ for p in / /tools/ /offline.html; do
>   printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "https://example.org$p")" "$p"
> done
503 /
503 /tools/
404 /offline.html

The two lines stayed after the incident: any future failure of PHP-FPM now shows the same calm page. A Retry-After header could tell crawlers when to come back; for a short outage it was not worth the extra configuration.

The patch

When I checked at 16:09, nine minutes after the release, Debian's security repository already had the fixed package. Before installing, it is worth checking where a package comes from:

$ sudo apt update
$ apt-cache policy php8.4-fpm
php8.4-fpm:
  Installed: 8.4.26-1~deb13u1
  Candidate: 8.4.27-1~deb13u1
  Version table:
     8.4.27-1~deb13u1 500
        500 http://security.debian.org/debian-security trixie-security/main amd64 Packages
 *** 8.4.26-1~deb13u1 100
        100 /var/lib/dpkg/status

The candidate comes from trixie-security, the official security repository of Debian 13, signed by Debian's keys (see repositories and trust). In the version, 8.4.27 is PHP's release and ~deb13u1 means "the first update of this package for Debian 13". The build stamp says 07:53 that day, hours before the public release: distributions often receive security fixes in advance under embargo, so they can publish at the same moment.

Only the ten PHP packages were upgradable, so this time sudo apt upgrade changed nothing else and needed no reboot. Then PHP-FPM was started again.

Verification

"The upgrade finished" is not the same as "the server runs the fixed code". The command-line PHP and PHP-FPM are separate programs, and only the second one serves the website, so both were checked:

$ sudo systemctl start php8.4-fpm
$ php -v | head -1
$ php-fpm8.4 -v | head -1
$ systemctl is-active php8.4-fpm nginx
PHP 8.4.27 (cli) (built: Oct 21 2026 07:53:46) (NTS)
PHP 8.4.27 (fpm-fcgi) (built: Oct 21 2026 07:53:46) (NTS)
active
active

Then from outside: the PHP pages returned 200 again, and the deploy script's own check ran. It requests a list of paths that must be blocked (configuration files, .git folders, data files) and a list that must work. Everything passed, except one surprise: a tool that had been taken offline a week earlier was still reachable. It had been moved away in the local copy, but the server had not been synchronized since. Unrelated to PHP, and found only because the check tests the live site rather than the local files.

What I learned

  • "Up to date" depends on the package lists. apt-cache policy said the newest version was installed, because apt had not looked in months. Check the date of the lists, or just run apt update first.
  • Patching by memory does not work. A server that keeps working gives no reminder, so updates that depend on someone remembering them get forgotten. Security updates have to happen automatically.
  • Know every place the software runs. Three copies of PHP, from three sources (Debian 13, Debian 12 and a Docker image), each with its own update path and its own delay. The official Docker image, whose build stamp says 00:37 on the release day, still had 8.2.34: a rebuild is only as fresh as the image it pulls.
  • Switch off the vulnerable part, not everything. Stopping only PHP-FPM kept the static parts of the site online, and a static 503 page kept it honest towards visitors and search engines.
  • Verify from the outside. Version numbers, service status and real HTTP requests each catch something different, and the live check also found a problem nobody was looking for.

Follow-ups

Action items, in the sense of action items that get done:

Action Why Status
Install and enable unattended-upgrades on the VPS, then on the Pi Installs security updates daily, so a backlog like this cannot build up again Planned
Decide on automatic reboots (for example at 04:00) or a regular check of /var/run/reboot-required Kernel and C library updates only apply after a reboot Planned
Rebuild the Docker container with docker compose build --pull once the official image has 8.2.35 The development copy ran a vulnerable version Done (5 November 2026)
Upgrade PHP on the Pi when Debian 12 has the fix Command line only, but still a vulnerable copy Done
Remove the forgotten tool from the server with a deploy, dry run first Local changes are not production changes Planned
Keep the Nginx maintenance page Any future PHP-FPM failure shows a calm 503 Done

On Debian, unattended-upgrades is switched on with sudo apt install unattended-upgrades and sudo dpkg-reconfigure -plow unattended-upgrades, and a test run with sudo unattended-upgrade --dry-run --debug shows what it would do. By default it installs security updates. It cannot send mail on a server that blocks outgoing mail, so its log in /var/log/unattended-upgrades/ is the place to look.

Sources