TL;DR
  • Build your own docker images from the upstream source to avoid long wait times.
  • Regularly test your playbooks to deploy package updates & config changes estate wide.
  • Keep an accurate, detailed, up-to-date SBOM of your entire estate.
  • Have playbooks to contain your stacks at various levels, with custom error pages.

Last updated: 23rd October 2026

At 20:50 on Tuesday 20th October, php2shell first appeared on my radar, when Scott Robinson dropped a link into the Magento Ecom Slack.

An embargoed PHP core vulnerability had leaked. Florian Roth was calling it an RCE, CVSS 10.0, with “only PHP 8 will receive patches”. watchTowr confirmed it was real. At 22:32 Willem de Groot from Sansec shared Kirill Firsov’s post with the name everyone ended up using: php2shell.

The official version, once php.net published it: two critical vulnerabilities, CVE-2026-109618 (OOM corruption) and CVE-2026-109619 (stale global state), CVSS v4.0 9.2, fixed releases at 15:00 BST on the 21st. No affected versions and no fixed versions until then. So we had about 17 hours to get ready for a patch we could not see yet.

I look after two very different estates. At work, an agency fleet of a few dozen native Ubuntu servers running nginx and PHP-FPM, supported by Ansible. Outside of work, Docker Swarm, a legacy Docker box, and Kubernetes clusters, and a few legacy/life support Ubuntu 20 (Focal) VMs with PHP 7.4.

Neither has a security team or even an on-call rota. It is just me and my laptop having a jolly good time. This is the order we did things in, with the actual commands, so if your setup looks like either of those you can steal the bits that worked and skip the bits that did not.

Act on the rumour, but call it hardening

At 22:37 Willem posted the rumoured quick fix:

session.upload_progress.enabled = 0
session.use_strict_mode = 1

Nothing official backed it up. But, after a quick bit of research, both settings seem’d like sensible defaults on most stores anyway. (upload progress tracking is rarely used, and strict mode rejects session IDs the server never issued), so the downside of applying them was close to zero. I replied at 23:25 that I had rolled it across the fleet.

The mitigation lives in one small ini file, so the same file can go to native servers and containers:

; CVE-2026-109618 mitigation
; priority=99
session.upload_progress.enabled = 0
session.use_strict_mode = 1

On Swarm it went in as a Docker config, php-session-hardening-v1, mounted into every PHP container at /usr/local/etc/php/conf.d/zz-session-hardening.ini. On Debian style servers, phpenmod drops it into each version’s cli and fpm conf.d with that priority.

The zz- prefix and priority=99 do the same job. PHP loads conf.d alphabetically and the last value wins.

Then we checked it with real requests through FastCGI, not php -i on the CLI (which reads a different ini). Every one of the production FPM replicas reported 0 and 1, kept a session across two requests, rejected a made up session ID, and still accepted a multipart upload. Before the change, two of them read 1 and 0, which is the positive control. Without that, a check that always prints the right answer looks exactly like a working one.

The Ansible version for native servers has one detail worth copying. It only reloads FPM pools that are already running:

# state: reloaded starts stopped units, which would bring idle EOL FPM pools back up.
- name: Reload running PHP-FPM
  ansible.builtin.service:
    name: "php{{ item.split('/')[3] }}-fpm"
    state: reloaded
  loop: "{{ php_sapi_dirs | select('search', '/fpm/') | list }}"
  when: ansible_facts.services.get('php' ~ item.split('/')[3] ~ '-fpm.service', {}).get('state') == 'running'

If you have an old php7.4-fpm you stopped years ago, a blanket state: reloaded will start it again. Mid incident. On the version you are most worried about.

Find every PHP you run

You cannot patch what you have forgotten exists, and scrambling around making sure you got everything is not a fun time.

The night before, we ran a read-only audit across all of our platform accounts. SBOM artefacts, Native packages, containers, and sleeping Kubernetes workloads that only spin up on demand. For each host it recorded every installed PHP branch, the full package revision, the package candidate, which FPM services were running, and which branches nginx and cron actually pointed at.

The important bit is the positive control. Before trusting “no PHP here”, the same probe had to find bash on that host. A probe that silently fails over SSH returns exactly the same empty result as a host with no PHP.

Work out where your patch is coming from

This is the section I would have most liked to read at 23:00 on the 20th. “Patch when it is available” assumes something will make it available to you. For a lot of servers, nothing will.

Ondřej’s PPA or packages.sury.org. Builds land on release day, often within minutes. But your box will not install them by itself. unattended-upgrades only installs from the origins it is told about, and the PPA is not one of them by default:

grep -A8 'Allowed-Origins' /etc/apt/apt.conf.d/50unattended-upgrades

If you only see ${distro_id}:${distro_codename}-security and friends, your PHP has never been auto-patched. Ours had not either.

Ubuntu’s own archive (php8.1 on 22.04, php8.3 on 24.04). Patched by Canonical, after a USN. Usually days, not hours.

Ubuntu 20.04 with the PPA. Nothing. The PPA’s focal index has been empty since 9th June 2025. Four of our servers were in that state and still showed PHP 8.2.28 and 8.2.27 as “latest”. Those packages were never going to update again, release day or not.

A stale package index. Three servers had not refreshed the PPA since 25th April 2025. apt-cache policy showed a candidate of 8.2.28 when the PPA had 8.2.34. No error, no warning, just quietly old:

apt-cache policy php8.2-fpm

Read the candidate line, then compare it with what the repo actually has. If they disagree after an apt-get update, find out why before the day, not during it.

Official Docker images. Built by the docker-library maintainers after the release, so hours behind. Alpine’s own php84 packages run on a separate schedule again.

Write that list down for your own estate, one line per install method. It tells you which servers you can patch at 15:00, which ones you are waiting on someone else for, and which ones you need a different plan for tonight.

Get one revision behind before the day

The single best thing we did was boring. On the morning of the 21st, before anything was released, we updated every agency server to the newest PHP build that already existed.

By bumping them to the latest patch version before hand, it gives you a great opportunity to test your playbooks and spot any potentially issues / conflicts that may occur.

The legacy hosts that were still stuck on Ubuntu 20.04 got a do-release-upgrade to 22.04 the same morning, which is absolutely not something to discover at 15:01.

Your initial instinct may be todo a apt upgrade php*, which is often a trap. The global can match packages you do not have installed, bump additional unrelated dependencies, pull in different versions etc. I prefer, to read exactally what is already installed, then explicitly bump those packages.

- name: Update installed PHP packages
  hosts: php_ppa:php_ubuntu
  become: true
  serial: "{{ rollout_batch | default(1) }}"
  any_errors_fatal: true
  tasks:
    - name: Read installed packages
      ansible.builtin.package_facts:
        manager: apt

    - name: Select installed PHP packages
      ansible.builtin.set_fact:
        php_update_packages: "{{ ansible_facts.packages.keys() | select('match', '^(php[0-9]+\\.[0-9]+($|-)|libapache2-mod-php[0-9]+\\.[0-9]+$)') | sort }}"

    - name: Upgrade installed PHP packages only
      ansible.builtin.apt:
        name: "{{ php_update_packages }}"
        state: latest
        only_upgrade: true
        dpkg_options: force-confold,force-confdef
        fail_on_autoremove: true
        install_recommends: false

The rest of the playbook validates each running pool with php-fpm -t before restarting it, and writes a before and after JSON file per host to reports/php-updates/. Those files are where every timing in this post comes from.

Have a way to take sites down, and a page to show

With an RCE rumoured and no patch available, there is a fair question: do you just turn PHP off until it lands? On the work fleet we never pulled the switch, although, we had it ready and tested. On my own stack I did, because unfortunately I would not be able to get around to that until after work, which would be a good few hours.

Either way, write the off switch before you need it. Ours were different shapes for the two estates.

Native servers. Stop, disable and mask nginx, then check that nothing else is still serving PHP to the world:

- name: Mask Nginx until explicitly restored
  ansible.builtin.systemd_service:
    name: nginx
    masked: true

- name: Read public listeners
  ansible.builtin.command:
    argv: [ss, -Hltnp]
  register: containment_listeners
  changed_when: false
  check_mode: false

- name: Verify nothing else serves PHP publicly
  ansible.builtin.assert:
    that: php_public_listeners | length == 0
    fail_msg: "PHP is still reachable without Nginx, contain by hand: {{ php_public_listeners }}"

That listener check earned its keep before it ever ran. The audit had already found a old development box running php -S 0.0.0.0:8000 and Apache with mod_php alongside nginx. Stopping nginx there would have felt like containment and been nothing of the sort.

Masking (not just disabling) matters too. A disabled unit still starts if anything depends on it, or if someone runs a deploy that restarts nginx. Masked units refuse. There is a matching restore playbook that unmasks, runs nginx -t and starts it again. Neither ever ran. I am fine with that. (Although, we did test run them on the staging fleet).

Important to note: you won’t have a nice error page to show with the approach. But, given my workload, a pretty error page was bottom of the list.

Swarm. Turning containers off was easy. Showing visitors something better than a connection error was the actual work. The script:

  1. Saved every PHP service’s replica count and wrote a restore.sh before touching anything.
  2. Started a tiny nginx service returning a plain 503, and pointed a Traefik router at it with priority=1000000 for every affected hostname, so it beat every real router.
  3. Probed every hostname for the 503, and probed this site for a 200. Only then did it scale PHP to 0.
server {
  listen 80;
  default_type text/plain;
  add_header Cache-Control "no-store" always;
  add_header Retry-After "3600" always;
  location / {
    return 503 "Temporarily unavailable while we apply an urgent security update.
    We will restore service once the update has been verified. Thank you for your patience.

    More information: https://www.ncsc.gov.uk/news/critical-security-issue-affecting-php";
  }
}

The www.samdjames.uk 200 is the control. If Traefik had broken, every hostname would have “passed” the 503 check for the wrong reason.

Release day on native servers: under 30 minutes with Ansible

On the day we pointed the PPA servers at packages.sury.org (Ondřej’s own repository) instead of the Launchpad PPA. Simply because the packages landed there first (and that is the recommended place to grab Ondřej’s packages from nowadays https://codeberg.org/oerdnj/deb.sury.org/issues/73).

Then it was the same playbook as the morning, targeting one single staging server at the start. So we could validate there was not any issues, before we started opening the flood dates.

ansible-playbook -i inventories/php.yaml playbooks/php-update.yaml --limit acme-stg --check --diff
ansible-playbook -i inventories/php.yaml playbooks/php-update.yaml --limit acme-stg

Once that initial run complete, we validated the binary versions had changed and matched the expected outcome. The full Debian revision is worth recording, not just 8.3.36. For the older branches it is the only thing that tells you whether you have the backport. And started a full E2E test suite against the staging site in the background.

"php8.2-fpm": "8.2.34-1+ubuntu22.04.1+deb.sury.org+1" -> "8.2.35-1+0~20261021.94+ubuntu22.04~1.gbp8bff78"
"php8.3-fpm": "8.3.35-1+ubuntu22.04.1+deb.sury.org+1" -> "8.3.36-1+0~20261021.88+ubuntu22.04~1.gbp250f7c"

Then we opened the flood gates of Staging, and ran the playbook against all the staging hosts in one run. Once that complete, we have some of the hosts a quick spot check and critical path check (add to cart, checkout etc).

Then it was time for production, we ran the playbook against the production hosts in smaller batches, so we could terminate if things went sideways (Spoiler alert: they didn’t).

In hindsight, we could have ran the production rollout in a single / larger batches, and had the entire fleet patched in under 5/10 minutes if we needed. Although personally, I was happy with the more controlled rollout, based on threat/risk level at the time of release.

Restarting is the bit people forget

Installing a package replaces the file on disk. Every process that already had the old binary loaded keeps running it until it restarts. The playbook restarts the FPM pools it found running, then checks the FPM master is not still pointing at a deleted file:

- name: Verify running FPM masters use the installed binary
  ansible.builtin.shell:
    cmd: readlink /proc/$(systemctl show {{ item }} --property=MainPID --value)/exe
  register: php_fpm_exe
  changed_when: false
  failed_when: php_fpm_exe.rc != 0 or '(deleted)' in php_fpm_exe.stdout
  loop: "{{ php_running_fpm_services }}"

FPM is the easy one. The sneaky ones are long running CLI processes. Magento’s queue:consumers:start workers happily sit on the old binary for days. The playbook walks /proc, finds any PHP process whose exe ends in (deleted), kills the Magento consumers (cron starts them again on the new binary) and lists anything else for a human to deal with.

Run this on any box you have just patched. Anything it prints is still running the old PHP:

for p in $(pgrep -f php); do
  readlink /proc/$p/exe | grep -q '(deleted)' && echo "$p $(tr '\0' ' ' < /proc/$p/cmdline)"
done

Release day on containers: don’t wait for Docker Hub

The docker-library maintainers do a brilliant job, but they are people, and they had just been handed four PHP releases at once. Their pull request, docker-library/php#1694, landed at 14:44. At this point I was focused on our native installations with Ansible.

Although, I did setup Claude before hand to monitor and let me know once the docker images started landing. I got a notification from Claude a bit before 15:00, to let me know the PR had landed an that it may be a while before they become available based on past CI history.

But, it did suggest, we could build our own images, from the pull request and get those deployed much quicker. This is not “patching PHP from source” or Claude YOLO building. It is the official release tarball, built with the official Dockerfile via our own CI, a few hours before the official image exists.

name: Build patched PHP base

on:
  push:
    branches: [build/php-8.4.27-base]
  workflow_dispatch:

permissions:
  contents: read
  packages: write

jobs:
  base:
    runs-on: ubuntu-latest
    timeout-minutes: 40
    steps:
      - uses: actions/checkout@v4
        with:
          repository: docker-library/php
          ref: e3eaff845f7a9f7259c31da54dce0320b47a3bbd
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: 8.4/bookworm/fpm
          pull: true
          load: true
          tags: ghcr.io/samjuk/xxx/php:base-8.4.27-fpm-bookworm
      - name: Verify and publish the patched base
        run: |
          image=ghcr.io/samjuk/xxx/php:base-8.4.27-fpm-bookworm
          docker run --rm --entrypoint php "$image" -r '
            echo "PHP ", PHP_VERSION, PHP_EOL;
            exit(PHP_VERSION_ID === 80427 ? 0 : 1);
          '
          docker push "$image"

The Dockerfile checks the tarball’s checksum and GPG signature itself, which is the part that makes this safe to do at speed:

php.tar.xz: OK
gpg: Good signature from "Calvin Buckley (PHP) <[email protected]>"

The compile took 578.7 seconds. And the new image was deployed across the clusters a bit before 15:30. A solid 3-4 hours before the official images landed, which is significant in lost trading for ecommerce sites.

I was impressed with Claude, I approved a strategy from Claude and gave a quick code review of its suggested workflow / manifest changes.

Check what is actually running

A green deploy proves the deploy worked. It does not prove what PHP is running. After each restore, a small script docker exec’d into every replica of every PHP service and failed unless the binary itself said 8.4.27 and the image digest matched the one we had tested:

version = remote(host, ['docker', 'exec', container, 'php', '-r',
    'echo PHP_VERSION, PHP_EOL; exit(PHP_VERSION_ID === 80427 ? 0 : 1);']).strip()
assert version == '8.4.27', (service, version)

Check behaviour, not version numbers

Everything above checks a version number. That is fine for the upstream releases, where 8.4.27 means fixed. It falls apart for backports, where 8.1.34 can be fixed or not depending on the Debian revision tail, and for anything you built yourself.

So on the evening of the 21st we wrote a checker that asks PHP to misbehave and watches what it does. The fix for CVE-2026-109618 changes what happens when a script runs out of memory. Unpatched PHP still runs your shutdown functions after the OOM. Patched PHP stops hard before they get the chance.

The core of it is one line of PHP, run with an 8 MiB memory limit:

register_shutdown_function(function () { fwrite(STDERR, "SHUTDOWN-RAN\n"); });
$objects = array();
while (true) { $objects[] = new stdClass(); }

Run inside our CI image before and after the rebuild:

# our old CI image, PHP 8.4.26
$ bash check-php-security-fixes.sh
PHP 8.4.26 (php)
NOT PATCHED: PHP ran the shutdown handler after running out of memory.

# the rebuilt image, PHP 8.4.27
$ bash check-php-security-fixes.sh
PHP 8.4.27 (php)
PATCHED: PHP used the new hard memory limit and stopped safely.

Exit code 0 is patched, 1 is not, 2 is inconclusive. The whole script and how to run it are on the docs page, and the source is on GitHub.

What it does not do: it does not try to exploit / poc the vulnerability. All we are doing, is checking the new binary, has functionality that only belongs to the new releases.

What about PHP 7.4 and 8.1?

The leak said only PHP 8 would get patches. Upstream, it was stricter than that: releases for 8.2 and up only. Anything older gets nothing from php.net.

That is not the same as nothing at all, and this is the bit I would most like smaller teams to know. The distros backport.

  • Ondřej Surý’s packages shipped fixes on the day for 8.1 (8.1.34-9), 7.4 (7.4.33-31), and even 7.3 (7.3.33-35). Those landed on our agency servers in the same 15:34 batch as everything else. The upstream version number does not change on a backport, so check the package changelog for both CVE IDs.
  • Ubuntu fixed 8.1 on 22.04 as a normal security update. PHP 7.4 on Ubuntu 20.04 is fixed in 7.4.3-4ubuntu2.29+esm5, but only through Ubuntu Pro (ESM). Pro is free for personal use on up to five machines. Business use needs a paid subscription. See Ubuntu’s CVE page for the current state.
  • Freexian sell PHP LTS packages for older branches, if you need someone on the hook for it.

What we actually did on the agency side was neither of the Ubuntu options. The four 20.04 servers were release-upgraded to 22.04 that morning and moved onto the sury.org packages with everything else. Pro came up as the quick fix and never got attached.

And the obvious answer, “just move to 8.2”, is not free either. One site went over to 8.2 that morning and broke. Somebody has to own that compatibility work, and release day is the worst possible day to start it. Luckily, I was able to throw an Agent at it, and get another team member to perform the QA alongside E2E tests.

If you are still on 7.4 or 8.1, today is a good day to write down which of those routes you would take next time. There will be a next time.

For a few legacy / lifesupport projects running Ubuntu 20 (Focal), I installed the PHP7.4 updates via Ubuntu Pro ESM. Worked as expected, nothing to note here.

Bonus Tip: How to check at scale?

One box is a bash command. Twenty boxes is an Ansible task. This copies the checker to each host, runs it against every versioned PHP binary in /usr/bin, and fails the host if any of them is not patched or the result is inconclusive:

# tasks/healthchecks/security/php-oom-fix.yaml
- name: Copy the PHP2SHELL checker
  ansible.builtin.copy:
    src: files/check-php-security-fixes.sh
    dest: /usr/local/bin/check-php-security-fixes
    mode: "0755"

- name: Find installed PHP CLI binaries
  ansible.builtin.find:
    paths: /usr/bin
    patterns: "php[0-9].[0-9]"
    file_type: any
  register: php_binaries

- name: Require at least one PHP binary
  ansible.builtin.assert:
    that: php_binaries.matched > 0
    fail_msg: "No /usr/bin/phpX.Y found, check this host by hand"

- name: Check each PHP binary behaves patched
  ansible.builtin.command:
    argv: [/usr/local/bin/check-php-security-fixes, "{{ item.path }}"]
  loop: "{{ php_binaries.files }}"
  loop_control:
    label: "{{ item.path }}"
  register: php_fix_check
  changed_when: false
  failed_when: php_fix_check.rc != 0

The assert is there for the same reason as the bash control earlier. Without it, a host where the find matches nothing loops zero times and comes back green.

This checks the CLI binaries. FPM is the same build from the same package, but a running pool only picks it up after a restart, so pair it with the (deleted) check from the release day section.

What we got wrong

  • Waking everything at once. Rolling the hardening out just after midnight woke every sleeping Magento store on the Kubernetes staging/preview clusters together, and Longhorn’s storage fell over. “Just a config change” still goes one at a time.
  • Native error pages. Whilst disabling and masking Nginx is a great way to contain risk to the Native instances, this prevents us easily serving informative error pages. Instead this should be reserved for Nginx vulnerabilities. PHP-FPM ones can be contained by just disabling the vhosts, and/or replacing with the error page.

Conclusion

  • Apply a rumoured mitigation if it is harmless hardening anyway. Then prove it took with a positive control.
  • Know where every server’s PHP comes from before release day, and what software versions you are running.
  • Get to the newest published build the morning before, so release day is one revision.
  • Write the off switch and the holding page before you need them. Purge the CDN.
  • Waiting on upstream images builds can be slow, building the official recipe yourself is a fair way round that.
  • Restart everything that loaded PHP. Look for (deleted) binaries.
  • Check behaviour, not version strings, especially for backports.
  • 7.4 and 8.1 have backports: Ondřej’s packages, Ubuntu Pro, Freexian. Pick one before you need it.

References