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:
- Route a request validated by one endpoint's schema through a completely different endpoint's callback
- Inject unsanitized SQL through
author__not_in(the string→array cast skipsabsint()) - Use
UNION SELECTto poison WordPress's object cache with fake post objects - Trigger a changeset auto-publish that elevates privileges, then re-enter the REST API with admin context
Once 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):
- A trigger post with an
[embed]shortcode in its content - A changeset post (
customize_changeset, statusfuture, date in past) - An outer loop partner (parent=changeset, creating Loop 1)
- An oEmbed target (dynamic anti-recursion ID, parent=changeset, empty content)
- A nav menu item post (poisoned as
post_type=nav_menu_itemfor theis_nav_menu_itemcheck) - A re-entry post (
post_type=request,post_status=parse, parent=inner) - An inner loop partner (parent=re-entry, creating Loop 2)
Execution Flow:
- UNION poisons the object cache with all 7 fake posts
- Posts handler renders the trigger post's content →
[embed]shortcode fires - oEmbed cache lookup finds a backing post with empty content → falls through to
wp_update_post wp_update_postreads the cached changeset (parent=outer) → hierarchy check detects Loop 1- Fix-up writes changeset to DB with
futurestatus → auto-converts topublish _wp_customize_publish_changesetfires →wp_set_current_user(admin_id)→ admin context active- Changeset processes
nav_menu_item[real_id]— cache says type=nav_menu_item → UPDATE path object_idresolves to a cached post withpost_parent=re-entry→wp_update_poston real post- Hierarchy check (non-zero
$post_id) detects Loop 2 (re-entry ↔ inner) - Fix-up calls
wp_update_post(re-entry)→ writes type=request, status=parse to DB wp_transition_post_statusfiresdo_action("parse_request")→rest_api_loaded()→serve_request()- REST API re-enters, re-processes the entire batch with admin privileges
POST /wp/v2/usersin the tail succeeds → administrator created →die()
An anti-recursion MySQL session variable (@_wp2s) ensures the chain fires exactly once and doesn't loop.
- Three extraction modes with auto-detection: UNION (1 request/value), error-based via EXTRACTVALUE (~30 chars/request), boolean-blind binary search (~7 requests/char)
- Full pre-auth RCE — no credentials, no cracking, escalation fires in a single round-trip
- Auto-discovery — table prefix via INFORMATION_SCHEMA, admin user ID via capabilities meta
- Post-exploitation — plugin webshell with token auth, CWD-tracking interactive shell, file read/write
- Cleanup mode —
--cleanupdeletes the created user and removes the webshell on exit - Zero dependencies — stdlib only, single file, runs on Python 3.8+
git 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 -ipython3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -iThe check and read commands work on any affected target. The exploit chain has three additional requirements:
| 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) |
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.
