From Patch Diff to Pre-Auth RCE: Inside the WordPress 7.0.1 wp2shell Chain

RESEARCH WordPress 7.0.1 wp2shell Pre-Auth RCE via REST Batch Confusion and SQL Injection 1 REST Batch Confusion Malformed path smuggles widgets into posts 2 author__not_in SQLi String reaches WP_Query without absint 3 SQLi-to-Admin Bridge oEmbed cache rows create administrator 4 Direct-File RCE Uploaded plugin PHP accessed by URL WordPress 7.0.1 CVE-2026-63030 CVE-2026-60137 WP_Query REST API W fortbridge.co.uk

This post walks through how two patched WordPress 7.0.1 bugs chain together, starting from a single unauthenticated HTTP request and ending with code execution on a default install. We are not sharing a copy-paste exploit; the point is to show defenders and developers what actually happens under the hood, how the bugs connect, and why the 7.0.2 fix matters.

How this research came about

The underlying vulnerabilities were found by Adam Kues at SLCyber, who published the original wp2shell research first. When WordPress shipped 7.0.2, we diffed the patch against 7.0.1, reproduced the issues independently in our local lab, and built our own end-to-end proof-of-concept. That exercise confirmed the two fixes were not routine hardening. Taken together, they close a real unauthenticated RCE path. This post shares what we learned, seen from a defensive angle.


TL;DR

WordPress 7.0.2 fixed two issues in 7.0.1 that, combined, let an unauthenticated visitor run arbitrary PHP:

  1. CVE-2026-63030, REST batch processing can be tricked into dispatching a sub-request under the wrong route handler.
  2. CVE-2026-60137, WP_Query::author__not_in skips integer sanitisation when the value is not an array.

By routing an unsanitised widgets parameter into the posts collection handler, an attacker gains SQL injection. They then abuse injected wp_posts rows and WordPress’s own post-type machinery to create a real administrator account, upload a plugin, and execute the uploaded PHP file directly. No password hash cracking is needed.


The bugs

1. REST batch route confusion (CVE-2026-63030)

WordPress exposes a batch REST endpoint at /?rest_route=/batch/v1. It takes a JSON array of sub-requests, validates each against its registered route, dispatches the validated requests, and returns the collected responses.

The validation step looks at the path of each sub-request and asks the REST server for the matching route and allowed methods. The dispatch step, however, reuses the same server state. A malformed path like http://: matches during validation but leaves internal routing state confused, so the next sub-request in the batch is dispatched under a different handler than the one that validated it.

The practical effect: an attacker can smuggle a /wp/v2/widgets request into the /wp/v2/posts controller. The posts controller sees the widgets parameter schema but applies the posts controller’s permission and dispatch logic.

Patch diff (WordPress 7.0.1 → 7.0.2)

--- wordpress-7.0.1-src/wordpress/wp-includes/rest-api/class-wp-rest-server.php
+++ wordpress-7.0.2/wordpress/wp-includes/rest-api/class-wp-rest-server.php
@@ -283,6 +283,12 @@
     * @return null|false Null if not served and a HEAD request, false otherwise.
     */
    public function serve_request( $path = null ) {
+       // Refuse to start a fresh top-level REST cycle while another dispatch
+       // is already in flight. Internal sub-requests must use dispatch().
+       if ( $this->is_dispatching() ) {
+           return false;
+       }
+
        /* @var WP_User|null $current_user */
        global $current_user;

@@ -1744,6 +1750,7 @@
        foreach ( $requests as $single_request ) {
            if ( is_wp_error( $single_request ) ) {
                $has_error    = true;
+               $matches[]    = $single_request;
                $validation[] = $single_request;
                continue;
            }

7.0.2 adds two hardening measures:

  • serve_request() now refuses to start a new top-level REST cycle if a dispatch is already in flight, which prevents nested or recursive confusion.
  • Invalid sub-requests are recorded in the $matches array, so later bookkeeping cannot misalign validation results with dispatched handlers.

Nested REST dispatch (re-entrancy)

The batch controller dispatches sub-requests by calling the internal dispatch() method, not the public serve_request() entry point. That separation is correct, but 7.0.1 had no guard against a hook or callback triggered during dispatch from restarting the REST API.

WordPress loads the REST API through rest_api_loaded(), which is hooked to parse_request. It reads $wp->query_vars['rest_route'] and calls $wp_rest_server->serve_request($path). This entry point is meant to run once per HTTP request. During batch dispatch, however, code inside get_posts() or related hooks can cause WordPress to re-run the main query loop with rest_route still set. That starts a fresh serve_request() cycle that knows nothing about the outer batch context, the batch permission checks, or the sub-request currently being dispatched.

This re-entrancy path is only reachable when a persistent object cache is not in use. With Redis or Memcached, early cache hits short-circuit WP_Query execution and collapse the hook surface that enables the nested call. On a stock WordPress install without a cache, the full get_posts() path runs every time and fires all of its hooks.

The fix adds an is_dispatching() guard to both entry points. In serve_request():

    public function serve_request( $path = null ) {
+       if ( $this->is_dispatching() ) {
+           return false;
+       }
+
        global $current_user;
        // ...

And in rest_api_loaded():

+ if ( isset( $GLOBALS['wp_rest_server'] )
+     && $GLOBALS['wp_rest_server'] instanceof WP_REST_Server
+     && $GLOBALS['wp_rest_server']->is_dispatching()
+ ) {
+     return;
+ }

The server maintains a stack of dispatching requests inside dispatch(). While any dispatch is in flight, is_dispatching() returns true and any attempt to start a fresh top-level REST cycle returns immediately.

2. SQL injection in WP_Query::author__not_in (CVE-2026-60137)

WP_Query builds the post-author exclusion like this in 7.0.1:

if ( ! empty( $query_vars['author__not_in'] ) ) {
    if ( is_array( $query_vars['author__not_in'] ) ) {
        $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) ";
}

The problem is the is_array() guard. If the value arrives as a string, absint() is skipped and the string is interpolated straight into the WHERE clause. Other __not_in query vars use (array) casts and are safe, but author__not_in is the outlier.

The core /wp/v2/posts endpoint declares author_exclude as an array of integers, and REST sanitisation coerces scalar input into an integer array, so the posts endpoint itself is not directly exploitable. The batch confusion bug is what allows an attacker to bypass that sanitisation: the widgets route does not declare author_exclude as an integer array, so the raw string reaches WP_Query through the posts controller.

Patch diff (WordPress 7.0.1 → 7.0.2)

--- wordpress-7.0.1-src/wordpress/wp-includes/class-wp-query.php
+++ wordpress-7.0.2/wordpress/wp-includes/class-wp-query.php
@@ -2397,12 +2397,16 @@
        }

        if ( ! empty( $query_vars['author__not_in'] ) ) {
-           if ( is_array( $query_vars['author__not_in'] ) ) {
-               $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
-               sort( $query_vars['author__not_in'] );
+           $author__not_in_id_list = wp_parse_id_list( $query_vars['author__not_in'] );
+           if ( count( $author__not_in_id_list ) > 0 ) {
+               sort( $author__not_in_id_list );
+               $where .= sprintf(
+                   " AND {$wpdb->posts}.post_author NOT IN (%s) ",
+                   implode( ',', $author__not_in_id_list )
+               );
+               /** Update the query var for stable cache key generation in {@see self::generate_cache_key()}. */
+               $query_vars['author__not_in'] = $author__not_in_id_list;
            }
-           $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
-           $where         .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
        } elseif ( ! empty( $query_vars['author__in'] ) ) {

7.0.2 replaces the conditional absint() with wp_parse_id_list(), which always normalises the input to a list of positive integers before concatenation into SQL.


The chain, step by step

Stage 1, confirm the injection

The attacker sends a batch request whose first sub-request uses the malformed path. A later sub-request targets /wp/v2/widgets?author_exclude=..., but is dispatched through the posts controller. Because the widgets schema does not enforce integer-array sanitisation on author_exclude, the raw value reaches WP_Query::author__not_in as a string.

A time-based probe like 1) AND 1=0 UNION ALL SELECT ... IF(condition, SLEEP(0.4), 0) ... confirms the SQL is being evaluated. The vulnerable server takes measurably longer when the injected condition is true.

Stage 2, extract schema metadata

Using blind boolean or time-based injection, the attacker recovers:

  • the real posts table name, usually wp_posts;
  • the table prefix;
  • the ID of an existing administrator.

This is done with standard binary-search extraction over CHAR_LENGTH() and ASCII(SUBSTRING(...)) expressions. Stacked queries are not available because WordPress uses mysqli_query(), so the attacker cannot simply INSERT a new user directly.

Stage 3, plant the SQLi-to-admin bridge

Because stacked queries are blocked, the attacker abuses WordPress’s own post table and object types to turn row control into capability control. The idea is to create rows that WordPress later reads as privileged objects.

  1. Seed oEmbed cache posts. A UNION-injected fake post row contains ... shortcodes. When WordPress renders the injected post, it creates real oembed_cache entries and stores their IDs in wp_posts.
  2. Recover the cache IDs. Blind SQLi extracts the IDs of those newly created cache rows.
  3. Recast the IDs into privileged object shapes. A second UNION payload creates fake rows using the recovered IDs and assigns them to special post types:
    • customize_changeset
    • nav_menu_item
    • request
  4. Trigger administrator creation. That object shape causes a later POST /wp/v2/users sub-request to run with the capability context of the extracted administrator, creating a new admin account with a known password.

This is the bridge. Instead of spending hours cracking a bcrypt hash, the attacker makes WordPress treat attacker-controlled rows as a changeset, menu item, and request combination that authorises user creation.

Stage 4, authenticated plugin upload to direct-file RCE

The new administrator logs into wp-admin and uploads a ZIP plugin containing a single PHP file. Because the file lands under wp-content/plugins/<slug>/<slug>.php, the attacker can request it directly by URL, so WordPress plugin activation is not required. The PHP file executes the command passed in the query string and deletes itself.

In our local Docker lab, the official WordPress image marks /var/www/html/ non-writable by the web server, so the final upload step needed define("FS_METHOD", "direct"); added to wp-config.php. That is an environment-specific workaround, not part of the exploit. On a typical shared host where the web server owns ABSPATH, plugin upload works without any configuration change.


Why this matters beyond the lab

  • The SQLi-to-admin bridge removes the usual bcrypt bottleneck, so no hash cracking is needed.
  • The uploaded PHP file is reachable directly, so plugin activation or extra authenticated clicks are unnecessary.
  • Everything up to RCE looks like ordinary WordPress REST and admin traffic.
  • Both bugs need fixing. Patching only the SQL sink still leaves the batch confusion as a route-confusion primitive, while patching only the batch issue still leaves a dangerous sink reachable from plugins, themes, or custom REST endpoints that pass raw strings into author__not_in.

Affected versions

Based on the SLCyber advisory, the vulnerable range is:

  • WordPress 6.9.0 to 6.9.4: affected.
  • WordPress 7.0.0 to 7.0.1: affected.
  • WordPress 6.8.5 and earlier: not affected.

Patched versions are 7.0.2 and 6.9.5. If you are on the 6.9 branch, update to 6.9.5 rather than jumping major versions.


Detection and defense

Upgrade

The cleanest fix is to upgrade to the patched version for your branch, either 7.0.2 or 6.9.5. Both root causes are patched there.

Web Application Firewall rules

If immediate patching is not possible, consider blocking or heavily rate-limiting:

  • POST /?rest_route=/batch/v1 from unauthenticated clients;
  • batch sub-requests containing http://: or similar malformed paths;
  • author_exclude values that contain SQL keywords, brackets, or UNION/SELECT/SLEEP fragments.

Server-side hardening

  • Run the web server user with least-privilege file access. If the web server cannot write under ABSPATH, the plugin-upload step of this chain is blocked, though the SQL injection and admin creation still succeed.
  • Disable arbitrary plugin installation on managed sites where it is not required.
  • Restrict outbound access from the database and web containers so that even code execution does not trivially lead to lateral movement.

Monitoring

  • Log WP_Query-generated SQL for unusual author__not_in fragments.
  • Alert on repeated /?rest_route=/batch/v1 requests from a single source.
  • Monitor for new administrator accounts and for unexpected files under wp-content/plugins/.

Conclusion

The wp2shell chain is a good reminder that modern attackers do not need a single critical bug. Two medium-severity issues, a route-handling mix-up and a lazy type check in SQL construction, combine into pre-authentication remote code execution. The 7.0.2 patch diff tells the whole story: normalise every input before it reaches SQL, and do not let one sub-request’s validation state bleed into another sub-request’s dispatch.

For defenders, the priority is straightforward: patch, review any custom code that passes user input into WP_Query, and treat REST batch traffic as a sensitive endpoint that deserves logging and rate limiting.


About Post Author