PHP Nightmare #109618

php

On 21 October 2026 the PHP project shipped fixes for two bugs that let an unauthenticated attacker run code on more or less any PHP server that answers web requests. I spent the next morning going through my own servers. This is the checklist I ended up with, in the order I'd do it again.

It covers finding where PHP actually runs, patching it, proving the patch landed, and checking that nobody got in first.

What php2shell is

Two separate bugs in PHP itself, both rated 9.2 on CVSS v4.0. Someone named them php2shell, and the name stuck.

CVEProblem
CVE-2026-109618Out-of-memory corruption
CVE-2026-109619Stale request state

The first one hits when PHP runs out of memory halfway through setting up a request, and the cleanup afterwards trusts data it shouldn't. The second leaves bits of one request lying around after their memory is freed, so the next request on the same worker can pick them up.

The important part: this happens in PHP's request handling, before your application gets a say. So it doesn't matter whether you run WordPress, Nextcloud or a single index.php. Only the PHP binary counts. The PHP team says the risk is highest with Apache's mod_php, where there is a working exploit that fires before any application code runs.

BranchFixed in
8.58.5.12
8.48.4.27
8.38.3.36
8.28.2.35
8.1 and olderVendor backports only

One trap before you start comparing numbers. Distributions patch the version they already ship without changing the version string. Ubuntu 24.04's fixed PHP still calls itself 8.3.6. The fix is in the package revision, 8.3.6-0ubuntu0.24.04.13, so php -v alone can't tell you whether you're patched on a distro build.

1. Find every PHP you run

This took longer than the patching. The PHP on your host and the PHP inside your containers are separate installs, and updating one does nothing for the other. On both of my VPSes the host didn't have PHP at all. Every copy lived in a container.

dpkg -l 'php*' 2>/dev/null | awk '/^ii/{print $2, $3}'

systemctl list-units --type=service --state=running | grep -Ei 'php|apache|nginx|litespeed'

for c in $(docker ps -q); do
  printf '%s: ' "$(docker inspect -f '{{.Name}}' $c)"
  docker exec $c php -v 2>/dev/null | head -1 || echo "no php"
done

On shared hosting you can't patch PHP yourself anyway. Note which version each site uses (control panel, or an AddType line in .htaccess) and go read your host's status page.

2. Patch

Ubuntu or Debian host. Ubuntu had packages out on release day for 22.04, 24.04, 26.04 and 26.10. Upgrade, then restart whatever serves PHP. A reboot covers both.

sudo apt update
sudo apt install --only-upgrade '?name(^php[0-9.]+$)'

Containers. apt on the host won't touch them. Pull the image and check what's inside it before you recreate anything, because image maintainers don't always rebuild on day one.

docker pull nextcloud:34-apache
docker run --rm --entrypoint php nextcloud:34-apache -v | head -1

If the version is right, recreate the container. If it isn't, I'd rather stop the container for a few hours than leave an unpatched mod_php on the internet.

Shared hosting. Here the host does the work. Mine had put temporary mitigations in place the night before the release, including switching off multipart form handling. That breaks uploads until they lift it, so don't waste an evening debugging a broken upload form.

3. Check that it actually worked

Run php -v inside every container after the update. Nothing else counts.

I learned this the slightly embarrassing way. Portainer told me two containers were updated. They weren't. Both were still running images from weeks earlier, and I only noticed because I checked the PHP version by hand. For Portainer stacks, use the stack-level update with "Re-pull image and redeploy" turned on, not the per-container one.

The application's own version number doesn't help either. BookStack can report the same release before and after an image rebuild while the PHP underneath changes.

for c in $(docker ps -q); do
  docker exec $c php -v 2>/dev/null | head -1
done

When everything checks out, docker image prune gets rid of the old images so nothing starts from them by mistake.

4. Look for signs that someone got in

Anything that was reachable between the disclosure and your patch is worth a look. Cheapest checks first.

  1. Run docker diff before you recreate. It lists files added or changed in the container's writable layer, and once you recreate, that layer is gone for good.
  2. Search volumes, not the container. A freshly built image has yesterday's timestamp on every file, so searching it for recent PHP files returns a wall of noise. Your data lives on mounted volumes, so look there.
  3. Let the software check itself. Nextcloud has occ integrity:check-core, Matomo has a file integrity line under System Check, and WordPress has wp plugin verify-checksums --all.
  4. Read the access logs. Look for POST requests since the disclosure, from addresses you don't recognise. Then check whether the same address did the same thing before the disclosure. Mine had.
for m in $(docker inspect -f '{{range .Mounts}}{{.Source}} {{end}}' CONTAINER); do
  find "$m" -type f \( -name '*.php' -o -name '.htaccess' -o -name '.user.ini' \) \
    -newermt '2026-10-21 14:00' 2>/dev/null
done

One thing that briefly worried me: Nextcloud's integrity check complained about some 70 files in the bundled AWS SDK. The image carries a clean copy of Nextcloud in /usr/src/nextcloud, and a diff -rq against it showed the files were identical. The rebuilt image simply shipped a newer SDK than its signature file knew about. Annoying, not dangerous.

5. Tighten things while you're in there

None of this would have stopped php2shell, since it hits before any .htaccess rule is read. It does limit what the next bug can do.

Upload folders should never run code. One .htaccess in each upload folder covers everything below it.

<FilesMatch "\.(php|php[0-9]|phtml|phar|pht|pl|py|cgi|sh)$">
    Require all denied
</FilesMatch>
Options -Indexes
SoftwareFolders
WordPresswp-content/uploads
Piwigoupload, galleries, _data/i
PHP-Fusion 7images, downloads

Test it: put a one-line PHP file in the folder, request it, expect a 403, delete the file. Then load a real image and make sure it still comes back with a 200.

Only .php should run as PHP. Control panels like to write handler lines such as AddType application/x-lsphp83 .php5 .php4 .php .php3 .php2 .phtml. Every one of those extra extensions is a way past an upload filter that only blocks .php. Cut the list down to .php.

Lock admin panels to your own IP with a Require ip line. Check first which address the server actually sees, with a tiny test file that prints REMOTE_ADDR. A proxy or IPv6 will otherwise lock you out of your own site.

Clean out old copies. I found an old copy of a site still sitting in a webroot. Nobody maintains that kind of thing, and anyone can reach it.

What I'd tell myself next time

Keep a list of where PHP runs. Most of my morning went into finding things I should already have known about.

Treat "updated" as a claim until php -v backs it up.

On shared hosting, remember that all your sites usually run as one user. The oldest, least-loved site can write to the files of the important one. If you have something old and unmaintained, give it its own account or retire it.

And most scary-looking findings turn out to be nothing. I found an avatar called pic.php[496].jpg, which turned out to be an ordinary JPEG from 2021. Check, but compare against history before you raise the alarm.

Sources