Incident Response Cloudflare WordPress

Hardening Cloudflare for WordPress: Four Layers, Two Traps, and the Tests That Prove It

A rule can validate, deploy, show Active — and match nothing. Here's the four-layer Cloudflare stack we built during the php2shell wave, the two configuration traps that ate our first rules, and the bot-side tests that tell you what's actually enforcing.
AIMF Security Blog • October 25, 2026 • 14 min read • Part 2 of the php2shell series

Four layers, four dimensions of the same attack. Each one catches what the others can't.

Short answer: For a WordPress site during an active PHP exploitation wave, four Cloudflare layers earn their place: the Managed Ruleset (signature matches, including the php2shell rules), the OWASP Core Ruleset (anomaly scoring), a path challenge rule (wp-login + wp-admin, with the admin-ajax exemption), and a transport challenge rule (multipart POSTs — the exploit's entry shape). But two configuration traps will silently eat your rules — one makes them match nothing, one takes your whole site down — and a rule that "shows Active" proves nothing. Only a test from outside your browser tells you what's enforcing.

Part 1 covered the PHP side: the dashboard said 8.3, the site was running 8.1. This part covers the edge — the layer that buys time while the engine gets patched, and the one you configure yourself.

The four layers, and what each one is for

The php2shell attack has four dimensions, and no single Cloudflare feature covers them all. The stack that does:

Layer 1 — Cloudflare Managed Ruleset (signatures)

The ruleset containing the php2shell-specific rules. Critical lesson from our build: the entitlement isn't the protection. On paid plans the ruleset must be deployed — and Cloudflare's own dashboard will tell you when it isn't: a banner reading "Your configuration doesn't protect [your domain] against php2shell!" with a one-click deploy button. We found an undeployed ruleset on a paid zone this way. Check yours: Security → Security rules → Managed rules → the ruleset should read Execute · Active.

Cloudflare banner: configuration does not protect against php2shell

the orange "doesn't protect against php2shell" banner

Managed Ruleset and OWASP Core both Execute/Active

Managed Ruleset + OWASP both Execute/Active

Layer 2 — OWASP Core Ruleset (anomaly scoring)

A second managed layer that scores requests: individual rules contribute anomaly points (method-not-allowed +5, security-scanner user-agents +5, …), and requests crossing the threshold get blocked. The WordPress-safe configuration: Paranoia Level PL1 (higher levels false-positive on legitimate plugin traffic), threshold Medium (40+), action Block. Found under Security → Settings → Web application exploits → OWASP Core — on paid plans it's present but toggled off by default.

Layer 3 — Path challenge rule (the login surface)

The single highest-value custom rule for any WordPress site: challenge everything that reaches your login and admin area.

Layer 4 — Transport challenge rule (the exploit's shape)

php2shell's entry vector is a multipart POST. Gating that transport shape closes the door the exploit prefers — more on the crawl-first discipline below.

Layer 3 in full: the wp-admin challenge rule

Built with the visual builder — three rows:

JoinFieldOperatorValue
—URI Pathequals/wp-login.php
OrURI Pathstarts with/wp-admin
And NotURI Pathequals/wp-admin/admin-ajax.php

Action: Managed Challenge. Compiles to:

(http.request.uri.path eq "/wp-login.php") or (starts_with(http.request.uri.path, "/wp-admin") and http.request.uri.path ne "/wp-admin/admin-ajax.php")

Three details that matter:

  • "starts with," not "equals" on the wp-admin row — "equals" matches only the literal directory path and misses every page inside it (edit.php, users.php… the actual targets).
  • The admin-ajax exemption is mandatory on most WordPress sites: forms from WPForms, MailPoet, and dozens of plugins POST to /wp-admin/admin-ajax.php from public pages. Challenge it and your signup forms silently break for visitors.
  • xmlrpc.php is a separate decision: if you run Jetpack, leave it alone — Jetpack communicates through it. If you don't, block or challenge it. (And if you don't use it at all, disable it at the WordPress level too — a one-line filter: add_filter('xmlrpc_enabled', '__return_false'); — so the application layer agrees with the edge.)
The three-row rule builder with clean expression preview

the three-row builder + clean expression preview

Layer 4 in full: the multipart transport rule

php2shell's exploit arrives as a multipart/form-data POST. The rule:

http.request.method eq "POST"
  and any(http.request.headers["content-type"][*] contains "multipart/form-data")
  and not starts_with(http.request.uri.path, "/wp-admin")
  and not starts_with(http.request.uri.path, "/wp-json")

Action: Managed Challenge. Three things to know before deploying:

  • There is no http.request.content_type field — multipart detection goes through the headers map, as above.
  • Crawl before you gate. Check your own site for file-upload forms first — fetch your pages and grep for type="file" and multipart/form-data. Forms with upload fields POST multipart and would get challenged. (Our crawl of 40 pages found zero — most form plugins post urlencoded unless a form has a file-upload field.)
  • The Log action is Enterprise-only. The classic advice — "deploy in Log mode first, observe, then enforce" — isn't available below Enterprise. Your observation baseline instead: your origin access logs. If they show no legitimate multipart POSTs to frontend paths, you can deploy straight to challenge.

Trap 1: the rule that validated, deployed, showed Active — and matched nothing

This is the one we'd warn every Cloudflare admin about, because it ate both of our zones independently.

The rule editor has two modes: a visual builder (Field / Operator / Value rows) and a raw expression editor. If you paste a full expression into the builder's Value box, Cloudflare accepts it, validates it, deploys it — and creates a rule whose condition is the expression text as a literal string:

# What you pasted into the Value box:
(http.request.uri.path eq "/wp-login.php") or (http.request.uri.path starts_with "/wp-admin" ...)

# What the rule actually became:
http.request.uri.path eq "(http.request.uri.path eq \"/wp-login.php\") or (...)"
#                                                          ↑ the whole expression,
#                                                            nested as a literal URL string

The Expression Preview can still look plausible. The rule shows Active. The matched-traffic stat reads 0.0% — which is the only visible symptom, and easy to misread as "quiet site." Our first deployment sat live and inert until an external test exposed it.

The defenses: (1) use Edit expression mode for pasted expressions — the raw box, where the whole expression belongs; (2) read the compiled preview — the tell is escaped quotes (\") and an outer eq " wrapping everything; (3) the matched-traffic preview should show a non-zero percentage for a rule targeting real endpoints — 0.0% on a wp-admin rule means it matches nothing.
Events empty while the broken rule was deployed

Events empty while the broken rule was deployed

Trap 2: the 500-error paste

Pasting .htaccess content (or any config file) from a notes app, chat, or an AI assistant can carry markdown formatting with it — prose, code-fence lines, bold markers. None of that is valid directive syntax: the site returns 500 Internal Server Error on every PHP request within seconds, and the ErrorDocument handler fails too. The rollback "fails" for the same reason if you re-paste the same wrapped content.

The rule: paste config from plain-text files whose last line is a real directive — nothing else. (Full story in Part 1.)

How to verify a rule is actually enforcing — the bot-side battery

A dashboard showing "Active" is a claim, not evidence. The proof is a test from outside a browser context:

# Gated paths — expect 403 + a cf-mitigated: challenge header:
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-login.php
curl -sI https://example.com/wp-login.php | grep -i cf-mitigated

# Form endpoints — expect pass-through (origin response, NOT a challenge):
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-admin/admin-ajax.php

# Content — expect 200:
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/

And for rate limits, volume matters: fire more requests than your threshold in the window and watch for the 429. One nuance we hit: if a challenge rule covers the same path, it intercepts first — every request comes back 403 and the rate limiter's block is masked. That's correct layering: the challenge stops bots at the door; the rate limit stands behind it for anything that solves the challenge.

Also verify from the human side: open a private window, hit the gated path — you should pass (invisibly or with a brief interstitial) and reach the real page. Bots fail; you don't.

The Cloudflare challenge interstitial

the challenge interstitial

What Free doesn't do (and why that's a plan decision, not a dealbreaker)

CapabilityFreePaid (Pro+)
Managed Ruleset deployment controls + php2shell gap bannerProtection banner onlyDeployment controls + targeted guidance
OWASP Core RulesetNot availableDeployable (off by default)
Rate-limit block duration10 seconds10 minutes
Custom-rule Log actionNot availableNot below Enterprise — but longer event history
Bot Fight Mode path exemptionsNone — challenges all automated trafficSuper BFM with per-category control

Free still gets the php2shell managed-rule protection by default — the banner proves it's live. But the controls (deployment verification, OWASP, meaningful rate-limit blocks) start at paid tiers. Size the plan to the site's stakes.

The near-miss worth more than the rules: media uploads are public

The best operational lesson from this build had nothing to do with WAF rules. Mid-walkthrough, incident screenshots were uploaded to WordPress's media library — including analytics views showing the origin server's IP. WordPress media is public by URL the moment it's uploaded, embedded in a post or not. Anyone with the URL pattern fetches the file.

Before uploading incident screenshots anywhere: check them for origin IPs, trusted-IP exemptions, internal paths, case/reference numbers, and anything that maps your defenses. A screenshot of your security dashboard is a recon document for anyone who reads it.

The decisions to make deliberately (a checklist, not our answers)

  • Skip rules: any rule that skips managed rules for matching traffic — by IP, path, or anything — is a published bypass. Audit every one; disable what you can't justify.
  • Bot settings: map every server-to-server integration your site has (syncs, feeds, API pushes) before touching bot-fight settings — each one is a traffic class that "block automated" will catch.
  • AI crawlers: decide deliberately which AI crawlers may crawl you. Verified-bot status and Cloudflare's AI bot policies are the controls — verified crawlers bypass automated-traffic blocks; policies gate the rest.
  • Leaked-credential checks: if your plan includes it, enable it — it blocks login attempts using passwords known to be breached. Zero impact on legitimate users.
  • SSL/TLS mode: Full (strict), explicitly — not Automatic, not Flexible. Strict validates your origin's certificate on every connection.

Questions this article answers

Which Cloudflare rules protect against php2shell (CVE-2026-109618 / CVE-2026-109619)?

The Cloudflare Managed Ruleset contains the php2shell rules — verify it's deployed with action Execute/Active on paid plans; on Free it's on by default (the protection banner confirms it). OWASP Core adds anomaly-scoring coverage.

Why is my Cloudflare custom rule not working even though it shows Active?

The most common cause: a full expression pasted into the visual builder's Value box — the rule matches the expression as a literal string and fires on nothing. Check the compiled preview for escaped quotes and nesting, and verify enforcement with an external request, not the dashboard.

Should I block admin-ajax.php in Cloudflare?

No — exclude it from wp-admin challenge rules. WordPress form plugins (WPForms, MailPoet, others) POST to /wp-admin/admin-ajax.php from public pages; challenging it breaks frontend forms silently.

Should I block xmlrpc.php?

If you use Jetpack, no — it communicates through xmlrpc.php. If you don't use XML-RPC, block it at the edge and disable it in WordPress (add_filter('xmlrpc_enabled', '__return_false')).

Can I deploy a Cloudflare rule in Log mode on Free or Pro?

No — the Log action is Enterprise-only. Use your origin access logs as the observation baseline, or deploy to Managed Challenge if your baseline shows no legitimate matching traffic.

Are WordPress media library uploads private?

No. Files in /wp-content/uploads/ are publicly fetchable by URL whether or not they're embedded in a post. Never upload incident screenshots containing origin IPs or security details.

Next in the series: Part 3 — the verification hierarchy: how to know what version your site is actually running, what to ask your host, and which answers are worth anything.

Sources

  1. [TO ADD: Cloudflare Managed Ruleset documentation — developers.cloudflare.com]
  2. [TO ADD: Cloudflare OWASP Core Ruleset documentation]
  3. [TO ADD: Cloudflare custom rules / expression language reference]
  4. [TO ADD: Cloudflare plans and Log action availability]
  5. [TO ADD: WordPress XML-RPC filter documentation — developer.wordpress.org]