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.

wp2shell CVE-2026-63030

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]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();
$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:

  1. has_valid_params() – REST schema type checking (e.g., author_exclude must be integer[])
  2. sanitize_params() – per-element sanitisation (e.g., absint())
  3. The allow_batch route 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") returns false, sanitisation skipped entirely
  • (array) "payload" produces ["payload"] – PHP’s cast puts the string in a single-element array
  • implode(',', ["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:

  1. Sub-request 0 (benign GET /wp/v2/posts) passes validation. $matches[0] is the posts handler, $validation[0] is true.
  2. Sub-request 1 (bad URL) becomes a WP_Error. $validation[1] gets the error. $matches skips an index.
  3. Sub-request 2 (malicious author_exclude string) 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.path contains /wp-json/batch/v1
  • Block requests where url_decode(http.request.uri.query) contains rest_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 FILE privilege
  • Confirm secure_file_priv points to a non-webroot path in my.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 OUTFILE or LOAD DATA
  • New rows in wp_options with autoload = yes and suspicious serialized PHP content
  • Web access logs showing POST /wp-json/batch/v1 or GET /?rest_route=%2Fbatch%2Fv1 from a single IP in a short window, followed by a request to a new PHP file

References


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