
WordPress runs about 43% of all websites (W3Techs). In July 2026, WordPress shipped a coordinated security release to close a serious issue: an unauthenticated remote code execution that works against a stock install with no plugins and no special configuration.
Adam Kues of Assetnote / Searchlight Cyber reported the issue and published a public checker at wp2shell.com. The technical details were held back to give administrators time to patch. This post reconstructs the root cause from the released patch, explains why the bug is so easy to reach, and shares the safe detection we built for it.
Technical overview
Exposure risk
The batch API is part of WordPress core, on by default, and reachable without authentication. It does not need pretty permalinks: the endpoint answers on both /wp-json/batch/v1 and the always-available /?rest_route=/batch/v1 form, so installs that turn off rewrite rules are still exposed. Any affected-version site that exposes the REST API, which is almost all of them, is in scope. There is no login page in front of it and no plugin to blame.
Impact assessment
An attacker who reaches the bug gains unauthenticated code execution on the web server. In practice that means full control of the site and its content, access to the database and whatever credentials or personal data it holds, and a way into the surrounding hosting environment. For the large share of WordPress sites that share hosting infrastructure, one compromised instance is rarely the end of the blast radius.
Detection challenges
The exploit looks like ordinary REST traffic. It is a single POST to a legitimate core endpoint with a well-formed JSON body, hard to tell apart from the batch requests the block editor sends during normal editing. It carries no shell metacharacters, no path traversal, and nothing large enough to trip a length-based rule. Sites that strip the generator version string, a common hardening step, also remove the one passive signal that would flag an affected version. Any detection that reads the version off the homepage will miss exactly the installs that bothered to harden.
Technical breakdown
The batch API lets a client bundle several REST calls into one request. Core resolves each sub-request to a route and handler, validates them, then dispatches them in a second pass. While it does this it keeps three parallel lists, $requests, $validation, and $matches, and depends on all three staying index-aligned.
In WP_REST_Server::serve_batch_request_v1(), a sub-request that fails early (for example, a path that cannot be parsed as a URL) is recorded as an error and skipped:
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$validation[] = $single_request; // recorded here...
continue; // ...but NOT in $matches
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
// ...permission and validation checks...
}
The error is appended to $validation but not to $matches. From that point on, $matches is one element shorter than $requests and $validation. The dispatch loop then indexes straight into it:
foreach ( $requests as $i => $single_request ) {
// ...
$match = $matches[ $i ]; // now off by one for every request after the error
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
Every sub-request after the deliberately broken one is now dispatched against the route and handler that belong to the next sub-request in the batch, including that handler's permission callback. An attacker who controls the order of the sub-requests controls which request object gets evaluated against which permission check. A benign, low-privilege request can pass the permission gate that a privileged handler was meant to enforce, and the privileged handler then runs. Chained to the other sinks reachable through the batch route, that handler confusion is what becomes code execution.
The fix is two lines. Core keeps the arrays aligned by recording the error in $matches as well, and adds a re-entrancy guard so a sub-request can no longer start a fresh top-level REST cycle in the middle of a dispatch:
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;
}
Both changes are identical across the 6.9 and 7.0 branches (6.9.4 to 6.9.5 and 7.0.1 to 7.0.2).
Observed attack chain
- Send an unauthenticated POST to /?rest_route=/batch/v1 with "validation":"normal" and an array of sub-requests.
- Make the first sub-request fail to parse. A path such as http://: returns a WP_Error before matching, and it is the element that never reaches $matches.
- Every following sub-request is now dispatched against the next handler's route and permission callback.
- Order the remaining sub-requests so a privileged handler is evaluated against a permission check it would otherwise fail, steering execution into a reachable sink.
- The batch endpoint performs an action the caller was never authorized to make, ending in code execution.
Detection
Because the desync is a property of how core dispatches the batch, not of any version string, you can confirm it directly and safely. A crafted batch produces a response that only a vulnerable core returns, and it changes nothing on the target.
The probe sends three sub-requests: a malformed first entry to trigger the desync, a DELETE against /wp/v2/categories/0, and a POST against the block-renderer route.
{"validation":"normal","requests":[
{"method":"POST","path":"http://:"},
{"method":"DELETE","path":"/wp/v2/categories/0"},
{"method":"POST","path":"/wp/v2/block-renderer/core/paragraph"}]}
On a vulnerable server the categories sub-request comes back with the block-renderer's permission error, block_cannot_read, which shows its request object was evaluated against the block-renderer handler. On a patched server the same sub-request is handled correctly and returns rest_term_invalid. The difference is deterministic.
The probe is non-destructive by design. Category id 0 never exists, the request is unauthenticated so no write capability is ever granted, and on the vulnerable path the sub-request lands in a read-permission check that never reaches deletion logic. In the lab, firing the probe repeatedly left the category set untouched.
Recommended actions
Immediate:
- Update WordPress core to 6.9.5 or 7.0.2 (or later). Check that the update actually applied. Auto-updates can stall quietly on file-permission or disk problems.
- If you cannot update right away, block unauthenticated access to the batch endpoint at the WAF or web server, covering both /wp-json/batch/v1 and /?rest_route=/batch/v1. Blocking only one form leaves the site reachable.
Short term:
- Add a must-use plugin that rejects anonymous requests to the batch route until the core update is in place.
- Inventory every WordPress instance you run, including the staging and campaign sites everyone forgets. Those are the ones that miss the update.
Long term:
- Limit the REST API surface for unauthenticated users on sites that do not need it.
- Treat "core is on auto-update" as something to verify, not a control to assume.
The structural problem
REST batch APIs exist to save round trips. To deliver that, though, they take on something genuinely awkward: running several independent requests, each with its own permissions, inside a single privileged dispatch. The security of the whole arrangement rests on one quiet assumption, that every request stays tied to its own permission check for the entire cycle. In the affected versions of WordPress Core, that assumption breaks by a single index.
When the batch handler skips a malformed entry, the array tracking matched handlers drifts out of step with the array of requests, and from that point on each request is authorized against a handler that belongs to someone else in the batch. An operation you were never allowed to perform is suddenly waved through, because the server checked the wrong request's permission.
Detection and tooling
Hadrian detects this across attack surfaces which confirms the desync instead of guessing from a version string. It does not miss version-stripped ones. It is safe to run and changes nothing on the target.



