PHP2Shell: I Measured Every PHP Runtime I Manage. All of Them Were Vulnerable.
Why this vulnerability is not like the last ten
Most PHP “critical” advisories are application bugs wearing a framework’s name: a plugin, a CMS route, a library with a bad unserialize. They are bad, but they are patchable by updating one thing, and the blast radius is whatever that one thing serves.
PHP2Shell is in PHP itself. Two bugs — CVE-2026-109618 (an out-of-memory
error that leaves request state half-initialised, which PHP’s own cleanup
then treats as valid) and CVE-2026-109619 (stale global state that survives
the request that created it) — both CVSS v4.0 9.2, both allowing
unauthenticated remote code execution on a stock configuration. The Wiz
research team demonstrated pre-auth RCE against wordpress:latest,
drupal:latest and joomla:latest and separately showed a
memory-disclosure path that leaks other requests’ passwords and API keys
without any code execution at all.
Three details decided how I spent my day:
- It affects everything from PHP 5.4 to 8.5. Not “some versions”. A
“Hello World”
index.phpis enough for the RCE — your application’s code is irrelevant, so “my site is custom, nobody targets it” is not a mitigation. - It was exploited before it was disclosed. AWS’s threat intelligence observed PHP2Shell exploitation attempts in a honeypot on 17 October, four days before the advisory. Cloudflare reported attack traffic, including attempts to evade its protections, the same day the patches landed.
- Only patching fixes it. The circulating
session.upload_progress.enabled = 0tweak cuts off one exploitation path, not the class of bug. PHP’s own advisory says so; so does Datadog’s analysis. Treat it as a seatbelt, not a fix.
Fixed versions, released 21 October 2026: 8.2.35, 8.3.36, 8.4.27, 8.5.12. PHP 8.1 and older are end-of-life and will never get one.
I stopped guessing and measured
The infrastructure I manage spans a Synology DSM NAS, an unRaid box, an Ubuntu VM running CyberPanel/OpenLiteSpeed with eight production sites, and a VPS with Traefik, mailcow and six containerised PHP sites — roughly 170 containers in total.
My first instinct was to check the image tags and call it done. That is how
you get this wrong. php:8.3-fpm-alpine is a floating tag — it can hold any
8.3.x, and a container that has not been recreated keeps whatever it started
with. Docker’s tag is not a version. So I probed inside every candidate
runtime instead — php -v and php -i executed in the container or in the
host runtime’s own binary.
What came back:
| Host | Runtime | Measured version | Reachable from | Patched build existed? |
|---|---|---|---|---|
| VPS (203.0.113.10) | 6 × php:8.3-fpm-alpine | 8.3.33 | public (6 sites) | yes — 8.3.36 |
| VPS | mailcow phpfpm | 8.2.29 | public — its web UI | yes, inside mailcow 2026-10 |
| CyberPanel VM | lsphp84 (8 sites) | 8.4.25 | public (8 sites) | yes — 8.4.27 |
| CyberPanel VM | lsphp81 / lsphp82 / lsphp83 | 8.1.34 / 8.2.33 / 8.3.33 | unused / fallback | 8.1 never, others yes |
| DSM | Web Station PHP packages | 8.2.30 / 8.3.30 / 8.4.19 | LAN + tunnels | not yet — arrived 22 Oct, 19:45 |
| DSM | phpmyadmin:5.2.3 | 8.3.33 | behind SSO | yes — the same tag was rebuilt |
| DSM / unRaid | passbolt 5.15 / 5.14 | 8.4.24 | behind SSO / LAN | yes — 5.17.0 ships 8.4.27 |
| unRaid 7.1.4 | webGUI PHP | 8.3.19 | LAN only | no |
Every single row was vulnerable. That is not a coincidence — it is what a ten-year-old bug class looks like on a fleet that patches applications and not runtimes.
The table also produced the ranking. Two things decide the order of work: exposure (who can reach it) and whether a fix even exists. A public WordPress fleet on a pre-fix runtime comes first. An admin UI that only answers on the LAN comes last. And “no vendor fix exists” is a real state you have to name out loud rather than paper over with an update that doesn’t exist.
Step 1: one command closed it on eight public sites
The CyberPanel VM was the urgent one: eight public sites — five of them
WordPress — all sharing lsphp84 8.4.25.
The LiteSpeed repository already carried the fixed build, so the first move cost nothing in risk: a point upgrade inside the same 8.4 branch.
sudo apt update
sudo apt-get install --only-upgrade $(dpkg -l | awk '/^ii +lsphp84/{print $2}')
/usr/local/lsws/lsphp84/bin/lsphp -v # PHP 8.4.27 (litespeed)
sudo /usr/local/lsws/bin/lswsctrl restart
Two minutes later every one of those sites was on a patched runtime. I want to be explicit that this — not the 8.5 migration that follows — is what actually closed the hole. Everything after this was about ending up where I wanted to be, not about being safe.
Verifying it is the part people skip, and it is the part that matters. The
process name proves nothing: OpenLiteSpeed spawns each vhost’s PHP as a
child whose argv[0] is literally lsphp, so ps aux | grep lsphp shows
you eight identical processes and tells you nothing about which binary each
is running. Read the binary behind the PID instead, per site user:
ps -eo user:20,pid,args | grep '[l]sphp' | while read u p a; do
echo "$u -> $(readlink -f /proc/$p/exe)"
done | sort -u
client_wood -> /usr/local/lsws/lsphp84/bin/lsphp
hoele6451 -> /usr/local/lsws/lsphp84/bin/lsphp
app_portal -> /usr/local/lsws/lsphp84/bin/lsphp
...
Step 2: moving to 8.5.12, and the package that does not exist
With the CVE closed I could work at a civilised pace. The target was PHP 8.5.12 for everything that supports it, one branch of 8.3 kept as a fallback, and the two obsolete branches (8.1, 8.2) removed.
The install failed on the first attempt, in a way worth recording. I
mirrored the existing 8.4 package set by find-and-replace, which produced
lsphp85-opcache — and the whole apt run aborted:
E: Unable to locate package lsphp85-opcache
In PHP 8.5, OPcache is part of the base package (verify with
lsphp85 -i | grep 'Zend OPcache' → enabled), so the -opcache sub-package
simply does not exist for that branch in the LiteSpeed repo. Build the list
from the repository’s own inventory instead of from the previous branch:
apt-cache search '^lsphp85' | awk '{print $1}' | tr '\n' ' '
One more trap: lsphp -m prints the usage banner and exits, because the
LSAPI SAPI has no script to run. Use lsphp -i | grep …, or the sibling CLI
binary /usr/local/lsws/lsphp85/bin/php -m.
Where a site’s PHP version actually lives
This is the part that surprised me, and it is why I did not simply click the version dropdown. On this box, each vhost carries its own PHP handler — the site’s runtime is a line in the site’s own config file:
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
extprocessor <siteuser> {
type lsapi
address UDS://tmp/lshttpd/<siteuser>.sock
path /usr/local/lsws/lsphp85/bin/lsphp <- this line
...
}
The global extProcessor lsphp in httpd_config.conf — which still points
at lsphp83 — is only the fallback for the default vhost. Changing it does
nothing for your sites.
So the switch is a one-line edit, a config test, and a graceful restart:
cp -a /usr/local/lsws/conf/vhosts/<domain>/vhost.conf \
/root/vhost-backup/<domain>.vhost.conf.bak-$(date +%F-%H%M)
sed -i 's#/usr/local/lsws/lsphp[0-9]*/bin/lsphp#/usr/local/lsws/lsphp85/bin/lsphp#' \
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
/usr/local/lsws/bin/lshttpd -t # errors? stop here
/usr/local/lsws/bin/lswsctrl restart # graceful, ~2 s, all vhosts
The panel’s database is part of the runtime state too. CyberPanel keeps
phpSelection in cyberpanel.websiteFunctions_websites; leave it saying
PHP 8.4 while the vhost says 8.5 and the next panel-side save (or a
staging clone) will happily rewrite the vhost back to what the panel
believes:
UPDATE cyberpanel.websiteFunctions_websites
SET phpSelection = 'PHP 8.5'
WHERE domain IN (...);
I rolled it out one site at a time — a test WordPress install first, then a
low-traffic client site, then the rest, then my own site last. Rollback at
any point is the same sed in reverse plus a restart, five seconds.
Verify with bytes, not status codes
“200 OK” is a weak assertion. A PHP fatal in a page fragment, a plugin that silently stops rendering a block, a shortcode that returns an empty string — all of those still return 200. So before each switch I captured the rendered homepage and its byte count, and after each switch I required the same count:
curl -skL -o /dev/null -w '%{http_code} %{size_download}\n' https://<host>/
hoelee.com 200 227390 (before and after: identical)
client-wood.com 200 266192 (identical)
client-property.com.my 200 222780 (identical)
client-trading.com.my 200 134535 (identical)
test.hoelee.com 200 80869 (identical)
client-mail.com 200 44861 (identical)
app.client-portal.com 200 142762 (moved to 8.5.12 later — see below)
I also grep every response for Fatal error / Uncaught / Deprecated:,
and hit /wp-json/ — a 200 there means WordPress actually bootstrapped,
queried the database and serialised a response, which is a much stronger
signal than a cached homepage render. Zero errors across all of them.
(WordPress 6.9 and 7.x are documented as fully supporting PHP 8.5; the
residual risk is plugins, not core. My sites run 19–44 plugins each, which
is exactly why the byte-count check earned its keep.)
The bug the vulnerability found for me
While testing, I noticed /xmlrpc.php, /.env and /readme.html were
still returning 200 on one client site — in October 2026, on a vhost I had
deliberately hardened in September with eight deny contexts.
The cause was not the contexts. It was two stray } left in that vhost file
by my own earlier edit. A vhost.conf is the body of a virtualHost
block — the outer braces live in httpd_config.conf — so those two braces
closed nothing, and every context appended after them sat outside the
vhost and registered nowhere. The config parser had been saying so on every
single run:
[ERROR] [PlainConf] [virtualHostConfig:] parseline found more '}' in
/usr/local/lsws/conf/vhosts/client-trading.com.my/vhost.conf:80
I had read that line as noise for weeks. It was the whole diagnosis. After removing the two braces and reloading, the same three URLs answered 403, and the config test went from two errors to zero.
The lesson is not “check your braces”. It is that a security control that fails silently is worse than no control, because it buys you the confidence without the protection — and the warning that would have told you is sitting in a log you have decided is noisy.
The vendor fix landed mid-sweep
I had already written DSM off. Synology’s archive showed PHP8.4
8.4.19-0050 as the newest build, so Web Station was going to spend the next
weeks on session.upload_progress.enabled = 0 and a firewall rule. Then, on
22 October at 19:45, Synology published SA-26:14 with actual patched
packages. I installed them a few hours later:
PHP 8.2 8.2.30-0170 -> 8.2.35-0171
PHP 8.3 8.3.30-0050 -> 8.3.36-0051
PHP 8.4 8.4.19-0050 -> 8.4.27-0051
The awkward part was the execution path. Interactive SSH was locked down
on that NAS, and clicking through three manual .spk uploads in the DSM web UI
mid-incident was slow and unscriptable — whereas my automation workstation already
had a live channel to the NAS’s Docker API. So I drove the host package manager
through a short-lived privileged container with the host root bind-mounted, called via chroot:
HostConfig = {
"NetworkMode": "host",
"Privileged": True,
"PidMode": "host", # the part that matters
"Binds": ["/:/h:rw", "/proc:/h/proc", "/sys:/h/sys",
"/dev:/h/dev", "/run:/h/run"],
}
# then, inside the container:
# chroot /h /usr/syno/bin/synopkg install \
# /volume1/docker/pkgs/PHP8.4-x86_64-8.4.27-0051.spk
That PidMode line is the whole lesson. I omitted it on the first attempt,
and synopkg answered:
{"error": {"code": 299, "description": "failed to run preuninst worker"},
"stage": "install_failed_and_start_failed", "success": false}
The binaries stayed at the old version; the package was simply dead, marked
broken / install_corruption. Re-running the identical install with
PidMode: host took the repair path (stage: installed_and_started) and
finished. Mounting /proc, /sys, /dev and /run was not enough on its
own — the PID namespace had to be the host’s.
Two smaller traps. Direct SPK URLs carry the architecture inside the
filename (PHP8.4-x86_64-8.4.27-0051.spk); drop the -x86_64- and they
404. And the unit serving Calendar is not the one just restarted:
synopkg restart PHP8.4 reports success while Calendar’s FPM keeps the old
binary, because Calendar runs on
PHP 8.2 — pkg-synocalendar-phpfpm.service, with
ExecStart=/usr/local/bin/php82-fpm. Restart that unit by name, then prove
the version from the binaries instead of trusting Package Center:
/usr/local/bin/php82 -v -> PHP 8.2.35 (built 2026-10-22 11:50)
/usr/local/bin/php83 -v -> PHP 8.3.36 (built 2026-10-22 11:54)
/usr/local/bin/php84 -v -> PHP 8.4.27 (built 2026-10-22 11:58)
One side effect worth naming: installing a package starts it. PHP 8.3 had been installed but not running on that box; the upgrade brought it up and handed me a listener I did not have before, so it went back to stopped. And the Calendar package itself is still one build short of the advisory — 3.0.3-21434 against a required 3.0.4-21435, its SPK not on the public CDN yet — but the runtime it serves requests with is patched, which is the part that mattered.
The same tag, rebuilt, is a different runtime
phpmyadmin:5.2.3 is the counter-example to everything above about floating
tags, and the reason I now check the rebuild date as well as the version.
The tag had not changed since I deployed it, and the PHP inside had: the
image was rebuilt on 21 October with PHP 8.3.36 baked in. So the fix was
pull and recreate, no version bump, no changelog to read — and the
container reports PHP 8.3.36 (built: Oct 21 2026) where it used to say
8.3.33.
The cost of that pull was not local, either. Recreating that one stack
through the API also re-resolved mysql:8.4.11 and restarted four
containers for about thirty seconds. Nothing broke, but a “one container”
change is worth watching while it lands rather than after.
The application I said could not move
I left app.client-portal.com on 8.4.27 on purpose: CodeIgniter, and the
framework documentation stops at 8.4. That reasoning was worth checking
instead of trusting, so I checked it. CodeIgniter 4.7.0 onwards supports PHP
8.5 — the framework’s own composer.json allows ^8.2, and 4.7.4 is
explicit about it — and the app’s route table loads clean under E_ALL on
8.5.12: spark routes, zero deprecations. So it moved with everything else,
and all eight sites ended on the same build.
That switch then exposed a second runtime that had nothing to do with the
CVE. The application’s three cron jobs called /usr/bin/php — the
distribution’s PHP 8.3.6 CLI, without ext-intl — while the site itself
served through LiteSpeed’s build. The failure was quiet and total:
Class "Locale" not found at SYSTEMPATH/CodeIgniter.php:189, 429 lines
apiece in the auto-delivery and mail-queue worker logs, every scheduled run
dying before it did any work. Pointing the three crontab lines at
/usr/local/lsws/lsphp85/bin/php, the same binary the site serves with,
ended it.
The queue happened to be empty of stuck mail when I looked. That was luck, not design — those two crons are how paid orders get delivered and how receipts go out.
The standby that had been replicating nothing
Upgrading the second passbolt instance produced the least comfortable finding of the night.
The primary (5.15.0) and the DR standby (5.14.0) both ran PHP 8.4.24, and
both needed the 5.17.0 image, which carries 8.4.27. The standby came up
healthy by every check that ships with it — fingerprint present, private key
able to decrypt, valid JWT pair — with a database of 34 tables against the
primary’s 56, and Seconds_Behind_Source: 0.
Both numbers were true, and neither meant what it looked like. Replication
had never carried passbolt’s writes: the primary’s binary log was filtered
to --binlog-do-db=appdb, a database that no longer exists, so every binlog
on that server was 181 bytes. Seconds_Behind_Source: 0 on an empty stream
is not health; it is silence with a green tick.
The fix took three attempts and one real trap. --replicate-do-db=passbolt
filters DDL by the session’s default database, so a replicated
CREATE TABLE was dropped while the row inserts that followed still matched
— the SQL thread stopped with Table 'passbolt.x' doesn't exist. What works
is --replicate-wild-do-table=passbolt.%, which matches on the real table
name for DDL and DML alike. Then a full reseed (--source-data=2, so the
dump carries its own replication coordinates),
CHANGE REPLICATION SOURCE … GET_SOURCE_PUBLIC_KEY=1 — without it
caching_sha2_password will not authenticate over a non-TLS link — and
read_only back on. Verified by counts on both sides (56 tables, 2 users,
542 resources, 644 secrets, 525 permissions) and by writing a probe row on
the primary and watching it arrive on the standby within five seconds. That
one deserves its own post. The point here is that patching a runtime is also
an audit, and this audit found a disaster-recovery plan that had been
decorative.
What is still not patched
unRaid bundles PHP in the OS image. My box runs 7.1.4 with webGUI PHP 8.3.19; the current stable (7.3.3) and even 7.4.0-rc.1 ship 8.4.26 — still below 8.4.27. There is no patch to apply, so the control is reachability: the webGUI answers on the LAN only and is not tunnelled.
mailcow is where my first pass was wrong. I had filed its phpfpm container
(8.2.29) as internal-only, and it is not: its web UI answers on a public
hostname. The image is pinned by the mailcow release
(ghcr.io/mailcow/phpfpm:8.2.29-3), so there is nothing to bump in
isolation — the fix ships inside mailcow’s own release, and 2026-10 (21
October) carries phpfpm:8.2.35-1. That is an application upgrade with mail
downtime attached, not an image pull, so it gets its own window. Until then
that container is pre-fix, and writing that down is more useful than a table
that looks finished.
What I’d do differently
- Probe versions on a schedule, not during an incident. The measurement
work above took an hour because I was doing it under pressure. A tiny script
that runs
php -vin every container and every host runtime weekly would have answered the whole question in one table before the advisory landed. - Treat the control panel’s database as runtime state. Half my confusion was “the panel says 8.4” versus “the process says 8.5”. Two sources of truth is a bug.
- Delete the old branches on the same day. 8.1 and 8.2 were installed, unused, and unsupported — 194 MB of attack surface kept alive by inertia. They are gone now.
- Read the config test output every time, out loud. The stray-brace bug had been printing its own diagnosis on every restart for a month.
- Assume the vendor fix can arrive mid-incident. I wrote DSM off in the morning and had a patched package by 19:45 the same day. The mitigation plan was a placeholder I had started to treat as a decision — check the advisory again before publishing “no fix exists” as a finding.
The result
One command closed a 9.2-severity unauthenticated RCE across eight public sites in about two minutes. By the end of the night every runtime I had measured on the 22nd had moved, except the two that had nothing to install:
| Where | Now | How |
|---|---|---|
| 8 CyberPanel sites | 8.5.12 | lsphp83 8.3.36 kept as fallback |
| 6 VPS containers | 8.3.36 | floating tag, images recreated |
| DSM Web Station | 8.2.35 / 8.3.36 / 8.4.27 | SA-26:14 packages |
| DSM phpMyAdmin | 8.3.36 | same tag, rebuilt image |
| passbolt primary + standby | 8.4.27 | 5.17.0 |
| mailcow php-fpm | 8.2.29 | pinned to the mailcow release — 2026-10 queued |
| unRaid webGUI | 8.3.19 | no vendor fix |
Every site’s rendered output was byte-identical before and after, there were zero PHP errors, and the config test passes clean.
Two findings came out of the same sweep that had nothing to do with PHP2Shell: eight bot-block rules that had never been live on a public site, and a standby database that had been replicating nothing for as long as anyone cared to check.
Zero-day PHP bugs are rare. Silent security controls are not.
Want this done on your servers?
I patch and maintain self-hosted PHP fleets for a living — WordPress on CyberPanel/OpenLiteSpeed, Dockerised PHP behind Traefik, Synology and unRaid hosts included. If you have sites you are not sure are on a patched runtime, I will measure every one of them, tell you honestly which ones have no vendor fix yet, and schedule the rest around your traffic.
Reach me at [email protected] or WhatsApp +60 12-797 2969, or see what I do at hoelee.com.
Dealing with flaky containers, cloud cost bloat, or unexplained outages?
Real production reliability comes from understanding the kernel and the protocol layers, not just restarting containers. I manage 162 containers across 3 hosts running 24/7 client workloads.