On July 17, 2026, WordPress shipped 7.0.2 — a security release urgent enough that the core team enabled forced auto-updates across affected installs, something they very rarely do. The release closed a two-bug chain that let an anonymous HTTP request reach code execution on a stock WordPress site with no plugins and no unusual configuration.
The researcher who found the more interesting half of it, Adam Kues of Assetnote / Searchlight Cyber, named it wp2shell.
First, a correction to how this gets described
7.0.2 did not fix something that 7.0.1 broke. 7.0.1 was simply the last release that still had the bugs. The two flaws have different lineages:
| CVE | Introduced | Fixed in | |
|---|---|---|---|
SQL injection in WP_Query | CVE-2026-60137 | 6.8 branch | 6.8.6, 6.9.5, 7.0.2 |
| REST batch route confusion | CVE-2026-63030 | 6.9 branch | 6.9.5, 7.0.2 |
Anything before 6.8 is unaffected. 6.8.x sites had the SQLi only; 6.9 and 7.0 sites had both, which is what makes the full chain possible. WordPress 7.1 beta was affected too and was patched in beta2.
Bug one: a sanitisation guard that trusts the caller’s type
WP_Query accepts an author__not_in parameter — a list of author IDs to exclude. Core sanitises it, but the sanitisation sits behind an is_array() check.
Pass a string instead of an array and the guard simply doesn’t fire. The value falls through unsanitised and gets interpolated directly into the NOT IN (...) clause of the generated SQL.
That’s the whole bug. It’s a classic type-confusion-adjacent mistake: the code validated the shape of the input rather than the input itself, and the fast path for “not an array” quietly meant “not checked.”
WordPress rates this one Moderate on its own, which sounds low for an injection until you notice the word “facilitated” in the advisory title. On its own, reaching author__not_in with attacker-controlled data required an already-authorised context. It was a loaded gun with no trigger.
Bug two: the trigger, and it’s a single array index
The REST batch endpoint (batch/v1) exists so a client — mostly the block editor — can bundle several REST calls into one round trip. It’s been in core since 5.6 in 2020, it’s on by default, and it’s reachable unauthenticated at both /wp-json/batch/v1 and /?rest_route=/batch/v1. That second form works even on sites with rewrite rules disabled, which matters for anyone who thought they’d reduced their surface area.
WP_REST_Server::serve_batch_request_v1() runs in two passes. Pass one resolves each sub-request to a route and handler and validates it. Pass two dispatches them. To connect the passes, it keeps three parallel arrays — $requests, $validation, and $matches — and assumes all three stay index-aligned.
They don’t. Here’s the shape of the resolution loop in the affected versions:
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$validation[] = $single_request; // recorded here...
continue; // ...but never in $matches
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
// ...permission and validation checks...
}
A sub-request that fails early — say, a path that can’t be parsed as a URL — gets appended to $validation and skipped. It never lands in $matches. From that point on $matches is one element shorter than the other two.
The dispatch loop then indexes straight into it:
foreach ( $requests as $i => $single_request ) {
$match = $matches[ $i ]; // off by one for everything after the error
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
Every sub-request after the malformed one is dispatched against the route and handler belonging to the next sub-request in the batch — including that handler’s permission callback.
That last clause is the vulnerability. An attacker who controls the ordering of sub-requests controls which request object gets measured against which permission check. Put a harmless, low-privilege request where the gate is, and a privileged handler behind it, and the gate passes on the wrong request while the privileged handler runs. It is an authentication bypass built entirely out of arithmetic.
Chain that to the author__not_in injection and the trigger is attached to the gun: an anonymous POST reaches a query sink it should never have been able to touch. From there the published route to full compromise is unglamorous — dump the database, lift and crack the admin hash, log in, and use the theme or plugin file editor to write PHP. On a stock install, admin access is code execution.
What the patch actually does
Two changes, and they’re small enough to read in one sitting:
public function serve_request( $path = null ) {
+ if ( $this->is_dispatching() ) {
+ return false;
+ }
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
+ $matches[] = $single_request;
$validation[] = $single_request;
continue;
}
The first change records the error in $matches as well, so the arrays stay aligned no matter how many sub-requests fail. Every request is once again evaluated against its own handler.
The second is a re-entrancy guard: a sub-request can no longer kick off a fresh top-level REST cycle in the middle of an existing dispatch. That one is defence in depth — it closes off the nesting behaviour that made the confusion reachable in the first place, rather than just correcting the symptom.
The author__not_in fix follows the same logic on the sanitisation side: stop trusting the incoming type and normalise the value before it can reach the query builder.
Both batch-API changes are identical across the 6.9 and 7.0 branches.
How it was found, and the disclosure timeline
Kues reported the batch-route bug through WordPress core’s HackerOne program. The SQL injection was reported separately and independently by TF1T, dtro, and haongo — the two halves came from different people, and neither is dangerous in isolation, which is a decent argument for triaging “moderate” bugs with the rest of the codebase in mind.
- July 17 — 7.0.2, 6.9.5, 6.8.6, and 7.1 beta2 ship. Forced auto-updates enabled. Searchlight publishes an advisory withholding technical detail, plus a public exposure checker at wp2shell.com.
- Within hours — other researchers diff the patch. The root cause is public by the weekend, followed by proof-of-concept code on GitHub.
- July 20 — full technical writeups circulate.
Three days from patch to public exploit is roughly the modern floor for a bug this legible in the diff. Any site relying on a human to click “Update Now” was inside that window.
What to do if you’re still not sure
Check the version in Dashboard → Updates, or run wp core version. Anything other than 7.0.2, 6.9.5, or 6.8.6 (or later) needs updating now.
If you genuinely can’t update yet, block anonymous access to the batch endpoint at the WAF — both /wp-json/batch/v1 and ?rest_route=/batch/v1, since covering only one leaves the site reachable. Treat that as an emergency stopgap, not a fix; it will break legitimate block-editor traffic.
And verify the update actually applied. Background updates fail quietly on file-permission and disk issues, and “auto-updates are on” is a control to check, not one to assume.
The part worth keeping
Batch APIs take on something structurally awkward: running several independent requests, each with its own permissions, inside a single privileged dispatch. The whole arrangement rests on the assumption that every request stays bound to its own permission check for the entire cycle.
In WordPress, that assumption broke by one array index — and neither bug was, by itself, alarming enough to be treated as an emergency. If you maintain anything with a similar shape, the takeaway isn’t “sanitise your inputs.” It’s that parallel arrays holding security-relevant state are a liability, and that a “moderate” finding is only moderate until someone finds the other half.
References: WordPress 7.0.2 release announcement · GHSA-ff9f-jf42-662q · GHSA-fpp7-x2x2-2mjf · Searchlight Cyber advisory