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.
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.
Evidence status
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.
| Step | What happens |
|---|---|
| 1 | A batch request includes one sub-request that fails to parse before the crafted request. |
| 2 | The failed sub-request appends to $validation[], but not to $matches[]. |
| 3 | The 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. |
| 5 | A crafted POST /wp/v2/posts body carries author_exclude as a raw string. |
| 6 | Those params are matched against a different slot's route, so the schema that would reject the string is not the schema being applied. |
| 7 | get_items() executes as the public GET /wp/v2/posts handler with unsanitized author_exclude. |
| 8 | author_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?
Evidence status
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.
Evidence status
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.
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.
Evidence status
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.
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
| Case | WordPress 7.0.1 | WordPress 7.0.2 | What changed |
|---|---|---|---|
| Crafted batch with prior parse failure | author_exclude reaches get_items() as a raw string | Crafted POST routes to create_item | Batch arrays stay aligned |
| Same string without drift | 400 rest_invalid_param | 400 rest_invalid_param | Normal schema rejection still holds |
| Public posts collection | Public read path remains public | Public read path remains public | No authentication model changed |
| Nested REST dispatch | Separate hardening gap | is_dispatching() guard returns early | Defense-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.