Wp2shell (CVE-2026-63030): Pre-Auth RCE Chain in WordPress Core

mazen1601 pts0 comments

wp2shell (CVE-2026-63030): Pre-Auth RCE Chain in WordPress Core

CVE-2026-63030 is a pre-authentication remote code execution vulnerability in WordPress Core affecting versions 6.9.0 through 7.0.1. A single anonymous POST to the REST API batch endpoint reaches a SQL injection that leads to direct access to the WordPress and can be escalated to a pre-auth RCE.

WordPress patched it in 6.9.5 and 7.0.2, touching three files and sixteen lines. This post covers what broke, why the bugs connect, and what teams running WordPress need to do.

Background

The patch diff between 7.0.1 and 7.0.2 is three files. The batch REST API endpoint has been present since WordPress 5.6. The bugs were introduced when the endpoint landed in the 6.9 development branch, roughly a year before disclosure.

Affected files:

src/wp-includes/rest-api/class-wp-rest-server.php<br>src/wp-includes/class-wp-query.php<br>src/wp-includes/rest-api.php

Identify wp2shell Exposure with FullHunt

FullHunt fingerprints web technologies across every host it discovers, including WordPress. Look up tag:wordpress in the FullHunt Console to list every WordPress asset already mapped across your attack surface, then cross-reference the results against the affected range (6.9.0 through 7.0.1) while you patch.

FullHunt Enterprise customers get continuous monitoring, so newly discovered or newly exposed WordPress assets are flagged automatically as they appear on the perimeter, without re-running the search by hand.

We’re also open-sourcing wp2shell-scan, a scanner for CVE-2026-63030. It ships two non-destructive detection modes, time-based and error-based, for checking your own infrastructure at scale.

The vulnerability

Bug 1: Array index desynchronisation in serve_batch_request_v1

The batch endpoint at /wp-json/batch/v1 (also reachable via ?rest_route=/batch/v1) accepts a single POST containing multiple sub-requests. Processing happens in two passes.

In the first pass, WordPress parses each sub-request URL, matches it to a route handler, validates parameters, and sanitizes them. Results go into two arrays that are supposed to stay in lockstep:

$matches[$n] – the matched route and handler tuple

$validation[$n] – true if everything passed, or a WP_Error describing what failed

In the second pass, WordPress iterates $requests by integer index $i and cross-references $matches[$i] with $validation[$i] to decide whether to execute the handler or return an error.

The vulnerable code from 7.0.1:

$matches = array();<br>$validation = array();<br>$has_error = false;

foreach ( $requests as $single_request ) {

// A request that couldn't be parsed becomes a WP_Error<br>if ( is_wp_error( $single_request ) ) {<br>$has_error = true;<br>$validation[] = $single_request; // pushed to $validation<br>continue; // $matches NOT updated -- the bug

$match = $this->match_request_to_handler( $single_request );<br>$matches[] = $match;

// ... allow_batch check, has_valid_params(), sanitize_params() ...

if ( $error ) {<br>$has_error = true;<br>$validation[] = $error;<br>} else {<br>$validation[] = true;

When a sub-request is a WP_Error (any time the URL can’t be parsed), the code pushes one entry into $validation and zero into $matches. One WP_Error is enough to permanently shift every subsequent $matches[$i] one slot ahead of where $validation[$i] expects it.

That misalignment shows up in the execution pass:

foreach ( $requests as $i => $single_request ) {

if ( is_wp_error( $single_request ) ) {<br>// WP_Error sub-requests get handled here and skipped<br>$responses[] = $this->envelope_response(<br>$this->error_to_response( $single_request ), false<br>)->get_data();<br>continue;

$match = $matches[ $i ]; // WRONG index due to the shift<br>$error = null;

if ( is_wp_error( $validation[ $i ] ) ) { // WRONG validation result<br>$error = $validation[ $i ];

$result = $this->respond_to_request( $single_request, $route, $handler, $error );

With three sub-requests (one benign, one WP_Error, one malicious) the array state after the validation pass looks like this:

$requests: [ benign_req, err_req, malicious_req ]<br>$matches: [ benign_hdl, malicious_hdl ] indices 0, 1<br>$validation: [ true, WP_Error, WP_Error ] indices 0, 1, 2<br>^-- shift applied here -----^

In the execution pass, $i = 2 (malicious request slot): $matches[2] is the malicious handler but $validation[2] is a WP_Error, so the error gate fires. But at $i = 1 (benign request slot): $matches[1] is now the malicious handler and $validation[1] is the WP_Error from the unparseable URL, not from any check on the malicious request.

The attacker controls sub-request ordering. By placing the WP_Error at the right position, they align the malicious handler with a true validation result from a different request’s clean pass, bypassing:

has_valid_params() – REST schema type checking (e.g., author_exclude must be integer[])

sanitize_params() – per-element sanitisation (e.g., absint())

The allow_batch route check

The fix was one added line:

if ( is_wp_error( $single_request ) )...

validation wordpress matches wp_error single_request error

Related Articles