When we published our advisory on July 17, we ended it with the usual line: update immediately. We meant it, but we also knew what tends to happen next. A critical, unauthenticated, pre-auth chain in WordPress core is exactly the kind of bug that gets reverse-engineered from the patch and thrown at the entire internet within hours.
This time we had a front-row seat. In the days after disclosure Patchstack blocked more than 65,000 exploitation attempts against these two vulnerabilities, from more than 1,500 unique IP addresses. It’s important to add here that Patchstack RapidMitigate only activates mitigation rules on the websites with actual vulnerable versions present (and removes them when the vulnerability gets patched) – meaning that each of the exploitation attempts we logged were targeted against unpatched vulnerable sites. Furthermore, as Patchstack sits within the application itself, any network- or server-level WAF in front of these sites had already let the request through.
This post is the chronological story of that campaign: how quickly it started, what the traffic actually looked like as it evolved, and what it means for anyone who hasn’t patched yet.
A quick recap of the bug
If you missed the first post, the short version is that this was a two-part chain, not a single flaw:
- CVE-2026-60137, a facilitated SQL injection in
WP_Queryvia theauthor_exclude/author__not_inparameter. It affects WordPress 6.8 through 7.0.1. - CVE-2026-63030, a route/handler confusion in the REST API batch endpoint (
/batch/v1). It affects 6.9 through 7.0.1.
On their own, neither is a one-shot takeover. The SQL injection is read-only (it lives in a SELECT context, so it reads data rather than writing it), and the batch confusion doesn’t hand an attacker a magic admin session. But chained together, the batch confusion lets an unauthenticated request smuggle the SQL injection past the schema that would normally sanitize it, and from there the public exploit walks the rest of the way to a new administrator and, ultimately, remote code execution.
Both issues are fixed in 7.0.2, 6.9.5, and 6.8.6. And a point worth clearing up, because we saw some confusion about it in the community: older branches like 6.1 and 6.2 are not affected. We checked the source directly across releases. The vulnerable author__not_in code was introduced in 6.8, and the batch desync in 6.9. Anything older simply doesn’t contain the bug. If a pre-6.8 site was compromised, it was through a different door.
The clock starts: July 17
The fix landed in the WordPress trunk about 1.5 hours before 7.0.2 was tagged for release. That commit is effectively the disclosure: anyone watching core development can diff it and understand exactly what changed and why.
That’s the window that matters. The first real exploitation attempts hit our sensors roughly 90 minutes after 7.0.2 was released, three hours after the fix was committed. That’s the whole runway defenders got: a commit, a diff, and then payloads on the wire before most site owners had even seen the release notes.
Ninety minutes is fast, but it’s not surprising anymore. It’s what “industrialized” exploitation looks like: someone reads the patch, someone else turns it into a scanner, and the scanner gets pointed at everything.
The campaign ramps up
What followed over the next few days was less a targeted operation and more a land rush. The traffic had the fingerprints of a lot of different actors arriving at once rather than one coordinated botnet:
- More than 1,500 unique source IPs.
- The single busiest network accounted for only 6.45% of the traffic, and the top ten networks together made up about 22%.
Almost all of it came from VPS and cloud providers: Vultr (Choopa), M247, HostPapa, and a long tail of hosting ranges. That mix is exactly what you’d expect in the first days after a high-profile disclosure: researchers validating the public proof-of-concept, internet-wide scanners sweeping for vulnerable versions, opportunistic operators, and bot herders bolting the new exploit onto frameworks they already run.
One important caveat about this data: what we see is blocked attempts, not completed compromises. So the fact that most of the traffic was early-stage SQL injection probing doesn’t mean the campaign was “just reconnaissance.” It means we intercepted these requests at the earliest observable stage of the published chain. On an unprotected site, the same requests keep going, to admin creation, and then to code execution.
What the traffic actually looked like
It nearly all went through the batch endpoint
97% of everything we blocked targeted the REST batch endpoint. Attackers alternated between the two ways to reach it:
/?rest_route=/batch/v1/wp-json/batch/v1
The ?rest_route= form was the more common of the two. On top of that, they sprayed variants clearly designed to slip past naive path-matching filters:
/index.php?rest_route=/batch/v1
/wp/?rest_route=/batch/v1
/blog/?rest_route=/batch/v1
/wp-json?rest_route=/batch/v1
This is the route-confusion half of the chain (CVE-2026-63030) doing its job as the delivery vehicle. The malicious logic usually wasn’t visible in the outer request at all, it was tucked inside nested requests arrays that only reveal themselves once you parse the batch body recursively.
The SQL injection came in every obfuscated flavor
Around three-quarters of the blocked requests carried an injection attempt in the author_exclude parameter. The payloads made heavy use of the usual evasion tricks: mixed-case keywords, inline comments, MySQL conditional comments, and URL encoding.
AND (1=1)
AnD (1=1)
and(1=1)
AND/**/(1=1)
/*!AND*/
OR SLEEP(5)
UNION SELECT
The variety is the point. Payloads mutate constantly, so signatures that only match one spelling age badly. Behaviorally, though, the shape stays the same: a batch request, a call into the vulnerable REST endpoint, and a non-integer author_exclude value where only integers belong.
Two kinds of payloads: probing, then the full chain
Sorting the traffic by intent, two clear buckets emerged.
The first, and by far the largest, was SQL injection validation: boolean and time-based probes (AND (1=1), AND (1=2), OR SLEEP(5)) meant to confirm whether a target was vulnerable before investing in it. Standard scanner behavior, find the soft targets first.
The second bucket was small but much more serious. A tiny cluster of requests carried the complete, published privilege-escalation chain, not stopping at “is this vulnerable?” but going straight for administrator creation. Each embedded something equivalent to this inside the batch body:
{
"method": "POST",
"path": "/wp/v2/users",
"body": {
"username": "...",
"email": "...",
"password": "...",
"roles": ["administrator"]
}
}
Those full-chain requests came from just three IP addresses: 129.121.77.134, 91.202.233.61, and 125.164.233.50. In other words, most of the internet was still knocking on doors while a small number of operators were already trying to walk through them.
What this means if you haven’t patched
Here’s the impact in plain terms. The published exploit (circulating publicly as a tool nicknamed wp2shell) uses the read-only SQL injection to forge data that WordPress itself then acts on, seeding posts and a customizer changeset that lead to a fresh administrator account. Once the attacker has an admin, the rest is routine: log in, upload a plugin that’s really a webshell, and execute code. We independently verified that the combined vulnerabilities lead to full site takeover, including RCE.
So even though the injection can’t write to your database directly, the outcome on an unprotected 6.9 to 7.0.1 site is the worst case: an attacker-controlled admin and a shell. If you’re running an affected version, assume the window we described above applies to you too, and check for unfamiliar administrator accounts, unexpected plugins, and stray PHP files in your uploads directory.
How the protection held up
For sites behind Patchstack, this campaign was a non-event. We deployed RapidMitigate rules for both CVEs the moment the advisory went out, before the first attempts arrived.
99.9% of all blocked attempts were stopped by the two rules attached to the WordPress core vulnerabilities. We also deployed some additional generic rules but they barely registered, which is the outcome we want: the specific, root-cause rules caught the attacks upstream, before anything reached the generic backstops.
A twist: the rules almost everyone shipped were bypassable
While we kept digging into the traffic, our team found something that changed how we think about this whole class of mitigation. Nearly every WAF rule published in the first hours after disclosure shared the same blind spot.
The rules all keyed on the request URL. They looked for batch/v1 in the path or query string, because that is how every public proof-of-concept reached the endpoint, either as /wp-json/batch/v1 or /?rest_route=/batch/v1. That assumption turns out to be wrong.
WordPress registers rest_route as a public query variable, and when it parses an incoming request it reads those variables from the POST body before it reads them from the query string. In other words, you can send a plain POST / with rest_route=/batch/v1 in the form body, put the batch payload in the body alongside it, and WordPress will happily route the whole thing to the batch endpoint. The URL never contains batch/v1 at all. Any rule that only inspects the URL sees a boring request to the site root and waves it through.
Because this gap was industry-wide rather than specific to any one product, we handled it as a coordinated disclosure instead of publishing it openly first. Before this post went out, we reached out to the security companies whose rules were affected by the bypass, and to the WordPress security team, who communicated it onward to the major web hosts and DNS-level WAF providers so they could update their rules first. Only once that was underway did we publish this write-up.
We want to be straight about this: our first rules had the same gap. For the first days the published PoCs all used the query-string form, and the URL-based rules held up against the real campaign described above. We have since updated our rules to look for the batch route wherever WordPress will actually accept it, in the URL and in the POST body, and we would strongly encourage anyone maintaining their own rules for this bug to do the same. If your mitigation only matches on the URL, it is one small request change away from being bypassed.
From theoretical to observed
This is no longer hypothetical. Starting around 17:00 UTC on July 21, we began seeing exploitation attempts in our own telemetry that drop the query-string form the public PoCs shipped with and instead deliver the batch route and payload through the alternative shapes described above, with rest_route and the requests array carried in the POST body.
We were not the only ones to see it. During the coordinated disclosure, other hosts reported the same pattern against their own traffic: their original URL-only rules being bypassed by the alternative-shape payloads.
That is the whole difference between a theoretical gap and a live one, and it is why we did not sit on this. Once a base exploit is public, attackers iterate on the delivery shape specifically to slip past mitigations that only matched the obvious form. A rule written against the PoC you can see is always one request rewrite behind the payload you cannot.
What to grep for
If you run WordPress and want to check your own logs for this campaign, the highest-signal indicators are:
- The batch endpoint reached in any form:
/wp-json/batch/v1,?rest_route=/batch/v1, orrest_route=/batch/v1in the POST body (plus the/index.php,/wp/,/blog/path variants). Watch the parameter, not just the URL: therequestsarray can arrive as a JSON body, form-encoded asrequests[0][path]=..., or in the query string. - A non-integer
author_excludevalue, particularly withUNION,SLEEP(, or comment-obfuscatedAND/OR. Also match the spellingsauthor.excludeandauthor exclude, which WordPress normalizes back toauthor_exclude. - A privileged REST write nested inside a batch body. Most commonly
POST /wp/v2/userscarrying"roles":["administrator"](admin creation), but PoC variants also smugglePOST /wp/v2/plugins(plugin install/activate) and other privileged routes. Any privileged write tucked inside a batch sub-request is worth flagging, whether or not it would have succeeded. - After the fact: a new administrator you didn’t create, a recently added plugin you don’t recognize, a random PHP file under
wp-content/uploadsorwp-content/plugins, and, easy to miss, anything unexpected inwp-content/mu-plugins, which auto-loads and doesn’t appear on the Plugins screen.
The takeaway
The story here isn’t really the bug; it’s the timing. Ninety minutes from public patch to live exploitation is not enough time to schedule a maintenance window, test an update, and roll it out, which is precisely why virtual patching exists. Update to 7.0.2, 6.9.5, or 6.8.6 as soon as you can. And if “as soon as you can” is measured in days rather than minutes, make sure something is standing in the gap while you get there.


