php2shell: Patching a Critical PHP Flaw
Two leaked remote code execution flaws in PHP, getting a VPS ready to receive the fix, taking PHP offline behind a clean maintenance page, and installing the patch the moment it landed.
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 policysaid the newest version was installed, because apt had not looked in months. Check the date of the lists, or just runapt updatefirst. - 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
- PHP announcement: Upcoming Critical PHP Security Release
- php.net news archive 2026: the release notes of 8.2.35, 8.3.36, 8.4.27 and 8.5.12
- Help Net Security: A critical PHP security release is coming today
- Huntress: php2shell, leaked critical PHP vulnerabilities