TL;DR: PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 fix two bugs in PHP itself that run before your application. One corrupts memory when a request runs PHP out of memory during startup. The other lets state from a crashed request leak into the next request on the same worker. WordPress, Laravel and application-level WAF rules cannot stop either. PHP says there are no complete mitigations. On the morning of 21 October 2026, before the fix was out, I protected two servers with an ini change, a multipart block at the edge, and a firewall that only lets Cloudflare in, then verified each change on the PHP process that serves traffic. The whole sequence, plus the patch step, is now a Claude Code skill: php2shell-defense. It runs from your own machine over SSH and changes nothing without showing you first.
What PHP2Shell is
On 20 October 2026 the PHP team announced an emergency release for 21 October at 14:00 UTC. Two critical bugs, both CVSS (Common Vulnerability Scoring System) 9.2. No details.
The details leaked anyway. watchTowr reproduced one bug in a lab, Sansec and Hadrian reported exploitation at scale from early morning, and the UK NCSC issued an alert. The exploit kit is called PHP2Shell.
PHP released early, at about 13:35 UTC, with the security advisory and two GitHub advisories:
- CVE-2026-109618, RCE via OOM corruption (GHSA-w8hw-w9fx-j79w). When PHP runs out of memory it used to jump out of the request. If that happens while request memory is only partly set up, shutdown trusts half-built data. An attacker gets there with a multipart body that stays inside every visible limit: no usable Content-Length, thousands of headers per part, or nested empty file fields. Confirmed remote code execution on Apache mod_php with default settings.
- CVE-2026-109619, RCE via stale global state (GHSA-6wmf-4rv5-p3rp). PHP workers live for many requests. When a request dies from a fatal error, out of memory, or a timeout, some global state still points into freed memory. The next request on the same worker can use it before normal startup resets it. The confirmed paths are session upload progress, which is on by default and runs before
session_start(), and on PHP 8.5 the fatal error backtrace buffer.
Both run before index.php is compiled. Any URL that maps to PHP is reachable. Updating WordPress does not help. disable_functions does not help. PHP’s words: “There are no complete mitigations, updating is the only safe choice.”
The leak had the location wrong. The early reports said use-after-free in the multipart parser. The multipart body is the trigger, not the bug. That matters, because it changes which workarounds help.
How I mitigated my two servers, then ran the skill on them
The morning work was by hand, on leaked information, because the servers were exposed and the fix was hours away. Once the official advisory was out I ran the skill against the same two servers from my laptop. It found that the first server runs PHP 8.1 from a PPA that no longer builds for its Ubuntu release, so no package will ever arrive, and that the second runs 8.5.11 in a Docker image whose patched base, php:8.5.12-fpm, was not on Docker Hub yet. So it mitigated both with the official workarounds, verified every value on the serving process, blocked multipart at the edge on the first, and left the patch for when it becomes possible. The steps below are the morning sequence, with what the advisory changed noted where it matters.
| Server 1 | Server 2 | |
|---|---|---|
| OS | Ubuntu, PHP from the Ondřej Surý PPA | Debian, Docker Compose |
| PHP | php8.3-fpm as a system service | Official php:8.3-fpm image, app and queue services |
| Web server | nginx on the host | nginx container in front |
| In front | Cloudflare proxy | Cloudflare proxy |
Both run sites that accept uploads in one or two places and nowhere else.
What I did, in order
1. Inventory. Which PHP binaries exist, which one serves traffic, and what session.upload_progress.enabled is set to there. The CLI php -i reads a different php.ini than PHP-FPM. Its values are not evidence.
2. The ini stopgap. One drop-in file in conf.d:
session.upload_progress.enabled = 0
session.use_strict_mode = 1
The first line turns off the session path that was being abused in the wild. The official advisory later confirmed it as the workaround for CVE-2026-109619, with two conditions: it must load from php.ini, conf.d or the FPM pool, not from .user.ini or ini_set(), and every worker must be restarted. The second line stops a client from choosing its own session ID. Hygiene, not in PHP’s list.
After the advisory I added two more lines, also now in the skill’s template:
memory_limit = -1
fatal_error_backtraces = 0
memory_limit = -1 removes the predictable out-of-memory that CVE-2026-109618 relies on. PHP recommends it together with a container or cgroup memory limit, and warns never to use RLIMIT_AS or RLIMIT_DATA instead, because those cause the very allocation failure the bug needs. For FPM, php_admin_value[memory_limit] = -1 in the pool is stronger than the drop-in, because no application can put a finite limit back. fatal_error_backtraces = 0 only exists on PHP 8.5 and closes the confirmed mod_php path there.
3. The edge block. Multipart bodies are the trigger for the OOM bug and the only way into the upload progress path. So stop them reaching PHP except where uploads belong. In nginx:
set $php2shell_block 0;
if ($http_content_type ~* "multipart/form-data") { set $php2shell_block 1; }
if ($request_uri ~* "^/(wp-admin/async-upload\.php|upload)") { set $php2shell_block 0; }
if ($php2shell_block) { return 403; }
Uploads keep working on the two listed paths. Everywhere else a multipart POST gets a 403. Logins and normal forms use application/x-www-form-urlencoded, so they are not affected. PHP also asks for a low post_max_size (8M without uploads, 32M with) and body size limits at the proxy, including for requests with no Content-Length.
4. Close the origin. A Cloudflare WAF rule is useless if the attacker connects to the server’s IP directly. I set ufw to allow ports 80 and 443 from Cloudflare’s published ranges only, with SSH kept open.
5. Verify on the serving process. I wrote a small FastCGI client. It asks the running PHP-FPM to execute a one-line script and prints the effective PHP_VERSION and ini values. Before the restart it said session.upload_progress.enabled = '1'. After the restart it said '0'.
6. Hunt. Out-of-memory and timeout fatals in the PHP log, segfaults in the PHP-FPM log, shells with php-fpm as parent, .php files changed in the last 3 days, session files with upload_progress_ keys. Nothing found on either server. That does not prove they were not hit. PHP’s advisory says the same: clean logs do not prove safety.
7. Patch, when it becomes possible. The fix is out for all 4 supported branches, but neither server could take it on day one. Server 1 is on PHP 8.1, which PHP does not release, and its PPA dropped Ubuntu 20.04 last year. The routes are a source build from PHP’s backport branch, moving the site into a php:8.3-fpm container, or an OS upgrade. Server 2 waits for Docker Hub to publish php:8.5.12-fpm, then a rebuild of the app image. After each, the probe must report the fixed version on the serving process. Only then do the temporary blocks come off. The ini drop-in stays.
What broke
Server 1: the PPA was stale. apt update fetched the index but apt-cache madison php8.3-fpm was empty. The source still pointed at ppa.launchpad.net, and Launchpad moved to ppa.launchpadcontent.net. Removing and re-adding the PPA with add-apt-repository fixed it. Without that, the patch would have failed.
Server 2: 502 after recreating the container. Adding the ini drop-in meant a bind mount in compose.override.yml and a recreate of the app service. The new container got a new IP. The nginx container had resolved app at startup and kept the old one, so every request returned 502 Bad Gateway. A restart of the nginx container fixed it. The lesson: when you recreate a PHP container, restart or reload the proxy in the same change, then make one real request before you call it done.
Both: Cloudflare SSL mode. Closing the origin to 443 only works if Cloudflare connects to the origin over HTTPS. With “Flexible” mode it uses plain HTTP, the firewall drops it, and the site is down. The mode must be “Full (strict)”. Check this before you touch the firewall.
Server 2: ufw does not see Docker. Docker publishes ports through its own iptables chains, ahead of ufw. A ufw rule set that only allows Cloudflare does nothing for a container on port 443. The allow-list has to go in the DOCKER-USER chain. This server already had one. The skill now checks for it.
Server 2: the image overrode the drop-in. The app image ships a zz-app.ini that sets memory_limit. PHP loads conf.d alphabetically, so my 99- file lost. The probe showed 256M after the recreate. Renaming the mount to zzz- fixed it. The skill now writes zzz- and warns when a later file sets the same knob.
Server 1: a backup in the wrong folder. I copied sites-enabled/default to default.bak in the same directory before editing. nginx includes that directory with a glob, loaded the copy too, and refused the config with “duplicate default server”. The reload failed safely and the old config kept serving. Backups go outside any included directory.
The skill
Doing this twice by hand took a morning. The third time I wanted it to take 20 minutes. So I turned the sequence into a Claude Code skill: php2shell-defense on GitHub.
A skill is a folder with a SKILL.md that tells the agent when to act and what steps to follow, plus scripts and reference notes it can use. This one has 6 scripts:
| Script | Changes anything? | What it does |
|---|---|---|
php-inventory.sh |
No | Every PHP binary, SAPI, version and the ini values that matter |
php-check-release.sh |
No | Compares a version against php.net, live, and the fixed versions |
php-mitigate.sh |
Only with --apply |
Writes the ini drop-in with PHP’s published workarounds. Dry-run by default. Never restarts |
php-fpm-probe.py |
A temp file it deletes | The FastCGI client. Proves the values on the serving process |
cf-origin-lockdown.sh |
Only with --apply |
Rebuilds ufw to allow web ports from Cloudflare only |
php-hunt.sh |
No | The exploitation hunt |
The reference notes cover the official facts and advisories, how the request startup and the session hook work, what each ini knob does and does not do, where the ini lines go for each distro and panel, patch commands per platform including PHP’s source backports for 7.4, 8.0 and 8.1, and detection rules for auditd, Falco and Loki.
It handles both cases. If the fix is out for your branch and the package is really there, it patches: upgrade command, restart, and a version check over FastCGI afterwards. If the patch will take time, or your branch gets no package, it offers a ladder of temporary fixes and lets you pick how far to go: the ini change, the multipart block at the edge, the firewall or Cloudflare lockdown, and as a last resort taking PHP-FPM offline behind a maintenance page. Once the patch is verified it rolls back the blocks you want removed.
Three rules are written into it. It never downloads or runs exploit code, even in Docker. It shows every dry-run and the exact command, then waits for your approval. It works on one host at a time and reports per host.
Install and use it
You do not need Claude on the server. Install the skill on your own machine and give it SSH access. The README has the full details.
git clone https://github.com/SumonMSelim/skills.git ~/src/skills
mkdir -p ~/.claude/skills
ln -s ~/src/skills/php2shell-defense ~/.claude/skills/php2shell-defense
Start a new Claude Code session and name the host:
/php2shell-defense web-01 (ssh deploy@web-01) runs WordPress on PHP 8.3. Check exposure, mitigate, verify.
The agent re-checks php.net, copies the scripts to the host with scp, runs the inventory, and tells you whether to patch now or mitigate first. Each step is a dry-run, your yes, the change, and a probe of PHP-FPM to prove it. Then the patch after your approval, the hunt, and cleanup of the scripts from the host. If sudo needs a password, it gives you the command to run yourself.
The scripts also work without Claude. The README has the same sequence as plain ssh commands.
What to do today
- Patch. 8.2.35, 8.3.36, 8.4.27 or 8.5.12. Restart every worker. Re-check the version over FastCGI, not on the CLI.
- If the patch has to wait, add the ini lines from
php.ini,conf.dor the pool, restart every worker, and verify on the serving process..user.iniandini_set()do not count. - Block multipart at the edge for every path that does not take uploads. Set a low
post_max_size. - If you are behind Cloudflare, close the origin, and check the SSL mode first. Cloudflare’s managed rules “Generic Rules - Anomalous Execution” have been on by default since 03:02 UTC on 21 October; on paid plans confirm the ruleset is in Block mode, and check Security Events for hits before your patch time. Details in Cloudflare’s post.
- PHP 8.1 and older get no release. Upgrade, use a vendor backport, build PHP’s own backport branches, or take it off the internet.
- mod_php is the highest-risk setup in PHP’s own words. Patch those first.
- Keep
session.upload_progress.enabled = 0after patching if you do not use the feature. PHP suggests the same.
The skill, the scripts and the notes are MIT licensed, provided as is, with no warranty. Read the dry-run before you apply anything.
Sources
- PHP: Security advisory: Critical remote code execution vulnerabilities, update now, 21 October 2026
- PHP: Upcoming Critical PHP Security Release, 20 October 2026
- GitHub: GHSA-w8hw-w9fx-j79w, RCE via OOM corruption, CVE-2026-109618
- GitHub: GHSA-6wmf-4rv5-p3rp, RCE via stale global state, CVE-2026-109619
- watchTowr: PHP CVE-2026-109618 FAQ
- Sansec: PHP2Shell research
- Hadrian: PHP2Shell vulnerability alert
- UK NCSC: Critical security issue affecting PHP
- Cloudflare: php2shell WAF protections
- Cloudflare IP ranges: cloudflare.com/ips
- The skill: php2shell-defense