php2shell: Your Host Is Patched, Your Docker Containers Probably Aren't
PHP shipped an emergency fix for CVE-2026-109618/109619. How I checked which of my servers were really patched, and why the Docker containers were not.
PHP pushed an emergency security release on October 21: 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Two critical remote code execution bugs in PHP core, CVE-2026-109618 and CVE-2026-109619, nicknamed "php2shell". No login needed, public exploit out before the patch. That's the kind of release where you don't wait for the next maintenance window.
So the morning after, I went through my servers. The hosts were fine. The containers were not. That gap is what this post is about.
TL;DR: Updating PHP on the host does nothing for PHP inside Docker containers. Every container ships its own PHP. Check them one by one with docker exec <name> php -r 'echo PHP_VERSION;', then pull or rebuild. And don't panic when Ubuntu says 8.3.6: distro packages backport the fix without bumping the upstream version, so read the changelog, not the version number.
Check 1: the host PHP
On a vendor build (control panel PHP, Sury PPA, compiled yourself) the version number tells you everything. Fixed means 8.3.36, 8.4.27 and so on:
for p in /usr/bin/php[0-9]* /opt/*/php/*/bin/php; do
[ -x "$p" ] && echo "$p: $($p -v | head -1)"
done
One of my boxes showed PHP 8.3.36 (built: Oct 21 2026) and 8.4.27. Good.
The other one is plain Ubuntu 24.04 and showed this:
/usr/bin/php8.3: PHP 8.3.6 (cli) (built: Oct 19 2026 15:16:58) (NTS)
8.3.6. Looks ancient. It isn't. Ubuntu freezes the upstream version for the life of the release and backports security fixes into its own package revision. The real version is the package one:
dpkg -l | grep -E '^ii +php[0-9.]+-(fpm|cli) '
zcat /usr/share/doc/php8.3-fpm/changelog.Debian.gz | head -10
php8.3 (8.3.6-0ubuntu0.24.04.13) noble-security; urgency=medium
* SECURITY UPDATE: Remote RCE via state corruption issue
- debian/patches/CVE-2026-109618_109619.patch: ...
There it is. CVE IDs in the changelog = patched, no matter what php -v says. If you only grep for "8.3.36" on Ubuntu or Debian you'll think you're vulnerable when you aren't. Mixing distro PHP with other PHP builds on one box makes this even more confusing, I wrote about that in multiple PHP versions on one server.
If you have unattended upgrades on for the security pocket, this one probably already landed by itself. Mine had.
Check 2: is the running process actually new?
A patched package on disk doesn't help if PHP-FPM is still running the old binary from before the update. The package scripts usually restart FPM, but check it instead of trusting it:
systemctl list-units --type=service --no-legend | grep -i php
systemctl show -p ActiveEnterTimestamp --value php8.3-fpm
The start time has to be after the package update. If it's older, systemctl restart php8.3-fpm.
For the public side, if you didn't turn off expose_php:
curl -sI https://example.com | grep -i x-powered-by
x-powered-by: PHP/8.3.36
That's the version actually serving requests right now, which is the only one that matters.
Check 3: the containers (this is where it went wrong)
Here's the thing people forget. A container doesn't use the host PHP. php:8.3-apache, wordpress:*, every PHP image brings its own interpreter, frozen at the moment the image was built. apt upgrade on the host doesn't touch it.
Quick loop over everything running:
for c in $(docker ps --format '{{.Names}}'); do
v=$(docker exec "$c" php -r 'echo PHP_VERSION;' 2>/dev/null) || continue
img=$(docker inspect -f '{{.Image}}' "$c")
echo "$c: $v (image built $(docker inspect -f '{{.Created}}' "$img" | cut -c1-10))"
done
My result on the box where the host was already fine:
invoiceplane-app-1: 8.3.35 (2026-10-20)
site-v3-wp: 8.3.31 (2026-05-20)
site-v2-wp: 8.3.31 (2026-05-20)
site-wp: 8.3.31 (2026-05-20)
Four containers with vulnerable PHP on a "patched" server. Three WordPress sites on an image from May, and a self-built invoicing app built literally one day before the fix.
The || continue skips containers without PHP (databases, proxies), so you don't get noise from them.
Fixing it: pulled images
For containers that run an official image straight from Docker Hub, it's pull and recreate. Only the app service, the database can stay up:
cd /opt/mysite
docker compose pull wp
docker compose up -d wp
docker exec site-wp php -r 'echo PHP_VERSION, PHP_EOL;'
Your site data lives in volumes or bind mounts, so recreating the container is safe. If it doesn't, you have a bigger problem than php2shell. Check docker inspect for the mounts before you recreate anything.
Gotcha: the tag you use might not be rebuilt yet
This one got me. My WordPress containers use wordpress:6-php8.3-apache. The morning after the release I pulled it and checked:
docker pull -q wordpress:6-php8.3-apache
docker run --rm wordpress:6-php8.3-apache php -r 'echo PHP_VERSION;'
# 8.3.31
Still old. But other tags of the same image were already rebuilt:
docker run --rm php:8.3-apache php -r 'echo PHP_VERSION;' # 8.3.36
docker run --rm wordpress:php8.3-apache php -r 'echo PHP_VERSION;' # 8.3.36
Official images get rebuilt tag by tag, not all at once. Don't assume "I pulled, so I'm patched". Always check the version inside after the pull. If your tag lags behind, you can switch to a tag that's already rebuilt or wait and pull again later. With the official WordPress image, switching tags only changes the PHP/Apache runtime: the WordPress files in your volume stay as they are, the entrypoint only copies WordPress in when the directory is empty.
Fixing it: self-built images
If you have build: . in your compose file with FROM php:8.3-apache, a plain docker compose build happily reuses the old cached base image. You need --pull:
cd /opt/myapp
docker compose build --pull app
docker compose up -d app
docker exec myapp-app-1 php -r 'echo PHP_VERSION, PHP_EOL;'
After the rebuild, check your extensions are still all there (php -m). If the app code is bind-mounted like mine, any local patches to the app survive. If the code is baked into the image, they don't, so know which one you have before you rebuild.
Gotcha: control panels and Docker
If the box runs a hosting panel next to Docker, a panel update or firewall reload can wipe Docker's iptables chains at the same time. Then the containers run but aren't reachable. I had that before on a KeyHelp box. After a big patch day, curl every site once from outside, don't just look at docker ps.
My checklist for the next one
There'll be a next one. This is what I'll run again:
- Host PHP:
php -v. On Debian or Ubuntu, read the package changelog and look for the CVE IDs. - FPM start time is after the update, otherwise restart it.
x-powered-byor aphpinfo()behind auth shows the live version.- Loop over every container,
php -r 'echo PHP_VERSION;'. - Pull or
build --pull, recreate, check the version inside again. - Curl every site from outside.
Steps 1 to 3 took me five minutes. Step 4 is the one that found the problem. Most guides only cover the host, and that's exactly where it's already fixed for you. The containers are the part nobody updates for you.
If you run your PHP apps behind Apache as a reverse proxy like I do, the Apache reverse proxy setup post shows the layout these containers sit in.