PHP2Shell: What WordPress Site Owners Need to Check After the October 2026 PHP Security Release
· 18 min read
The PHP project shipped an out-of-band security release on Tuesday 21 October, a day earlier than planned, because the technical details had leaked on the 20th. Two vulnerabilities in PHP's core, CVE-2026-109618 and CVE-2026-109619, now collectively known as PHP2Shell. Both rated CVSS 9.2. Both reachable by an unauthenticated POST request before a single line of WordPress code runs. By the time the fixed builds were on the mirrors, watchTowr's honeypots were already logging exploit attempts, and Amazon's threat intelligence team reported seeing a probe on 17 October, four days before anyone outside the embargo was supposed to know.
This is not a WordPress vulnerability. There is no WordPress update to install. John Blackbourn from the WordPress Security Team said as much on WordPress.org: no core release is required or planned. The fix lives in the PHP binary your web server runs, which means the person who controls that binary is the one who has to act. For most site owners that is their hosting company. For some of you, it's you.
I manage 70+ WordPress sites across servers I control, so the patching side of this was mine to do. Here's what the bugs are, how to find out which PHP you're actually running from inside WordPress, what to do if you manage your own server, and what to write to your host if you don't.
What PHP2Shell Actually Is
Three months ago I wrote up wp2shell, a WordPress core RCE. The name is a deliberate nod. The difference this time is where the bug sits.
CVE-2026-109618 is memory corruption triggered by an out-of-memory condition during request parsing. When PHP inserts a string key into a hash table, it increments the element counter before allocating the key. If that allocation pushes the request over memory_limit, PHP raises a fatal error and abandons the insert, but the counter already includes a bucket whose key and value were never initialised. At request shutdown PHP destroys $_POST, walks that counter, and interprets whatever bytes happen to be in the uninitialised bucket as PHP values. An attacker who has groomed the heap controls those bytes and can point the destructor at system(). The trigger is a single POST with a field name sized to land exactly on the memory limit: public exploits use fields between roughly 3 MB and 60 MB depending on the target's memory_limit.
CVE-2026-109619 is stale worker state surviving across requests. PHP workers (mod_php children, PHP-FPM processes) serve thousands of requests each. If a fatal error interrupts PHP while it's resetting per-request state, a later request on the same worker can follow a pointer into freed memory. Two confirmed paths: the session upload progress feature (session.upload_progress.enabled, on by default, and it runs even if your application never calls session_start()), and on PHP 8.5 only, the fatal error backtrace state.
Both bugs are in the request parser, which runs before WordPress, before your security plugin, before any permission_callback. How far that gets an attacker depends on the SAPI. On Apache with mod_php, the worst case, both bugs have been demonstrated against a bare index.php that prints "hello": no application code needed. PHP-FPM is narrower. For the out-of-memory bug, FPM exits the child when request startup fails, so the half-initialised state is never consumed. For the stale-state bug, the path that's been validated on FPM needs the application to do something suitable (an upload, a session operation) after startup succeeds. WordPress does plenty of both, so I don't read that as "FPM is safe", only as "FPM isn't exploitable from an empty script the way mod_php is". Every web SAPI is in scope for patching: mod_php, PHP-FPM, php-cgi, LiteSpeed's lsphp, FrankenPHP. The PHP security team was clear that there is no complete configuration-level mitigation. Updating is the fix.
Fixed versions: 8.5.12, 8.4.27, 8.3.36, 8.2.35. Anything below those on each branch is vulnerable.
PHP 8.1 and older: No official binary release, because those branches are end of life. The PHP security team published source-only backports for 7.4, 8.0 and 8.1, and the distribution and panel vendors (Debian, Ubuntu, Ondřej Surý's packages, cPanel, Plesk, CloudLinux) have built fixed packages from them. If you're on 8.1 or older, you depend entirely on your vendor having done that work.
Step 1: Find Out What PHP Version WordPress Is Running
You don't need SSH for this. WordPress has reported it since 5.2.
Go to Tools → Site Health, then the Info tab, then expand the Server section. You'll see a row labelled PHP version. It reads something like 8.3.36 (Supports 64bit values).
Two things to be aware of before you trust that number.
First, the Status tab won't warn you about this. Site Health's "PHP is out of date" check compares your version against the minimum WordPress recommends. It knows that 8.1 is end of life. It has no idea that 8.3.35 is vulnerable and 8.3.36 isn't. A site on 8.3.35 shows a clean bill of health on the Status tab right now. You have to look at the Info tab and read the number yourself.
Second, the number can be misleading on distribution packages. Debian and Ubuntu backport security fixes into the PHP version they shipped with and keep the old version string. Ubuntu 24.04's fixed package is php8.3 8.3.6-0ubuntu0.24.04.13, so Site Health reports 8.3.6 on a fully patched server. Ubuntu 22.04's fixed package is 8.1.2-1ubuntu2.28, which shows as 8.1.2. Debian 13 did take the upstream release, so it reports 8.4.27, but Debian 12 reports 8.2.35. If your host runs stock Debian or Ubuntu packages, the version in Site Health tells you the branch and nothing else.
So the reading is:
| Site Health shows | Meaning |
|---|---|
| 8.2.35, 8.3.36, 8.4.27, 8.5.12 or higher | Patched upstream build. Confirm workers were restarted. |
| Lower than those, but a round number like 8.3.6 or 8.1.2 | Probably a distro package. Patched or not, you can't tell from here. Ask. |
| 8.3.35, 8.4.26 or any other recent-but-not-fixed number | Vulnerable. Act today. |
| 8.1.x, 8.0.x, 7.4.x | End-of-life branch. Only patched if the vendor backported. Ask, and plan the upgrade. |
Don't substitute wp --info or php -v over SSH for this. Both report the CLI binary, and on a multi-version server the CLI is often a different build from the one PHP-FPM serves your domain with. A patched CLI next to an unpatched FPM is a common state on the morning after an update. Site Health runs inside a web request, so it reports the binary that actually matters. If you have a hosting panel (cPanel's MultiPHP Manager, Plesk's PHP Settings, HestiaCP's Web domain settings), that shows which PHP version is assigned to the domain, which is the other reliable source.
Step 2a: If You Manage the Server Yourself
The sequence is the same everywhere: update the packages, confirm the running binary, restart every worker, verify.
Debian and Ubuntu
Stock packages:
apt update
apt list --upgradable 2>/dev/null | grep -i php
apt install --only-upgrade 'php8.3*' # adjust branch
Confirm the package version against the fixed ones listed above. Because the version string doesn't change on Ubuntu, check the changelog for the CVE instead:
zcat /usr/share/doc/php8.3-fpm/changelog.Debian.gz | grep -m1 CVE-2026-109618
If you use Ondřej Surý's packages (packages.sury.org, or the ppa:ondrej/php PPA), which is what most multi-version setups including HestiaCP use, the fixed builds landed on release day for amd64. arm64 builds lagged by roughly two days, so if you're on an ARM VPS and apt shows nothing, check again rather than assuming you're done. Surý also backported to 8.1, 8.0, 7.4 and older, which is more than upstream did.
On a HestiaCP server, drive the update from the list of installed PHP versions rather than a hand-typed glob, because each version serves whichever domains are assigned to it and a server that still has 7.4 installed for one legacy site is exactly the server that gets missed:
v-list-sys-php
for v in $(v-list-sys-php plain); do
apt install --only-upgrade "php${v}-*"
systemctl restart "php${v}-fpm"
done
cPanel and CloudLinux
EasyApache 4 build 25.91 (21 October) carries the fix for ea-php82 through ea-php85 as upstream releases, and backports for ea-php74, 80 and 81. CloudLinux published fixed ea-php52 through ea-php85 for CloudLinux 7, 8, 9 and 10. Use a plain update, not --security, because these packages don't carry the security flag:
yum update 'ea-php*'
rpm -q --changelog ea-php83-php-cli | grep -m1 CVE-2026-109618
/scripts/restartsrv_apache_php_fpm
/scripts/restartsrv_httpd
Imunify360 added a WAF rule for the session upload progress path, which is the one the public exploit uses. CloudLinux said themselves that it does not cover the out-of-memory path of CVE-2026-109618. The WAF is a seatbelt, not the fix.
Plesk
Plesk rebuilt its own PHP packages including the end-of-life branches on 21 October. Run plesk installer update or apply the update from Tools & Settings → Updates, then restart PHP-FPM from the same panel or with service plesk-php83-fpm restart for each version in use.
Docker and anything else
Rebuild from an updated base image. php:8.3-fpm and friends on Docker Hub were rebuilt on release day. A container that was started last week is still running the old binary regardless of what the registry now holds.
Restart the workers. All of them.
This is the step people skip. Updating the package on disk does nothing for the PHP-FPM processes that were forked before the update. They keep the old code mapped in memory until they exit. Restart PHP-FPM for every version. If you run mod_php, restart Apache. If you run LiteSpeed, restart lsws so lsphp children respawn.
Then prove it:
# Is any php-fpm process, master or worker, still running a deleted (pre-update) binary?
pids=$(pgrep -f 'php-fpm')
[ -z "$pids" ] && echo "no php-fpm processes found, check the service names" && exit 1
for pid in $pids; do
if ls -l /proc/$pid/exe 2>/dev/null | grep -q '(deleted)'; then
echo "PID $pid ($(tr '\0' ' ' < /proc/$pid/cmdline)) still on old binary"
fi
done
echo "checked $(echo $pids | wc -w) php-fpm processes"
The masters matter as much as the workers. A pool using pm = ondemand (which I recommend for isolation) may have no children at all when you look, and a master that missed its restart will fork vulnerable workers from the old binary on the next request. Checking only php-fpm: pool processes passes that silently. The final line is there so that an empty result reads as "nothing checked" rather than "all clear". Then load a phpinfo() page through the web server, not the CLI, on one domain per installed PHP version and read the version from there.
If you can't patch today
A vendor that hasn't shipped yet, a change freeze, whatever the reason. These reduce exposure and the PHP team was explicit that none of them is complete:
In the web SAPI's php.ini (on Debian-style layouts that's /etc/php/8.3/fpm/php.ini, not the cli one), never .user.ini or ini_set(), because both of those are read after the request parser has already run:
session.upload_progress.enabled = 0
; PHP 8.5 only
fatal_error_backtraces = 0
If you'd rather scope it to a single FPM pool, the pool file uses a different syntax. Bare directives in a pool file are a config error and FPM refuses to start, which takes every site on that pool offline:
; in /etc/php/8.3/fpm/pool.d/example.conf
php_admin_flag[session.upload_progress.enabled] = off
; PHP 8.5 only
php_admin_flag[fatal_error_backtraces] = off
Run php-fpm8.3 -t to test the config, then restart workers. Disabling upload progress closes the public exploit's path for CVE-2026-109619 (and the related, low-likelihood CVE-2026-115560 that PHP acknowledged on 23 October). It does nothing for CVE-2026-109618. For that one, the only interim measure the PHP team described is memory_limit = -1 at the administrator level, which removes the allocation failure the exploit depends on at the cost of letting a single request eat the whole server. I wouldn't run that on a shared box. Blocking multipart/form-data POSTs at nginx or the WAF for sites that don't accept uploads is a reasonable extra layer, with the obvious caveat that it also blocks every form that uses multipart encoding.
Step 2b: If Your Host Manages PHP
Most managed WordPress hosts had patched or mitigated within 48 hours. Kinsta reported the patch complete across its network. Cloudways finished its rollout. Hostinger deployed patched builds from 5.2 to 8.5. SiteGround applied server-level mitigations (disabling the affected features) ahead of the release. GoDaddy patched shared hosting but told VPS customers to update themselves. Whichever host you're on, don't rely on a status page from Tuesday. Open a ticket and get a written answer specific to your account.
This is what I'd send. Fill in the version from Site Health:
Subject: PHP2Shell (CVE-2026-109618 / CVE-2026-109619) status for [domain]
Hello,
I'm checking my site's exposure to the PHP security release of 21 October 2026 (CVE-2026-109618 and CVE-2026-109619). WordPress Site Health on [domain] currently reports PHP [version].
Please confirm:
- Which PHP build serves [domain]: the exact package name and version, not just the branch.
- Whether that build includes the fix for both CVEs, and when it was deployed.
- Whether PHP-FPM / Apache workers were restarted after the update so the patched binary is actually serving requests.
- If my site is on PHP 8.1 or older: which vendor backport you applied, since upstream did not release binaries for those branches, and what the upgrade path to a supported branch is.
- Whether any interim mitigations (session.upload_progress disabled, WAF rules, multipart blocking) were applied before the patch and whether they are still in place.
- Whether you have reviewed logs for exploitation attempts against my account since 17 October, when the first in-the-wild attempts were reported.
If the fix is not yet deployed, please give me the expected date.
Thanks, [name]
Answers you should not accept: "WordPress is up to date" (irrelevant), "we have a firewall" (the WAF only sees what it's configured to see, and the OOM path has no known rule), or "you're on PHP 8.3, which is supported" (8.3.35 is supported and vulnerable). If the host can't tell you the package version, that's an answer in itself.
Step 3: Check Whether Anyone Got In First
Exploitation started before disclosure. If your PHP was unpatched between 17 and 21 October, which describes nearly every server on the internet, look at that window.
In the web server access log, you're looking for multipart POSTs to URLs that don't take uploads, very large POST bodies, and PHP_SESSION_UPLOAD_PROGRESS or PHPSESSID in request bodies where the application doesn't use them. The catch is that the default combined log format records neither the body nor its size. The byte count in that format is $body_bytes_sent, the response, and an exploit POST that crashes the worker sends back almost nothing. So on a stock log you're reduced to method, path and status:
# POSTs that drew a 500 or 502 between 17 and 23 October, grouped by client IP
zgrep -hE '\[(1[7-9]|2[0-3])/Oct/2026' /var/log/nginx/access.log* \
| awk '$6 ~ /POST/ && ($9 == 500 || $9 == 502) {print $1}' \
| sort | uniq -c | sort -rn | head
Rotated logs are usually gzipped, which is why that's zgrep and not grep: a plain grep skips the .gz archives silently and the window you care about is mostly in them by now. A single IP with a cluster of 500s on POST, especially to a path that isn't wp-admin/admin-ajax.php or an upload endpoint, is worth pulling the full lines for. If your log format includes $request_length (mine do, as the last field, for exactly this kind of question), filter on that instead: anything over a couple of megabytes to a URL that doesn't accept uploads is a probe.
In the PHP-FPM or Apache error log, the signature of the CVE-2026-109618 trigger is a memory exhaustion fatal that happened before any script ran:
grep -h 'Allowed memory size' /var/log/php*-fpm.log /home/*/web/*/logs/*error.log 2>/dev/null \
| grep 'Unknown on line 0'
That exact message, Unknown on line 0, means PHP ran out of memory during request initialisation rather than in your code. Legitimate oversized uploads produce it too, so a handful across a fleet is noise. A burst of them from one IP within a few seconds is someone grooming the heap. Segfaults in the same window (AH00052 in Apache, SIGSEGV in the FPM log) are failed address guesses.
Then the standard post-incident sweep, because a successful exploit returns HTTP 200 and leaves no crash:
# PHP files modified in the window, anywhere under the docroot
find /var/www -name '*.php' -newermt '2026-10-17' ! -newermt '2026-10-24' -ls
# PHP files in uploads, which should be zero
find /var/www/*/wp-content/uploads -name '*.php'
# Web user cron entries, then any socket owned by a PHP process: listeners (-l) and established connections
crontab -u www-data -l
ss -tlnp | grep php
ss -tnp state established | grep php
If you've already blocked PHP execution in the uploads directory, a dropped webshell there is inert, but the find still tells you someone was in.
What This Changed in My Monitoring
The package update itself was the easy part: Surý's amd64 builds were available on the 21st, and HestiaCP's multi-PHP layout means a single apt run plus an FPM restart per version covers every site on the box. The parts that took longer were the ones that didn't exist as scripts yet.
Worker binary verification. The /proc/$pid/exe check above is now part of my post-update script for any PHP package. I had been trusting systemctl restart and moving on. A restart that fails silently on one pool leaves that pool exploitable with a patched binary sitting unused on disk.
Version drift alerting now understands branches. My daily sweep compared the WordPress core version against the latest release. It now also compares the PHP-FPM package version on each server against a fixed-version list I maintain, so a server that misses a security build for any reason shows up the next morning rather than at the next incident.
A log alert for Unknown on line 0. Memory exhaustion before script execution is rare in normal operation. Three or more from the same IP in a minute now pages me.
If you'd rather someone else were reading the advisories at 11pm, that's what a security maintenance plan covers: I own the PHP version on every server I manage, I patch the day the build lands, and I check the logs for the window before it did. For self-hosted sites where you want the server side handled, server management is the same deal.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off security audit — I can help.
