Get in Touch

Tell us a bit about yourself — someone from our team will reach out shortly.

Max file size 10MB.
Uploading...
fileuploaded.jpg
Upload failed. Max size for files is 10 MB.
Send Request
Send Request
Request sent!
Thanks for reaching out - we've got your details. Someone from the Emphere team will reach out shortly.
Typical response:
within one business day
Done
Done
Oops! Something went wrong while submitting the form.

Emphere Engineering

Assurance

CVE-2026-63030 and CVE-2026-60137: schema-validation bypass reaches an unauthenticated SQL injection in WordPress core

WordPress 7.0.2 fixes a batch REST index-misalignment bug that let a raw string skip schema coercion and reach an unauthenticated SQL injection sink in WP_Query.

Table of content
1.
Lorem ipsum dolor
  • July 18, 2026
  • 4 min read

WordPress 7.0.2 fixes a two-CVE chain in core: CVE-2026-63030, a REST batch-request index-misalignment bug, exposes CVE-2026-60137, an unauthenticated SQL injection sink in WP_Query.

The important part is what this was not. The posts collection was already public by design, and WP_REST_Posts_Controller::get_items_permissions_check() returns true for read access. The gate protecting this parameter was schema coercion, not authentication, and the batch bug let a raw string step around that gate.

Emphere Evidence

[
  ["affected source verified", "WordPress 7.0.1"],
  ["fix verified here", "WordPress 7.0.2, red on 7.0.1 and green on 7.0.2"],
  ["also fixed per the WordPress 2026-07-17 advisory", "6.9.5 and 7.1 Beta 2 carry the chain fix; 6.8.6 patches the SQLi sink only, since the batch-confusion bug (CVE-2026-63030) does not affect 6.8"],
  ["ship date", "2026-07-17"],
  ["reproduced by Assurance", "Unauthenticated SQLi reachability, red on 7.0.1 and green on 7.0.2"],
  ["fidelity", "Focused fidelity: real WordPress routing, schema, and WP_Query WHERE-clause construction"],
  ["not used", "No live HTTP, no database execution, no downstream callback bodies"]
]

The vulnerable path

The sink is in src/wp-includes/class-wp-query.php. Before the patch, author__not_in was sanitized only when it arrived as an array. A string skipped the absint() path, got cast into a one-element array, got joined, and landed directly in the SQL WHERE clause.

if ( ! empty( $query_vars['author__not_in'] ) ) {
    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) ";
}

On its own, that sink is normally not reachable with a raw string through the REST posts collection. The REST schema declares author_exclude, which maps to author__not_in, as type: array with integer items. Under honest dispatch, a string is rejected before WP_Query sees it.

StepWhat happens1A batch request includes one sub-request that fails to parse before the crafted request.2The failed sub-request appends to $validation[], but not to $matches[].3The later response-building loop indexes $requests[], $validation[], and $matches[] by the same $i.4$matches[] is now one slot short, so later requests are paired with the wrong route match.5A crafted POST /wp/v2/posts body carries author_exclude as a raw string.6Those params are matched against a different slot's route, so the schema that would reject the string is not the schema being applied.7get_items() executes as the public GET /wp/v2/posts handler with unsanitized author_exclude.8author_exclude reaches WP_Query as raw author__not_in and enters the SQL WHERE clause.

That is the chain. It is not an authentication bypass. It is a schema-validation bypass caused by batch index drift, reaching a parameter that was public but supposed to be type-constrained.

What red looked like

Our red run used WordPress 7.0.1 with real core routing, schema handling, and WP_Query WHERE-clause construction. The database execution path, live HTTP layer, and downstream callback bodies were stubbed, because the question we were testing was narrower: can an unauthenticated batch request carry a raw string past schema coercion into the SQL sink?

Emphere Evidence

[
  ["version", "WordPress 7.0.1"],
  ["payload shape", "Batch request with one parse-error sub-request ahead of the crafted POST /wp/v2/posts"],
  ["crafted value", "author_exclude = \"0) UNION SELECT user_pass FROM wp_users -- \""],
  ["observed drift warning", "Undefined array key 2 at class-wp-rest-server.php:1836"],
  ["routing result", "get_items() received author_exclude as a raw string"],
  ["sink result", "The attack string reached the WP_Query WHERE-clause build"]
]

The control matters as much as the red. With the same attack string and no batch index drift, normal routing returned 400 rest_invalid_param. get_items() was never reached, which is exactly what should happen when the posts collection schema sees a string where it expects an array of integers.

Emphere Evidence

[
  ["control input", "Same attack string, no drift"],
  ["expected response", "400 rest_invalid_param"],
  ["handler reached", "get_items() was not reached"],
  ["what it proves", "The schema normally rejects the string. The batch index drift is what bypasses it."]
]

The fix that closes this chain

The chain closes in WP_REST_Server::serve_batch_request_v1(). WordPress 7.0.2 adds the failed parse result to $matches[] in the same early-continue branch that already appended to $validation[]. That keeps $matches[], $validation[], and $requests[] in lockstep even when earlier sub-requests fail to parse.

Emphere Diff

{  "file": "src/wp-includes/class-wp-rest-server.php",  
  "diff": "@@\n      if ( is_wp_error( $single_request ) ) {\n          $validation[] = $single_request;\n+         $matches[] = $single_request;\n          continue;\n      }\n"
}

That is the line that changes the reproduced behavior. After the patch, the crafted POST request routes to its true handler, create_item, and the unauthenticated caller gets rejected there. The public get_items() path only sees the legitimate GET sub-request, with its default empty author_exclude.

Emphere Evidence

[
  ["version", "WordPress 7.0.2"],
  ["same batch payload", "Yes"],
  ["crafted POST route", "Routes to create_item"],
  ["unauthenticated result", "401 rest_cannot_create"],
  ["public GET route", "get_items() runs only for the legitimate GET sub-request"],
  ["sink result", "The raw string never reaches WP_Query"]
]

The second hunk is real, but it is not this fix

WordPress 7.0.2 also adds a re-entrancy guard around REST dispatch. That hardening is genuine: Assurance tested it separately as defense-in-depth against a plugin triggering a nested serve_request() call while a dispatch is already in flight, which could otherwise re-read superglobals mid-request and create a response-smuggling risk.

It does not close the unauthenticated SQLi chain above. A nested serve_request() re-authenticates from scratch against the same unauthenticated identity, so the permission gate still fires. For this chain, the one-line $matches[] append is the closing fix.

Emphere Diff

{  "file": "src/wp-includes/rest-api.php",  
  "diff": "@@\n+ if ( isset( $GLOBALS['wp_rest_server'] )\n+     && $GLOBALS['wp_rest_server'] instanceof WP_REST_Server\n+     && $GLOBALS['wp_rest_server']->is_dispatching()\n+ ) {\n+     return;\n+ }\n"
}

Emphere Diff

{  
  "file": "src/wp-includes/class-wp-rest-server.php",  
  "diff": "@@\n+ if ( $this->is_dispatching() ) {\n+     return false;\n+ }\n"
}

Our source-diff research also found sink-side hardening in the same release: class-wp-query.php now applies wp_parse_id_list() to author__not_in unconditionally, whether the input is a string or an array. We did not gate-verify that hunk as the closure for this chain, so we are treating it as source-verified defense-in-depth at the sink, not as the fix our red/green result depends on.

Patch behavior

CaseWordPress 7.0.1WordPress 7.0.2What changedCrafted batch with prior parse failureauthor_exclude reaches get_items() as a raw stringCrafted POST routes to create_itemBatch arrays stay alignedSame string without drift400 rest_invalid_param400 rest_invalid_paramNormal schema rejection still holdsPublic posts collectionPublic read path remains publicPublic read path remains publicNo authentication model changedNested REST dispatchSeparate hardening gapis_dispatching() guard returns earlyDefense-in-depth, not the SQLi closure

The useful lesson here is narrow and mechanical. If a dispatcher carries request params, validation results, and route matches in parallel arrays, one missing append is enough to make a later request inherit the wrong security context. In this case, that was enough to convert a schema-protected public parameter into an unauthenticated SQL injection path.

The chain continues to remote code execution from here, but that escalation is the hard, non-public part of this bug, and we do not repeat exploit steps we have not run. What we verified holds on real cloned source, at focused fidelity: WordPress 7.0.1 routes the crafted unauthenticated batch request into the SQL sink, and 7.0.2 does not.

The bug was one missing append. The exploit path was everything that trusted the arrays to still line up.