On 2026-10-21 the PHP project shipped an out-of-band release fixing a pair of vulnerabilities that quickly got the nickname “PHP2Shell”: CVE-2026-109618 and CVE-2026-109619, CVSS 9.2, unauthenticated remote code execution. Both are use-after-free bugs (CWE-416). One is an out-of-bounds, out-of-memory corruption during multipart request parsing. The other is stale per-request state that gets reused across php-fpm and mod_php workers. Either one lets an unauthenticated attacker who can reach a PHP endpoint corrupt memory in a way that leads to code execution. The fixed versions are PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35.

The boring read of that advisory is “run apt upgrade and move on.” The interesting part, and the reason this post exists, is that when we went looking for PHP across the fleet, it was hiding in four very different places. A distro package on a mail server. A compiled-from-source binary inside a container image. The web GUI of a pair of firewalls. And the web GUI of a single edge firewall on an older release train. Each of those needed a genuinely different remediation, and each one had a trap that would have let us declare victory while still running a vulnerable interpreter.

This is the write-up of how we found all of it, how we proved each box was actually fixed rather than trusting a version string, and the HA dance that let us patch a production firewall pair with zero WAN outage.

Why a version-number scan is not enough

The instinct on a day like this is to run something that collects php -v from every host and flag anything below the fixed version. That scan lies in both directions, and both directions bit us:

  • A patched distro package looks vulnerable. Debian and Ubuntu backport security fixes without bumping the upstream version string. A fully patched box keeps reporting the same 8.3.x forever. A naive scan flags it as unpatched and sends you chasing a non-problem.
  • A vulnerable container or appliance looks fine. A container image that bundles its own PHP has no distro package to read, so dpkg-based checks see nothing at all. A firewall GUI is PHP too, but it never shows up in a “list my servers” inventory.

So the two questions for the day became: where does PHP actually live, and for each place, what is the authoritative way to tell whether it is fixed? The rest of this post is four answers to that pair of questions.

Act 1: the mail server, and why php -v lies

Our mail server runs PHP from the Ubuntu archive (the webmail and admin UI need it). Here is what it reported, both before and after patching:

bash
$ php -v
PHP 8.3.6 (cli) (built: ...) ( NTS )

That string never changes. Ubuntu’s security team backports the fix into the existing 8.3.6 package and bumps only the Debian revision (the part after the dash, like 8.3.6-0ubuntu0.24.04.x). The upstream version PHP prints has nothing to say about whether the CVE patch is present. If you scan on PHP_VERSION you will conclude this box is vulnerable long after it is fixed, and conversely you would have believed it was safe the day before the patch landed.

There are two reliable ways to tell on a Debian or Ubuntu host.

Read the package changelog. The security upload lists the CVE it closes:

bash
# Installed revision, including the Debian/Ubuntu part the CVE fix bumps
dpkg-query -W -f='${Version}\n' php8.3-cli

# The changelog entry names the CVE explicitly
zcat /usr/share/doc/php8.3-common/changelog.Debian.gz | head -n 40
# ...
#   * SECURITY UPDATE: use-after-free in multipart parsing / request state
#     - CVE-2026-109618, CVE-2026-109619

Ask the distro’s own oracle. On Ubuntu, Ubuntu Pro knows the mapping from a CVE to the package revision that resolves it, and will tell you whether the running system is already covered:

bash
$ sudo pro fix --dry-run CVE-2026-109618
CVE-2026-109618: PHP vulnerabilities
...
1 affected source package is installed: php8.3
(1/1) php8.3:
A fix is available in Ubuntu standard updates.
The update is already installed.

✔ CVE-2026-109618 is resolved.

“The update is already installed” is the answer you want, and it is authoritative in a way php -v can never be. We ran the normal unattended-upgrade path, confirmed the revision bumped, and pro fix --dry-run flipped both CVEs to resolved. Then php-fpm and the webserver were restarted so running workers picked up the new shared library (a patched package on disk does nothing for a worker that is still mapped to the old code).

That is the whole fix for the mail server. The lesson is the detection, not the remediation: on a backported distro, the version number is the wrong thing to look at, and the distro ships you a tool that looks at the right thing.

Act 2: proving it is really fixed, for the skeptics

“The changelog says so” and “Pro says resolved” are strong, but we did not want to trust any version string, including the ones that said we were done. We wanted the box to demonstrate the bug was gone. You can do that without ever firing an exploit at production, because the vendor ships the proof with the patch.

PHP’s own regression tests for a security fix are .phpt files, and distro security uploads bundle them in the patched source package. They are named after the advisories, for example:

tests/basic/GHSA-f883-x959-3vr9_001.phpt
tests/basic/GHSA-f883-x959-3vr9_002.phpt

These are the exact tests upstream wrote to prove the fix, and they run under PHP’s own run-tests.php harness. The plan:

  1. Pull the patched source package and extract the advisory tests.
  2. Run them against the patched binary on the mail server. They should all pass.
  3. Run the same tests against the previous, vulnerable build in a throwaway container. At least one should fail in a way that is obviously the bug.

Extract the tests from the patched source:

bash
# On a scratch box, fetch the source matching the installed binary package
mkdir -p /tmp/php-src && cd /tmp/php-src
apt-get source php8.3

# The security patch adds the advisory regression tests under tests/
find . -path '*tests*' -name 'GHSA-*.phpt'

Run them against the patched interpreter using PHP’s own harness:

bash
# TEST_PHP_EXECUTABLE points the harness at the binary under test
TEST_PHP_EXECUTABLE=$(command -v php) \
  php run-tests.php -q tests/basic/GHSA-f883-x959-3vr9_*.phpt

# =====================================================================
# PASS ... GHSA-f883-x959-3vr9_001.phpt
# PASS ... GHSA-f883-x959-3vr9_002.phpt
# ---------------------------------------------------------------------
# Tests passed : 2 (100.0%)

Run the same tests against the old, vulnerable build in a disposable container, so you can see them fail. Pick an image tag that predates the fix:

bash
docker run --rm -it -v /tmp/php-src:/src php:8.3.6-cli bash
cd /src
TEST_PHP_EXECUTABLE=$(command -v php) \
  php run-tests.php -q tests/basic/GHSA-f883-x959-3vr9_*.phpt

# =====================================================================
# FAIL ... GHSA-f883-x959-3vr9_001.phpt
#   Fatal error: Allowed memory size of N bytes exhausted ...
#     (the multipart parser walks off the end and the OOM guard trips)
# PASS ... GHSA-f883-x959-3vr9_002.phpt

On the old build, the multipart-parser test dies with “Allowed memory size exhausted,” which is exactly the out-of-bounds memory-corruption behavior the advisory describes, caught here by the memory limit instead of turning into something worse. On the patched build the same input is parsed cleanly and the test passes. No exploit, no payload, no guessing. The vendor regression tests are the proof, and they are reproducible by anyone.

This is worth internalizing beyond this one CVE: for most memory-safety fixes in a mature project, the upstream regression test is a better acceptance test than any scanner, because it exercises the exact code path the advisory is about.

Act 3: the sweep, because PHP hides where a host scan cannot see

The real risk in CVE response is not fumbling the fix on a box you know about. It is missing a box entirely. So the sweep has to enumerate authoritatively, from systems that actually know what is running, rather than from memory or from a hand-maintained list. Three sources covered the fleet:

  • The cloud provider’s instance list for anything running outside the lab.
  • Kubernetes pod images for anything containerized, enumerated straight from the API.
  • The SSH inventory for bare metal and VMs.

The Kubernetes sweep is a one-liner that lists every running image so you can eyeball which ones are PHP apps:

bash
kubectl get pods -A \
  -o jsonpath='{range .items[*]}{.metadata.namespace}{"\t"}{.spec.containers[*].image}{"\n"}{end}' \
  | sort -u

That turned up two PHP surfaces a VM-only scan would have missed entirely.

Finding 1: a container that bundles its own PHP

Our Nextcloud instance runs the official image, which compiles PHP and installs it under /usr/local, not from the distro. That has a sharp consequence for detection:

bash
# Inside the Nextcloud pod: apt knows nothing about this PHP
$ kubectl exec -n <ns> deploy/nextcloud -- dpkg -l | grep -i php
# (no output)

# The interpreter is right there, it just is not a package
$ kubectl exec -n <ns> deploy/nextcloud -- php -r 'echo PHP_VERSION . "\n";'
8.5.8

dpkg -l | grep php is empty inside the container, so every apt-based or package-manifest check sees nothing and reports the container clean. The only honest check is to ask the interpreter itself with php -r 'echo PHP_VERSION;'. It came back 8.5.8, which is below 8.5.12: vulnerable.

The fix for a bundled-PHP container is not to patch in place. It is to roll the image to the rebuilt one that ships the fixed interpreter. A fresh container is a fresh binary, so there is no separate “restart php-fpm” step: when the new pod starts, it is already running patched PHP. We moved to the image built on PHP 8.5.12 and re-ran the php -r check in the new pod to confirm.

There is a Helm trap here worth calling out, because it is the kind of thing that silently ships the wrong image. The chart passed the image tag through verbatim, and its flavor key (apache vs fpm) only feeds the chart’s default tag. The moment you pin image.tag yourself, flavor stops influencing the tag at all. So this looks right and is wrong:

yaml
image:
  flavor: apache
  tag: "34.0.5"      # WRONG: pulls nextcloud:34.0.5, the fpm-less default
                     # variant, not the apache image this deployment runs

You have to pin the full tag, suffix included:

yaml
image:
  tag: "34.0.5-apache"   # correct: the apache variant on PHP 8.5.12

Pinning 34.0.5 instead of 34.0.5-apache does not fail loudly. It pulls a different image that happens to exist, and you find out when the container behaves differently than the one it replaced. Always pin the exact tag the deployment actually runs, suffix and all, and verify the running version from inside the new pod rather than trusting that the tag you typed is the image you got.

Finding 2: firewalls run PHP too

The other thing the sweep made obvious: the firewall web GUIs are PHP applications. pfSense’s entire management interface is PHP running on FreeBSD. It is easy to forget because you never think of a firewall as “a PHP host,” and its exposure is low (the GUI is bound to the admin LAN, not the internet). But low exposure is not no exposure, and a vulnerable interpreter is a vulnerable interpreter. Any box whose management plane is PHP belongs on the list. That realization is what turned a three-host afternoon into Act 4.

Act 4: firewalls, two different fix paths, and an HA dance

This was the meatiest part of the day, because the firewalls split into two completely different remediation paths, and the production path had to happen with no WAN outage.

The colo HA pair: an up-to-date train still needs a package update

Our colo firewalls are an HA CARP pair, already on the latest release train. The GUI updater said “up to date” and offered no firmware update. That is a trap: on an up-to-date train, the vendor’s fix for a bundled library like PHP is not a point release, it is a package update from the repo. The updater’s “up to date” refers to the base firmware image, not the packages layered on top. The fix has to be driven from an SSH or console shell; it cannot be done from the GUI:

sh
# On the firewall shell, not the GUI
pfSense-upgrade -c                         # refresh the package catalog
pkg upgrade -y                             # pull patched packages incl. PHP
pkg upgrade -fy pfSense-pkg-WireGuard      # force-reinstall the WG package
/etc/rc.reboot                             # reboot into the patched system

Trap 1: that WireGuard force-reinstall drops the tunnel you are managing over. The pair is reached over a WireGuard tunnel, and force-reinstalling pfSense-pkg-WireGuard tears the interface down and back up. If you run that chain interactively from an SSH session that rides the very tunnel being reinstalled, your shell dies in the middle of the command and you are left guessing whether the reboot ever fired. The fix is to run the whole chain detached so it completes and reboots without you, then reconnect afterward to verify:

sh
# Detach so the chain survives the WireGuard bounce and reboots on its own
nohup sh -c 'pfSense-upgrade -c && pkg upgrade -y && \
  pkg upgrade -fy pfSense-pkg-WireGuard && /etc/rc.reboot' \
  >/tmp/php-patch.log 2>&1 &

Then wait for the box to drop and come back, reconnect, and confirm the PHP package revision and that the GUI is serving again.

The HA runbook: redundancy is the rollback

With a CARP pair, you never have to take a WAN outage to patch, and you get a free safety property: patch the node that is not carrying traffic first, so if it comes back wrong, nothing moved.

  1. Patch the backup node first. It holds no VIPs, so rebooting it has zero traffic impact. Confirm it comes back healthy, GUI up, PHP patched, CARP re-syncing.
  2. Fail traffic over to the freshly patched backup. Put the current master into CARP maintenance so the backup takes the VIPs.
  3. Patch the former master (now carrying nothing). Reboot, verify.
  4. Fail back by taking the node out of maintenance.

The supported way to drive CARP failover and failback from the CLI is the maintenance-mode playback, not calling the internal function by hand:

sh
# Enter CARP maintenance: this node demotes, the partner takes the VIPs
pfSsh.php playback enablecarpmaint

# Leave maintenance: this node resumes mastering the VIPs it should own
pfSsh.php playback disablecarpmaint

Worth noting for anyone tempted to shortcut it: invoking the internal maintenance-mode function directly through the PHP developer shell did not work the way the playback does. Use pfSsh.php playback enablecarpmaint / disablecarpmaint. Because the partner always held the VIPs during each node’s reboot, the whole operation was a zero-outage WAN event.

Trap 2: a long CARP advertisement base makes failover look broken. Two of the VLAN CARP VIPs were configured with a long advertisement base (advbase 20), so on failover their virtual IPs took roughly 60 seconds to converge on the new master. If you fail over and immediately check those VIPs, they look master-less and it is easy to panic and start “fixing” something that is simply mid-convergence. Do not panic. Wait out the advertisement interval and re-check before concluding anything is wrong. (If you want faster failover on those VLANs in steady state, that is a separate change to the advbase, not something to tune in the middle of a patch window.)

The edge firewall: a self-severing major upgrade

The home edge firewall was a different animal. It was on an older release train, and that train had no package path to the PHP fix. The only way to get patched PHP there was a full major-version firmware upgrade (first to the current train, then later up again for parity with the colo pair), and a firmware upgrade reboots the box.

Two things make that genuinely dangerous in a way the colo pair was not:

  • It is a single box, and it is the site’s only gateway. There is no partner to hold the VIPs. Rebooting it takes the whole site’s internet down for the duration.
  • The upgrade is self-severing. You are reaching the firewall through the firewall. The moment it reboots, you lose the connection you are working over, and so does any management jump host that sits behind it. You cannot watch the upgrade finish from where you started it.

That combination means this is strictly an on-site or console-standby job, never a “kick it off over SSH from the couch” job. Before starting we took a full config backup and confirmed a known-good boot environment to roll back to if the new firmware came up wrong. Then we did it with an operator physically at the console, who could watch the boot, catch a bad boot environment, and roll back without needing the network that the upgrade was busy tearing down. It came up clean on the new train, PHP patched, and a later window brought it up again to match the colo train.

The closing lessons

Five things from this one carried further than the CVE itself.

  1. Version-number scanners lie in both directions. A backported distro package reports the old upstream version forever and looks vulnerable when it is patched. An appliance or a container that bundles its own interpreter looks fine when it is not. Verify with the vendor’s own oracle (pro fix --dry-run, the package changelog) and, when you want certainty, with the upstream regression tests. The version string is the least trustworthy signal you have.

  2. Know where all your PHP lives. It is not just the obvious web VMs. It is distro packages, container images that compile it under /usr/local where apt cannot see it, and appliance GUIs like a firewall’s management interface. A sweep that only checks VMs missed two-thirds of our PHP. Enumerate from systems that actually know what is running: the cloud instance list, the Kubernetes API, the SSH inventory.

  3. For HA pairs, redundancy is your rollback. Patch the backup, fail over, patch the primary, fail back. A node that fails to boot never causes an outage because it was never carrying traffic when you rebooted it. You get to validate the new build on live-but-standby hardware before it touches production flows.

  4. Beware self-severing upgrades. When the box you are patching is the path you are reaching it through (a single gateway, the firewall you are SSH’d through, a tunnel you are about to reinstall), run the operation detached so it survives losing your session, and have out-of-band or console recovery ready before you start. “I’ll just watch it over SSH” is how you end up with a half-upgraded box and no way back in.

  5. “Patch” meant four different operations for one CVE. A backported package revision plus a service restart on the mail server. A container image roll on Nextcloud. A pkg upgrade from a shell on an up-to-date firewall train. And a full firmware major-version upgrade on an older one. Same vulnerability, same fixed upstream version, four genuinely different remediations. Plan CVE response around where the code runs, not around a single update command you hope applies everywhere.

The whole fleet landed on fixed PHP (8.3.36 on the distro mail server, 8.5.12 in the Nextcloud image, and the patched packages or firmware on the firewalls) with zero WAN outage on the production pair, and every box was verified by something stronger than a version string before we called it done.