-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The two arrays were supposed to stay 1-to-1\n\n`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.\n\nIn 6.9.4 the handler runs three passes.\n\nThe build pass turns each entry into a `WP_REST_Request` or a `WP_Error`, in order:\n\n```php\npublic function serve_batch_request_v1( WP_REST_Request $batch_request ) {\n $requests = array();\n\n foreach ( $batch_request['requests'] as $args ) {\n $parsed_url = wp_parse_url( $args['path'] );\n\n if ( false === $parsed_url ) {\n $requests[] = new WP_Error( 'parse_path_failed', __( 'Could not parse the path.' ), array( 'status' => 400 ) );\n continue;\n }\n\n $single_request = new WP_REST_Request( /* method, path, body, headers from $args */ );\n $requests[] = $single_request;\n }\n```\n\nThe 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:\n\n```php\n$matches = array();\n$validation = array();\n\nforeach ( $requests as $single_request ) {\n if ( is_wp_error( $single_request ) ) {\n $has_error = true;\n $validation[] = $single_request; // $matches[] NOT appended\n continue;\n }\n\n $match = $this->match_request_to_handler( $single_request );\n $matches[] = $match;\n $error = null;\n\n // ... run validation / sanitization against the matched route's schema ...\n\n if ( $error ) {\n $has_error = true;\n $validation[] = $error;\n } else {\n $validation[] = true;\n }\n}\n```\n\nThe 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.\n\nThe dispatch pass then reads from them by index, trusting the 1-to-1 contract:\n\n```php\nforeach ( $requests as $i => $single_request ) {\n if ( is_wp_error( $single_request ) ) {\n $responses[] = $this->envelope_response( $this->error_to_response( $single_request ), false )->get_data();\n continue;\n }\n ...\n $match = $matches[ $i ];\n ...\n list( $route, $handler ) = $match;\n $result = $this->respond_to_request( $single_request, $route, $handler, $error );\n}\n```\n\n`$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.\n\nThe 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.\n\n## `\"http://:\"` is the entry that fails to parse\n\n`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[]`.\n\nA 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.\n\n## The outer shift is a batch inside a batch\n\nThe disclosed PoCs post this top-level body to `/?rest_route=/batch/v1`:\n\n```json\n{\n \"requests\": [\n { \"method\": \"POST\", \"path\": \"http://:\" },\n { \"method\": \"POST\", \"path\": \"/wp/v2/posts\",\n \"body\": { \"requests\": [ /* nested batch, see below */ ] } },\n { \"method\": \"POST\", \"path\": \"/batch/v1\" }\n ]\n}\n```\n\nValidate pass over the outer batch:\n\n- Index 0 is a `WP_Error`. `$validation[0] = WP_Error`. `$matches` is not touched.\n- 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.\n- Index 2 is a real request for `POST /batch/v1`. `$matches[1]` becomes the batch-handler match. `$validation[2]` is set accordingly.\n\nDispatch pass:\n\n- `$i = 0`: `$requests[0]` is a `WP_Error`, the handler emits the parse-failure envelope and continues.\n- `$i = 1`: `$requests[1]` is the `POST /wp/v2/posts` request 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 `$request` and the batch handler as `$handler`.\n\n`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.\n\nThe inner batch is the one the attacker actually wants to run.\n\n## The inner shift sanitizes under /wp/v2/categories and dispatches under /wp/v2/posts\n\nThe nested body the PoC sends is:\n\n```json\n{\n \"requests\": [\n { \"method\": \"GET\", \"path\": \"http://:\" },\n { \"method\": \"GET\", \"path\": \"/wp/v2/categories?author_exclude=SELECT+IF((),SLEEP(0.4),0)\" },\n { \"method\": \"GET\", \"path\": \"/wp/v2/posts\" }\n ]\n}\n```\n\nValidate pass over the inner batch:\n\n- Index 0: `WP_Error`, no `$matches` push.\n- Index 1: `GET /wp/v2/categories?author_exclude=`. `match_request_to_handler()` returns the categories route handler. `$matches[0]` becomes the categories match. `has_valid_params()` and `sanitize_params()` run against the categories route's registered schema. The categories collection schema does not register a parameter named `author_exclude`. The sanitizer walks only registered params. `author_exclude` is ignored. The raw SQL string stays in the request's query params.\n- Index 2: `GET /wp/v2/posts`. `$matches[1]` becomes the posts collection match.\n\nDispatch pass:\n\n- `$i = 0`: `WP_Error`, skipped.\n- `$i = 1`: `$single_request` is the `/wp/v2/categories` request, still carrying the unsanitized `author_exclude` in 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 invokes `WP_REST_Posts_Controller::get_items( $single_request )`.\n\nThe 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`.\n\nThe 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.\n\n## author_exclude as a scalar is CVE-2026-60137\n\n`WP_Query::get_posts()` in 6.9.4 handles `author__not_in` with a scalar-skips-sanitization gap:\n\n```php\nif ( ! empty( $query_vars['author__not_in'] ) ) {\n if ( is_array( $query_vars['author__not_in'] ) ) {\n $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );\n sort( $query_vars['author__not_in'] );\n }\n $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );\n $where .= \" AND {$wpdb->posts}.post_author NOT IN ($author__not_in) \";\n}\n```\n\n`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.\n\nA query string `author_exclude=SELECT+IF((),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((),SLEEP(0.4),0))`. A boolean-blind SQL injection, no credentials required.\n\nThat 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.\n\n## The patch is one line in each file\n\nThe 6.9.5 fix for the batch handler is one line, inside the `WP_Error` branch of the validate pass:\n\n```diff\n foreach ( $requests as $single_request ) {\n if ( is_wp_error( $single_request ) ) {\n $has_error = true;\n+ $matches[] = $single_request;\n $validation[] = $single_request;\n continue;\n }\n```\n\nThe 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.\n\nThe 6.9.5 fix for the SQL injection is also one line, this time by substitution:\n\n```diff\n if ( ! empty( $query_vars['author__not_in'] ) ) {\n- if ( is_array( $query_vars['author__not_in'] ) ) {\n- $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );\n- sort( $query_vars['author__not_in'] );\n+ $author__not_in_id_list = wp_parse_id_list( $query_vars['author__not_in'] );\n+ if ( count( $author__not_in_id_list ) > 0 ) {\n+ sort( $author__not_in_id_list );\n+ $where .= sprintf(\n+ \" AND {$wpdb->posts}.post_author NOT IN (%s) \",\n+ implode( ',', $author__not_in_id_list )\n+ );\n+ $query_vars['author__not_in'] = $author__not_in_id_list;\n }\n- $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );\n- $where .= \" AND {$wpdb->posts}.post_author NOT IN ($author__not_in) \";\n }\n```\n\n`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.\n\nThe same 6.9.5 release also adds `is_dispatching()` as a defense-in-depth guard on `serve_request()`:\n\n```diff\n public function serve_request( $path = null ) {\n+ if ( $this->is_dispatching() ) {\n+ return false;\n+ }\n```\n\nThat 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.\n\n## Read-only SQLi, unauthenticated RCE\n\nA 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.\n\nThe injection point is inside a `NOT IN (...)` clause whose subquery the attacker controls. `) AND 1=0 UNION ALL SELECT -- -` 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.\n\nWordPress'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.\n\nThe CVE record is filed against the REST batch handler. The remote-code-execution primitive is an [emergent primitive](/patterns/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](https://www.cisa.gov/known-exploited-vulnerabilities-catalog) 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.\n\n## The index is the primitive\n\nThe 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.\n\nThis bug is the pattern [we have been collecting as gate-narrower-than-sink](/patterns/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.","closing_line":"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.","hook_md":"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.","post_id":738,"slug":"wordpress-rest-batch-cve-2026-63030-sanitized-under-categories-dispatched-under-posts","title":"CVE-2026-63030: WordPress's REST Batch Sanitized Under /wp/v2/categories. It Dispatched Under /wp/v2/posts.","type":"initial","unreadable_sentence":"Validation ran under /wp/v2/categories. Dispatch ran under /wp/v2/posts. The gate's grammar is strictly contained within the sink's, and the one line of PHP that keeps them apart is the one line the patch adds back."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCatlDOQAKCRDeZjl4jgkQ Js9rAPwPcsA2VKXEfm2Tx94pSYLu+Br23PxNOgMfe3Xc+NkcawD/XCNJMySkqzyX X7WN5m5zawW2ktCMkhc64LobtJUBVwI= =VFAo -----END PGP SIGNATURE-----