Ninety minutes, then four days: the WordPress core RCE from a different perspective
On July 17, Patchstack published an account of how quickly attackers weaponised the WordPress core RCE chain — CVE-2026-60137 and CVE-2026-63030 — measuring roughly ninety minutes from public patch to live exploitation attempts. Their post closed with a section on what to grep for in your own logs.
We took them up on it. Sitting in front of over 3 million WordPress sites, we have a vantage point many don’t: we see requests as our customers’ servers answered them, responses included. Some of what we found matches their campaign data. Some diverges. And one delivery technique doesn’t appear in any public writeup we’ve seen.
From 3,640,128 WordPress site we analyzed over 6 billion records to present this data.
The bug, briefly
Two halves. A SQL injection in WP_Query via the author__not_in / author_exclude parameter, and a route-confusion flaw in the REST batch endpoint at /batch/v1. Neither is a takeover on its own. Chained, the batch confusion smuggles the injection past the schema validation that would normally sanitise it, and the published exploit walks from there to a new administrator account and remote code execution.
Affected: 6.8 through 7.0.1, with the batch half applying from 6.9. Fixed in 7.0.2, 6.9.5, and 6.8.6. Older branches genuinely aren’t affected — there was public confusion on this point and it’s worth repeating.
Read the status code
This is where a hosting vantage point earns its keep, and it’s the single most useful thing here for anyone running the same analysis.
WordPress core’s batch handler returns 207 Multi-Status. We confirmed this against core 7.0.2 source: serve_batch_request_v1() in wp-includes/rest-api/class-wp-rest-server.php returns WP_Http::MULTI_STATUS from every exit path, and this behaviour is unchanged since batch support landed in 5.6.
That makes a 207 in your access log meaningful, but in a specific and limited way:
| Status | What it means |
|---|---|
| 207 | The request reached core’s batch handler and was processed as a batch. Delivery and routing worked. |
| 500 | A fatal error occurred during dispatch, before the handler could return. More interesting than a 207. |
| 200 | Usually the ordinary page rendering — rest_route didn’t take. Check individually. |
| 403 / 404 | Blocked, or route unreachable. |
The precision matters. Core returns 207 for every outcome of that handler, including batches whose sub-requests were rejected during validation and batches where every sub-request failed its permission check. Individual failures are wrapped into the response body rather than changing the outer status. A 207 confirms the envelope was accepted and routed — not that anything executed, and not that anything succeeded.
That is still a significant finding, because reaching the handler at all means routing succeeded. For the requests described in the next section, that is the entire point.
Across the disclosure window we logged roughly 19,200 requests carrying the batch route, with 207s appearing across 139 unrelated servers running different configurations — which rules out any single server-level rule as the source. A smaller set returned 500, several within seconds of a 207 against the same site.
Attackers are prefixing WordPress’s own endpoints
The public proof-of-concept reaches the batch endpoint two ways: /wp-json/batch/v1 and /?rest_route=/batch/v1. Patchstack documented evasion variants prefixing /index.php, /wp/, and /blog/.
We saw something else:
/wp-login.php/?rest_route=/batch/v1
/xmlrpc.php/?rest_route=/batch/v1
/wp-login.php/wp-json/batch/v1
/xmlrpc.php/wp-json/batch/v1
/wp-admin/user-new.php/?rest_route=/batch/v1
These abuse PATH_INFO. The request appears to target wp-login.php or xmlrpc.php; WordPress treats the remainder as path info, reads rest_route, and routes to the batch endpoint anyway. Many of these returned 207 — meaning core resolved them to the batch handler, which is precisely what a URL-anchored rule assumes cannot happen.
Any rule that anchors on the start of the path, or that exempts known WordPress endpoints from REST inspection, is bypassed by this. The wp-admin/user-new.php variant is a pointed choice — that’s the admin-creation screen.
Match the batch route wherever it can appear, not where the proof-of-concept happened to put it. That includes the POST body: Patchstack’s coordinated disclosure covered a bypass where rest_route arrives as form data and the URL never contains batch/v1 at all. That shape is invisible in standard access logs, ours included, and we make no claim about its prevalence.
Which routes actually accept batching
Worth knowing if you’re writing your own detection. In core 7.0.2, these controllers declare allow_batch => array( 'v1' => true ):
/wp/v2/posts/wp/v2/users- terms endpoints (categories, tags)
- widgets endpoints
Attachments, font-families and font-faces are explicitly false; global-styles is explicitly 'v1' => false. Anything not declaring the property defaults to disallowed.
Two consequences. First, /wp/v2/users is batchable, so an admin-creation sub-request passes core’s structural check and proceeds to the permission callback — which is why the chain needs the SQL injection at all. It isn’t bypassing the batch allowlist; it’s forging the state that makes the permission check pass. Second, /wp/v2/plugins is not batchable, so PoC variants smuggling a plugin install sub-request are rejected during validation. Attackers evidently tried it anyway, which makes it a useful detection string even though it isn’t a viable path.
If you’re writing rules: flag batch bodies referencing posts, users, terms or widgets. Those are the ones that can actually go somewhere.
A sharded, list-driven operation
One cluster behaved unlike anything else in our data: nine addresses in a single /24 registered to a Belgrade hosting provider.
Its signature is distinctive. Exclusively the PATH_INFO evasion, never the plain form. A bare Mozilla/5.0 user-agent with no platform token. Two to three requests per site, one to three seconds apart, then gone. Predominantly 207 responses.
Over a two-hour window on July 21 this cluster touched 113 of our customer sites, and the work was clearly partitioned across workers. One address handled a run of eleven consecutive domains all beginning with the same letter. Within one franchise network of a dozen-plus regional sites, visits were strictly alphabetical: eighteen domains, no inversions.
Two things follow.
They were working a domain list, not an IP range. The same physical servers were revisited repeatedly through the window — one at 04:15, 04:18, 04:20, 04:26, 04:38 — while a single customer’s sites, hit consecutively, were spread across three servers in two clouds. Anyone sweeping IP space drains each host once and moves on. This only makes sense iterating over hostnames.
Some of those hostnames weren’t publicly discoverable. The list included staging subdomains, .dev hostnames, agency template sites, and one instance name with a numeric suffix that isn’t linked anywhere, isn’t crawlable, and isn’t realistically brute-forceable. The most plausible source is Certificate Transparency logs or passive DNS: anything with a TLS certificate is enumerable, and CT results return sorted, which would explain the alphabetical runs. We infer the source from the list’s contents rather than observing it directly — but nothing else accounts for those names.
The cleanest single example: at 12:53 and 12:54 on July 21, one worker hit a production domain and its dev. subdomain back to back, both returning 207. Adjacent entries, seconds apart. That’s what a certificate-derived list looks like in a log.
Two-stage targeting
Most sites in that sweep received two to three requests. A small number received substantially more — and those came from a different address in the same netblock, arriving later.
One site took a routine two-request probe from one worker, then a much longer session from another worker twenty minutes afterward.
A broad sweep identifies responsive targets; a separate worker returns for extended interaction. It mirrors the split Patchstack described between mass probing and a small cluster running the full chain — but visible here as a handoff inside a single netblock, with the interval between stages measurable.
Staging sites took the worst of it
Development and staging environments were markedly more likely to have the batch endpoint actually route than production sites, and showed a correspondingly higher rate of 500 responses.
The targeting is explicit rather than incidental. Our logs contain requests to hostnames beginning staging., dev., and local., plus agency template sites and .dev hostnames — and in multiple cases both halves of a production/staging pair appear consecutively.
None of this is surprising in hindsight, and it’s the most actionable finding here. Staging environments run the same core version as production, are reachable from the internet, appear in the same certificate logs, and sit last in line for emergency patching. When the window between patch and exploitation is ninety minutes, “we’ll do staging after prod” means staging doesn’t get done.
If you run WordPress at any scale: your dev sites are in the same CT logs as your production sites, and attackers are reading them.
Other things worth knowing
Forged crawler user-agents. We logged requests carrying Bingbot, Googlebot, and ClaudeBot user-agents POSTing to the batch endpoint and receiving 207s. Legitimate crawlers do not POST to REST batch endpoints. If any part of your stack grants reduced scrutiny based on bot user-agent strings, that’s an exploitable assumption — verify by reverse DNS or drop the exemption.
Cheap, high-signal filters. Two user-agent strings account for a large share of the batch traffic that reached the endpoint: bare Mozilla/5.0 with no platform token, and Python-urllib/3.x. Neither is plausible legitimate traffic to a REST batch endpoint on a normal business site. Both are one-line rules.
At least two distinct actors. The PATH_INFO cluster and the plain ?rest_route= traffic come from entirely non-overlapping IP sets — the latter spread across DigitalOcean, AWS, and Linode ranges. Different tooling, different delivery, same window.
A different provider mix. Patchstack reported traffic dominated by Vultr, M247, and HostPapa, with no single network above 6.45%. Ours skews heavily toward Azure ranges, with the Belgrade cluster and a July 19 burst forming distinct groups. Neither picture is wrong. Two sensor networks with different customer geography and different placement in the request path see different slices of the same campaign — which is an argument for reading several vendors’ data rather than any one.
A separate coordinated burst. On July 19, dozens of unrelated ASNs first appeared within a ninety-minute window, each sweeping 120–180 of our sites — near-complete coverage of our estate from each node. Structurally unlike the Belgrade cluster’s sharded list-walking, and evidence that “the campaign” was several independent operations landing on top of each other.
Timing signals without timing data. Time-based injection probes like OR SLEEP(5) are hard to spot without response-time logging. One proxy, if you run nginx: status 499 means the client disconnected before the server answered. On a POST to the batch endpoint, that’s a reasonable hint the request ran long. Not a substitute for $request_time, but free.
What we’re adding to our telemetry
Several things would have made this campaign easier to see, and we’re now capturing them:
- REST API paths as a first-class category. Most WordPress vulnerabilities of the last several years live on the REST surface, and it deserves the same standing as
wp-login.phpandxmlrpc.php. content_lengthandcontent_type. The body-based bypass — a plainPOST /withrest_routein the form body — cannot be distinguished by URL. Request shape is the only remaining signal.request_timeandbytes_sent. Time-based and boolean-based injection probes are both oracles. Without duration and response size you can see the payload but never whether it worked.- Truncated request bodies on POSTs to WordPress hosts. The only thing that sees the post-July-21 delivery shape at all. First kilobyte, short retention.
- Core version per site. Separating “attacked” from “vulnerable” requires it, and it makes every figure in a writeup like this more precise.
If you haven’t patched
Update to 7.0.2, 6.9.5, or 6.8.6 — including staging, development, and every site behind a certificate you’ve ever issued.
Then check what the exploit leaves behind: administrator accounts you didn’t create, plugins you don’t recognise, unexpected PHP files under wp-content/uploads and wp-content/plugins, and — easy to miss — anything in wp-content/mu-plugins, which auto-loads and never appears on the Plugins screen.
Our thanks to Patchstack, whose writeup prompted this analysis and supplied the initial indicators we searched on.
