The gap between disclosure and patch
The nightmarish scenario is the one everyone just lived through: details leak, the patch isn't out yet, and your server is reachable. If that happens again, there are three stopgap measures that buy time. Treat all of them as temporary — they each cost something real.
Remove the predictable OOM path. CVE-2026-109618 needed PHP's memory_limit bailout to fire mid-initialization. Removing the cap removes the trigger:
; TEMPORARY pre-patch stopgap — revert once you're on 8.2.35/8.3.36/8.4.27/8.5.12
memory_limit = -1
The catch: -1 removes PHP's per-request memory cap, so an unpatched server can now be exhausted to death. Pair it with a cgroup or systemd MemoryMax= on the FPM service so the OS is the backstop. Do not substitute ulimit -v or RLIMIT_AS — those can trigger the same vulnerable allocation-failure path you're trying to remove.
On patched PHP 8.5.12, the fix changed the semantics of memory_limit from a hard cap to a soft limit: an over-limit allocation now raises the fatal error at a safe point, and only a separate hard limit terminates the worker outright. Setting memory_limit = -1 pre-patch was, in effect, manually simulating the safe half of that behavior by removing the unsafe bailout entirely.
Disable OPcache. One of the CVE-2026-109619 triggers lived in OPcache type confusion. opcache.enable = 0 kills it — at the cost of your bytecode cache, so every request recompiles PHP. That is a meaningful performance hit on a busy site, but it's survivable for the hours between disclosure and patch.
Recycle every worker. pm.max_requests = 1 forces each FPM worker to die after a single request, which limits how long stale state can survive inside a worker. This is a last resort: it destroys throughput under any real load. Use it only when you're being actively targeted and the patch is hours away.
One more thing, because shared-hosting customers are getting mixed signals right now: a version string is not the whole story. Debian and other distributions backport security fixes into their packaged PHP versions, so a host showing 8.5.6 may already carry the patch as 8.5.6-1+deb13u2 or similar. Check your distribution's security tracker or package changelog rather than trusting the bare php -v banner. And if you're on a genuinely end-of-life branch — anything before 8.2 — there is no official patch at all. That is not a scenario to harden around; it's a scenario to leave.
The edge layer
Cloudflare shipped seven PHP2Shell rules across both of its managed rulesets on October 21, 2026 — about nine hours before the official advisory, and following the October 20 leak. On the Free plan those rules live in the Cloudflare Managed Free Ruleset and run in Block by default, with no configuration required. On paid plans the same seven rules live in the Cloudflare Managed Ruleset, but they only fire if that ruleset is deployed to the zone and the rules are set to Block — a paid zone with the ruleset in Log mode, or with a skip rule in front of it, is silently unprotected.
Cloudflare also introduced a new WAF capability for this: full transaction detection, which correlates the request with the origin's response before the visitor sees anything. A response-phase rule can replace a matching origin response with a block page, and it runs on every plan, Free included. That matters here because a request-only signature can't always distinguish an exploit from a large legitimate upload.
If you're on Free and open Security → WAF → Managed rules, you won't see the individual rules — you'll see an upgrade banner instead. Don't read that as the rules being absent. The banner's wording — "Free customers are receiving protection from the Cloudflare Free Managed Ruleset" — is the confirmation that the ruleset is live on your zone. Rule visibility is a paid feature; the protection itself is not.
One documented blind spot: the WAF coverage assumes PHP's default session.upload_progress.name. If you've renamed that field, the rule doesn't match. If you've disabled upload progress entirely — as the settings earlier in this article do — you've removed the trigger at the source, which is stronger than any edge signature. The official details are in Cloudflare's blog post. If you're running the transparent proxy configuration from the Cloudflare Edge Configuration guide, this all works without touching anything.
There's a second perimeter layer worth naming: the origin only accepts traffic from Cloudflare. The cloud firewall allows Cloudflare's published IP ranges, your own administrative IPs, and the Tailscale network — everything else is dropped before it reaches nginx. For an engine-level bug this matters because it removes the direct-to-origin bypass. A WAF signature only helps if the traffic passes through the WAF; an attacker who finds the origin IP and probes it directly skips those signatures entirely. The firewall makes that probe die at the network layer instead of at the PHP worker. The setup is documented in Blocking Direct-to-Origin Access with a Cloud Firewall.
There's also a cheap nginx-side filter for sites that never accept uploads. If a site has no upload form, no multipart request has any business reaching PHP:
# For a site that never accepts uploads — stop multipart before PHP sees it
location / {
if ($http_content_type ~* "^multipart/form-data") {
return 403;
}
try_files $uri $uri/ /index.php$is_args$args;
}
The usual "if is evil" warning doesn't apply here: return inside if is one of the two directives that are safe there. This block kills the entire multipart attack surface at the proxy, before a single byte reaches the FPM worker. Don't add it to a site that accepts media uploads through the admin panel — WordPress uploads are multipart requests.
Honest limits
Nothing in this article is a substitute for patching. PHP2Shell was fixed by the PHP project, and the only complete remediation is the update. Everything above is delay-and-contain: it narrows the trigger surface, contains the blast radius, and buys hours when the patch isn't in your hands yet. If you are unpatched today, none of this makes you safe. Go update.
The lasting lesson isn't any single directive. It's that hardening happens in layers, and each layer has a specific job. The patch fixes the bug. Per-site pools contain the damage. Input bounds shrink the target. The edge layer buys time. And a patching routine — automated, boring, uneventful — is what turns a week-long fire drill into an hour of maintenance. That routine is the only layer that would have made last month boring. Boring is the goal.
This guide recaps the PHP2Shell engine-level vulnerabilities and lays out defense-in-depth PHP hardening: what contains an engine bug's blast radius, what shrinks its trigger surface, and which stopgap measures buy time between disclosure and patch.
Compatibility: Applies to PHP 8.2 and newer running under PHP-FPM on Debian-based systems. fatal_error_backtraces is PHP 8.5+; max_multipart_body_parts is present in 8.5.12 and resolves to max_input_vars + max_file_uploads when left unset. The per-site pool pattern and disable_functions list follow the conventions in the WordPress on Nginx guide. The nginx-level open_basedir technique assumes PHP-FPM behind nginx with FastCGI. The stopgap measures in the gap section are temporary and should be reverted after patching.
2026-11-10: Initial publication — defense-in-depth recap following PHP2Shell (CVE-2026-109618 / CVE-2026-109619). Covers the engine-level exploitation path and why disable_functions and open_basedir don't stop it, per-site FPM pool isolation as the primary containment layer, nginx-level per-request open_basedir, permanent request-shape and feature-surface hardening, pre-patch stopgap measures with tradeoffs, the patched 8.5.12 soft/hard memory-limit behavior, distribution backport caveats, and the Cloudflare WAF plus cloud-firewall perimeter. Cross-linked to WordPress on Nginx, Securing Nginx and PHP, Cloudflare Edge Configuration, and Blocking Direct-to-Origin Access with a Cloud Firewall.