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
src/wp-includes/class-wp-query.php
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]–trueif everything passed, or aWP_Errordescribing 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();
$validation = array();
$has_error = false;
foreach ( $requests as $single_request ) {
// A request that couldn't be parsed becomes a WP_Error
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$validation[] = $single_request; // pushed to $validation
continue; // $matches NOT updated -- the bug
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
// ... allow_batch check, has_valid_params(), sanitize_params() ...
if ( $error ) {
$has_error = true;
$validation[] = $error;
} else {
$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 ) ) {
// WP_Error sub-requests get handled here and skipped
$responses[] = $this->envelope_response(
$this->error_to_response( $single_request ), false
)->get_data();
continue;
}
$match = $matches[ $i ]; // WRONG index due to the shift
$error = null;
if ( is_wp_error( $validation[ $i ] ) ) { // WRONG validation result
$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 ]
-----------------------------------------
$matches: [ benign_hdl, malicious_hdl ] indices 0, 1
$validation: [ true, WP_Error, WP_Error ] indices 0, 1, 2
^-- 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_excludemust beinteger[])sanitize_params()– per-element sanitisation (e.g.,absint())- The
allow_batchroute check
The fix was one added line:
if ( is_wp_error( $single_request ) ) {
$has_error = true;
+ $matches[] = $single_request; // keep arrays in sync
$validation[] = $single_request;
continue;
}
Both arrays now receive an entry for every input. In the execution pass, $matches[$i] for a WP_Error slot is itself a WP_Error, caught by the existing is_wp_error($match) check before any handler runs.
Bug 2: Re-entrancy in serve_request and rest_api_loaded
WordPress loads the REST API through rest_api_loaded(), hooked to parse_request. It reads $wp->query_vars['rest_route'], then calls $wp_rest_server->serve_request($path). This entry point is designed to run once per HTTP request.
The batch controller dispatches sub-requests by calling dispatch() (the internal method), not serve_request(). That’s the correct separation. But nothing in 7.0.1 prevented a hook or callback triggered during batch dispatch from calling rest_api_loaded() again. If any code path during dispatch caused WordPress to re-run the main query loop with rest_route in the environment, a fresh serve_request() would start. That nested cycle has no awareness of the outer batch context, no batch permission checks, and no knowledge of the sub-request currently being dispatched.
This path is only reachable when a persistent object cache is not in use. With Redis or Memcached, early cache hits short-circuit WP_Query execution, collapsing the hook surface that enables re-entrancy. Without a cache (the default for a stock WordPress install), the full get_posts() path runs every time, firing all its hooks.
The fix added an is_dispatching() guard to both entry points.
In serve_request():
public function serve_request( $path = null ) {
+ if ( $this->is_dispatching() ) {
+ return false;
+ }
+
global $current_user;
// ...
In rest_api_loaded():
+ if ( isset( $GLOBALS['wp_rest_server'] )
+ && $GLOBALS['wp_rest_server'] instanceof WP_REST_Server
+ && $GLOBALS['wp_rest_server']->is_dispatching()
+ ) {
+ return;
+ }
is_dispatching() reads a request stack maintained inside dispatch():
protected $dispatching_requests = array();
public function is_dispatching() {
return (bool) $this->dispatching_requests;
}
While any dispatch is in flight, the stack is non-empty. Any attempt to call serve_request() from a nested hook returns immediately.
Bug 3: SQL injection in WP_Query via string author__not_in
The REST API Posts endpoint exposes an author_exclude parameter that maps directly to author__not_in in WP_Query. The REST layer validates author_exclude as type: array, items: { type: integer } and sanitises each element with absint(). Bug 1 bypasses that layer entirely.
What WP_Query::get_posts() does with author__not_in in 7.0.1:
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) {
// Only sanitised when it's an array:
$query_vars['author__not_in'] = array_unique(
array_map( 'absint', $query_vars['author__not_in'] )
);
sort( $query_vars['author__not_in'] );
}
// Always runs, even when the value is a raw string:
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
The is_array() check controls sanitisation but not execution. When the value is a string:
is_array("payload")returnsfalse, sanitisation skipped entirely(array) "payload"produces["payload"]– PHP’s cast puts the string in a single-element arrayimplode(',', ["payload"])returns"payload"- The string lands verbatim in the SQL query
The fix replaces the entire block with wp_parse_id_list(), which is safe regardless of input type:
- if ( is_array( $query_vars['author__not_in'] ) ) {
- $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
- sort( $query_vars['author__not_in'] );
- }
- $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
- $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
+ $author__not_in_id_list = wp_parse_id_list( $query_vars['author__not_in'] );
+ if ( count( $author__not_in_id_list ) > 0 ) {
+ sort( $author__not_in_id_list );
+ $where .= sprintf(
+ " AND {$wpdb->posts}.post_author NOT IN (%s) ",
+ implode( ',', $author__not_in_id_list )
+ );
+ $query_vars['author__not_in'] = $author__not_in_id_list;
+ }
wp_parse_id_list() handles both strings and arrays, and runs absint() on every element without exception:
function wp_parse_id_list( $input_list ) {
$input_list = wp_parse_list( $input_list ); // splits a string into array if needed
return array_unique( array_map( 'absint', $input_list ) ); // absint() every element
}
The attack chain
An unauthenticated attacker sends a single POST to /wp-json/batch/v1:
POST /wp-json/batch/v1
Content-Type: application/json
{
"validation": "normal",
"requests": [
{
"path": "/wp/v2/posts",
"method": "GET"
},
{
"path": "://invalid-url",
"method": "GET"
},
{
"path": "/wp/v2/posts",
"method": "GET",
"body": {
"author_exclude": "<injection payload>"
}
}
]
}
What happens:
- Sub-request 0 (benign
GET /wp/v2/posts) passes validation.$matches[0]is the posts handler,$validation[0]istrue. - Sub-request 1 (bad URL) becomes a WP_Error.
$validation[1]gets the error.$matchesskips an index. - Sub-request 2 (malicious
author_excludestring) fails schema validation at the REST layer.$validation[2]is a WP_Error. But$matches[1]is now the posts handler pointed at sub-request 2’s body.
In the execution pass, the index shift means the posts handler runs with sub-request 2’s payload but sub-request 1’s (empty) validation result. Schema checking is bypassed. The raw string reaches WP_Query::get_posts().
The re-entrancy path in Bug 2 provides an alternative route. The sub-request dispatch triggers rest_api_loaded() again through a hook, starting a fresh serve_request(). That cycle processes the malicious author_exclude as a top-level request, also bypassing REST layer validation. On a site without Redis or Memcached, this path is independently reachable.
Either way, get_posts() receives a string author__not_in. The is_array() guard returns false, sanitisation skips, and the value interpolates directly into:
AND wp_posts.post_author NOT IN (<your_string_here>)
What determines whether SQLi becomes RCE
The injection gives read access to the full database. Escalation depends on the MySQL configuration:
| Condition | Impact |
|---|---|
MySQL FILE privilege granted to the WP DB user |
Direct file write via INTO OUTFILE is possible |
secure_file_priv is empty or points to webroot |
File can land in a web-accessible path |
| Webroot writable by the MySQL process | Shell is reachable immediately |
No FILE privilege |
RCE still possible via UPDATE wp_options (auto-loaded PHP eval paths, malicious plugin data) |
Stock shared hosting and self-managed single-server WordPress installs frequently have FILE privilege enabled and the webroot writable by the database process. Managed cloud deployments with a separated DB and locked-down MySQL user are harder to escalate beyond data exfiltration, but the SQLi itself still works.
Why these bugs existed
The parallel array bug is the simplest. serve_batch_request_v1 assumes $matches and $validation stay the same length. They grow through []= assignments in different branches of the same loop, with nothing enforcing that both arrays are updated together. Whoever added the WP_Error branch would focus on $validation (where the error just landed) and miss that $matches needed an entry too. Using two parallel arrays instead of a single struct per sub-request is what made this possible to miss in the first place.
WP_Query is a different kind of problem. It was written expecting author__not_in to always be an integer array, because historically callers passed integer arrays. The is_array() check ran absint() per element. The else branch was not written with user-controlled strings in mind – (array) was a convenience cast before implode, not a trust boundary. When the REST API started accepting JSON bodies, that assumption quietly broke. Nothing in WP_Query was updated.
The re-entrancy bug is the one that would be hardest to catch in review. The batch controller correctly calls dispatch() (internal) rather than serve_request() (external). That’s the right separation. But WordPress’s hook system lets any action or filter run arbitrary code during execution, including inside get_posts(). rest_api_loaded() had no guard against being invoked from within an active dispatch, and for a year nothing triggered that path.
Affected versions
| Branch | Affected | Fixed |
|---|---|---|
| Before 6.9 | Not affected (batch endpoint absent) | |
| 6.8 | 6.8.0 through 6.8.5 (SQLi only, no full chain) | 6.8.6 |
| 6.9 | 6.9.0 through 6.9.4 | 6.9.5 |
| 7.0 | 7.0.0 through 7.0.1 | 7.0.2 |
| 7.1 | 7.1-beta1 | 7.1-beta2 |
WordPress auto-updates affected installs where automatic updates are enabled. Verify the version manually. Auto-update completion is not guaranteed on every host configuration.
Mitigation
Patch first. Update to 6.9.5, 7.0.2, or 7.1-beta2. That’s the only complete fix.
If patching isn’t immediately possible, block the batch endpoint at the WAF or load balancer:
- Block requests where
http.request.uri.pathcontains/wp-json/batch/v1 - Block requests where
url_decode(http.request.uri.query)containsrest_route=/batch/v1
The url_decode() step is necessary. Cloudflare’s URL normalization preserves reserved %2F escapes, so a raw string match on rest_route=/batch/v1 misses ?rest_route=%2Fbatch%2Fv1. Decoding before matching catches both forms.
At the database layer:
- Confirm the WordPress DB user does not have
FILEprivilege - Confirm
secure_file_privpoints to a non-webroot path inmy.cnf
These don’t prevent the SQLi but remove the direct file-write escalation path.
Indicators of compromise
If investigating whether a host was hit before patching, check:
- Unexpected PHP files in the webroot, especially recently created ones
- WordPress error logs with malformed batch API requests (entries containing
://in sub-request paths) - MySQL general query log entries containing
INTO OUTFILEorLOAD DATA - New rows in
wp_optionswithautoload = yesand suspicious serialized PHP content - Web access logs showing
POST /wp-json/batch/v1orGET /?rest_route=%2Fbatch%2Fv1from a single IP in a short window, followed by a request to a new PHP file
References
- Searchlight Cyber / Adam Kues – wp2shell advisory
- Rapid7 ETR – CVE-2026-63030
- WordPress 7.0.2 release notes
- GitHub diff: 7.0.1 to 7.0.2
- Wordfence threat intel – CVE-2026-63030
- FullHunt wp2shell-scan – open-source scanner for CVE-2026-63030
Are you an enterprise looking to enhance your security posture with advanced attack surface management, vulnerability intelligence, and threat detection? Contact us at [email protected] or visit our contact page to learn how FullHunt can help secure your organization.
Discover unknown assets today and protect your organization
Best regards,
Mazin Ahmed
The FullHunt Team
#JoinTheHunt