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
wp2shell — CVE-2026-63030 + CVE-2026-60137 - “wp2shell”: unauthenticated RCE in WordPress core | Kitploit
Tools/GitHubGitHub/0xsha/wp2shell
Password CrackingVulnerability AnalysisExploitationWeb Application ExploitationPenetration TestingCommand and ControlLearning & EducationRed TeamingPayload DevelopmentLabs & Practice
GitHub
9630372 months agoReviewed by Kitploit

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share
0xsha/wp2shell

wp2shell

CVE-2026-63030 + CVE-2026-60137 - “wp2shell”: unauthenticated RCE in WordPress core

View Repository

CVE-2026-63030 + CVE-2026-60137 - “wp2shell”: unauthenticated RCE in WordPress core

REST API batch route confusion (CVE-2026-63030) chained with a WP_Query author__not_in SQL 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)
PreconditionsREST API reachable; no persistent object cache (Redis/Memcached); ≥1 published post
Auth requirednone
Impactunauthenticated → create a new administrator → code execution (the SQLi also dumps the admin hash)

Demo

https://github.com/user-attachments/assets/7f9cc52c-3f31-4339-9192-e31e506684f6

What this repo adds

  • One original, stdlib-only tool (wp2shell.py) that unifies the best of six public PoCs into a single file, with no requests dependency and no broken features.
  • The full crack-free RCE, verified end-to-end in-lab: 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.
  • A version-independent confusion detector (block_cannot_read) used as the primary, non-destructive check.
  • Production transport on every command: self-signed TLS, custom headers, custom User-Agent, proxy, retries, request delay.
  • A verified 6.8.x facilitated-SQLi path (sqli) that the other PoCs do not have.
  • Reproducible Docker labs plus a version-by-DB reliability matrix, with every result verified in-lab.
  • The hashcat mode for the new $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.


1. Vulnerability details - code deep dive

The chain welds two independent bugs. Line numbers are from the real WordPress 6.9.4 source (extracted from wordpress:6.9.4-apache).

Bug A - 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+.

Bug B - REST batch route confusion (CVE-2026-63030)

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 documented fix (6.9.5 / 7.0.2)

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


2. Exploitation method

2.1 The double route confusion

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:

Download Tool