Getting Through the PHP2Shell Gap
On October 20, php.net posted a warning that a critical security release was coming the next day at 14:00 UTC. It covered two bugs, both rated CVSS 9.2 and tracked as CVE-2026-109618 and CVE-2026-109619. No details came with it. By that evening there were videos and proof-of-concept code going around under the name "PHP2Shell". The fixes shipped a little early, as 8.5.12, 8.4.27, 8.3.36 and 8.2.35.
I look after a fleet of WordPress sites across a few servers and a pile of LXC containers. They run every PHP version from 7.4 to 8.5, some from apt and one built by hand. This is what I did in the roughly 18 hours between the warning and the fix. None of it replaces patching, but all of it reduced how exposed we were while we waited.
Take inventory first
You can't patch what you don't know about. I went through every host and container and listed each PHP-FPM version, where it came from (apt, PPA or source) and which sites it served.
for f in /usr/sbin/php-fpm*; do echo "$f $($f -v | head -1)"; done
systemctl list-units --type=service --state=running 'php*'That turned up an 8.5.0RC3 running a production site, a box nobody remembered, and a hand-compiled PHP whose php.ini had never loaded opcache. The site on that last one had been taking 4 to 9 seconds per page for who knows how long.
Catch up on patch releases now
Most of the boxes were months behind on point releases. If you upgrade 8.4.11 straight to 8.4.27 on release day, you're taking months of changes under pressure. So I brought everything to the latest point release the night before, which made the security update a small step on each box.
apt-get install --only-upgrade -o Dpkg::Options::=--force-confold $(dpkg -l 'php8.4*' | awk '/^ii/{print $2}')Do a dry run first (apt-get install -s ...). Metapackages like php-imagick can quietly install extensions into PHP versions you didn't intend.
Block file uploads at Cloudflare
The leaked material pointed at multipart request parsing, and the advisory later confirmed that path. Most of my sites don't need visitors uploading files, so I blocked multipart/form-data for everyone except the office, the VPN and my own IP. This is a custom WAF rule with the action set to Block:
any(lower(http.request.headers["content-type"][*])[*] contains "multipart/form-data")
and not ip.src in {203.0.113.0/24 198.51.100.7}Replace the example ranges with your own trusted IPs. Put the rule above any skip rules you have, otherwise those skips go straight past it.
I also staged a stricter rule, switched off, that blocks everything except GET and HEAD and strips query strings. That one breaks forms and search, so I kept it ready for an active attack rather than turning it on.
Do the same at your origin
Here's the part people skip. Your origin servers probably answer anyone who connects to their IP directly, and then your Cloudflare rules don't apply. Mine did, all of them. So nginx gets the same rule, keyed on the connecting IP: Cloudflare's published ranges plus my trusted IPs are allowed, and everyone else gets a 403 on uploads.
In the http {} block:
geo $upload_ok {
default 0;
127.0.0.0/8 1;
203.0.113.0/24 1; # office / VPN
198.51.100.7 1; # me
173.245.48.0/20 1; # ...every range from cloudflare.com/ips-v4 and /ips-v6
}
map "$upload_ok:$http_content_type" $block_upload {
default 0;
"~*^0:\s*multipart/" 1;
}Then in every server {}:
if ($block_upload) { return 403; }If you have dozens of vhosts, don't hand-edit them. Use one sed to add an include after every server_name line, take a tarball backup of /etc/nginx first, and only reload if nginx -t passes.
Test it from somewhere outside your allowlist. One catch: on Cloudflare's Free plan, Bot Fight Mode challenges cloud provider IPs before your custom rules even run, so testing from an AWS box can look like a pass when your rule never fired.
PHP settings that help
These go in php.ini or the FPM pool config, not .user.ini, and you need to restart every worker afterwards:
session.upload_progress.enabled = Off ; confirmed path for 109619, and almost nobody uses it
fatal_error_backtraces = 0 ; PHP 8.5 only, the other confirmed pathThe advisory also suggests a conservative post_max_size. Check what you actually have before changing it. I set 32M everywhere and then realized most of my pools were sitting at the 8M default, so I'd raised the limit when I meant to lower it.
Don't wait on the packagers
Most of us get current PHP on Ubuntu from Ondřej Surý's PPA. It's excellent, and it's one volunteer. When the fix landed, his packages weren't there yet. I nearly sat around waiting for them, which was silly, because I'd already tested building PHP myself the night before.
I build in an ubuntu:22.04 (or 20.04) amd64 container on my laptop, so it gets the same libraries as the servers. It uses the same configure flags and the extensions the packaged PHP loads, and installs to /opt/php-<version>. On an M-series Mac under emulation that takes about 10 minutes, against about 45 on a 2-vCPU server. Then I copy the tarball over and point the existing service at it with a systemd override, reusing the packaged php-fpm.conf and pools:
# /etc/systemd/system/php8.5-fpm.service.d/override.conf
[Service]
ExecStart=
ExecStart=/opt/php-8.5.12/sbin/php-fpm --nodaemonize --fpm-config /etc/php/8.5/fpm/php-fpm.confsystemctl daemon-reload && systemctl restart php8.5-fpmTo roll back, delete the override and restart. When the packages land, apt upgrade and remove the override.
Before switching, compare php-fpm -m between the packaged build and yours. I nearly shipped without soap, and PECL extensions like imagick and mcrypt need building separately with phpize.
For 7.4, 8.0 and 8.1, which are past end of life, the PHP security team published source backports as php-src branches (PHP-8.1-CVE-2026-109618-109619 and so on). If you're stuck on an old version, those are your best option short of upgrading.
The short version
- Inventory every PHP install, including the ones you forgot about.
- Catch up on point releases before the emergency, not during it.
- Block what you don't need, at both the edge and the origin.
- Test your blocks from a host that isn't on your allowlist.
- Have a tested way to build PHP yourself, and use it the moment the fix ships.
Then patch, and lift the temporary rules.