LIVE ANALYSIS July 18, 2026

wp2shell: anatomy of a pre-auth RCE in WordPress Core

How two one-line oversights chain into code execution on 500 million sites

#wordpress #rest-api #sql-injection #route-confusion #cve-2026-63030 #cve-2026-60137 #code-review

TL;DR. In July 2026, WordPress patched wp2shell: a pre-authenticated RCE in the core, exploitable on a stock install with no plugins. It is not one vulnerability but two, each almost harmless on its own:

  1. a SQL injection in WP_Query (author__not_in), caused by a missing array_map('absint', ...) on the sink line;
  2. a route confusion in the REST API batch dispatcher, caused by a missing $matches[] = ... in an error branch.

Separately, neither one gives you much. Chained, they yield a blind SQL injection reachable by an anonymous user, and then (outside the public scope) a path to RCE.

This is an N-day analysis reconstructed from the patch. Searchlight Cyber deliberately withheld the technical details, so everything below comes from reading the 7.0.1 -> 7.0.2 diff. Credits: Adam Kues (Assetnote / Searchlight Cyber) for the route confusion, and TF1T, dtro and haongo for the SQL injection.

Contents

  1. Why this article
  2. Prerequisites: REST API, batch endpoint, WP_Query, parameter mapping
  3. Bug #1: the SQL injection, the “sanitizer inside the guard, sink outside” pattern
  4. Bug #2: the route confusion, the “desynchronized parallel arrays” pattern
  5. The chain: how the two bugs meet
  6. Diff analysis and the fix
  7. The two patterns, in the abstract, and how to detect them
  8. Detection and remediation
  9. Takeaways

How to read this. The Prerequisites sections exist so that someone who has never touched WordPress can follow. If you do appsec daily, skim them and jump straight to the bugs.

1. Why this article

There are already fifteen news pieces about wp2shell. They all say the same thing: “REST batch + SQLi = RCE, patch to 7.0.2.” That is true, and completely useless if you want to understand what happened.

What makes this vulnerability interesting is not the impact (one more WordPress RCE). It is that it is a textbook case of composition: two bugs that any classic source-to-sink audit walks past, because in isolation they are not weaponizable. The first needs you to send it a type the API does not accept. The second only “mixes up” otherwise valid requests. You need both, in the right order, for the magic to happen.

It is also a clean illustration of a broader idea: an internal trust primitive mistaken for a security boundary. We will come back to that. It is the thread running through the whole piece.

2. Prerequisites

2.1 The WordPress REST API

Since WordPress 4.7, the core exposes a REST API under /wp-json/. Each feature declares routes (/wp/v2/posts, /wp/v2/users, and so on) and, for each route, one or more handlers: an HTTP method, a callback that runs the logic, a permission_callback that decides who is allowed, and a schema that describes and validates the accepted parameters.

The simplified lifecycle when a request arrives:

HTTP request -> serve_request() -> match_request_to_handler()  (which route?)
             -> permission_callback  (are you allowed?)
             -> has_valid_params() / sanitize_params()  (params conform to schema?)
             -> callback  (run it)

Remember two things: (a) parameter validation runs against the schema of the matched route, and (b) a parameter unknown to the schema is not rejected, it is simply ignored by validation and passed through untouched. Both come back to bite us.

2.2 The batch endpoint /batch/v1

Since WordPress 5.6 (2020), the core exposes POST /wp-json/batch/v1. The idea: send several REST sub-requests in a single HTTP call, each one supposedly validated and permission-checked individually, as if it had arrived alone.

The body looks like this:

{
  "requests": [
    { "method": "POST", "path": "/wp/v2/posts", "body": { "title": "..." } },
    { "method": "POST", "path": "/wp/v2/comments", "body": { "content": "..." } }
  ]
}

The handler that processes all this is WP_REST_Server::serve_batch_request_v1(). That is our bug #2.

Key point: not every route allows batching. A handler must declare allow_batch => [ 'v1' => true ]. This allow-list is a security control. Keep it in mind, because we are going to bypass it.

2.3 WP_Query: from query var to SQL

WP_Query is the central class in WordPress: almost every content read goes through it. You hand it query vars (author__in, post__not_in, category__and, and so on) and it builds a SQL query.

Only one matters for our story: author__not_in, meant to be an array of author IDs to exclude. It ends up in an ... AND post_author NOT IN (...) clause.

2.4 The bridge: REST params to query vars

Last link. The /wp/v2/posts controller accepts a REST parameter named author_exclude. Internally, it copies it into the query var author__not_in before calling WP_Query.

REST param  author_exclude  ---->  WP_Query var  author__not_in  ---->  SQL  NOT IN (...)

That is it. You now have the four building blocks. The rest of the story is about finding how to slip a string into author__not_in when the REST schema wants an array of integers.


3. Bug #1: the SQL injection (CVE-2026-60137)

Here is the vulnerable code, in wp-includes/class-wp-query.php (WordPress 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) ";
}

Read it slowly, because the bug lives in the scope of a block, not in an obviously dangerous function.

  • The line that cleans (array_map('absint', ...), which forces every element to a non-negative integer) is inside the if ( is_array(...) ).
  • The line that builds the SQL (the implode and the $where .=) is outside it.

So what happens if author__not_in is not an array but a string?

  1. is_array(...) is false, so the cleaning branch is skipped. No absint.
  2. (array) $query_vars['author__not_in']: in PHP, casting a string to an array gives a single-element array: (array) "foo" becomes ["foo"].
  3. implode(',', ["foo"]) becomes "foo". The string comes back out raw.
  4. It gets concatenated as-is into NOT IN (...).

The (array) cast is the detail that makes the injection graceful: instead of crashing on a string, it politely wraps it and lets it flow untouched into the SQL. You control the inside of the NOT IN (...) parentheses, which is a SQL injection.

3.1 The sibling that is not a bug

The best way to feel the bug is to look at the immediate neighbor, author__in, two lines down, nearly identical but not vulnerable:

} elseif ( ! empty( $query_vars['author__in'] ) ) {
    if ( is_array( $query_vars['author__in'] ) ) {
        $query_vars['author__in'] = array_unique( array_map( 'absint', $query_vars['author__in'] ) );
        sort( $query_vars['author__in'] );
    }
    $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) );
    $where     .= " AND {$wpdb->posts}.post_author IN ($author__in) ";
}

Spot the difference, it fits in one fragment. On the sink line:

  • author__not_in: implode( ',', (array) $var ), no absint. Vulnerable.
  • author__in: implode( ',', array_map( 'absint', array_unique( (array) $var ) ) ), absint re-applied. Safe.

For author__in, even if a string slips past the first guard, the sink re-sanitizes it. For author__not_in, the sink trusts a cleaning step that never ran. The same block, two lines apart, one safe and one not. That is the entire vulnerability.

What about the other siblings? I checked the whole file: category__in/not_in, tag__in/not_in, post__in/not_in, post_parent__in, all of them re-sanitize on the sink (array_map('absint', ...)), and post_name__in puts the is_array() on the entry of the whole block (sink included). author__not_in is the only place where an (array) cast feeds an implode into SQL without cleaning. An isolated oversight, not a systemic pattern, but a sufficient one.

At this stage we have a SQLi, if only we could send a string in author_exclude. But the REST schema for /wp/v2/posts validates author_exclude as an array of integers. A string would be rejected before it ever reached WP_Query. This is where bug #2 comes in.


4. Bug #2: the route confusion (CVE-2026-63030)

Over to wp-includes/rest-api/class-wp-rest-server.php, method serve_batch_request_v1(). The handler processes sub-requests in two passes: first a validation pass that fills two arrays, then a dispatch pass that consumes them.

4.1 The build pass (validation)

$matches    = array();
$validation = array();

foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
        $validation[] = $single_request;   // push into validation
        continue;                          // <- $matches gets NOTHING
    }

    $match     = $this->match_request_to_handler( $single_request );
    $matches[] = $match;                   // push into matches
    // ... allow_batch, has_valid_params, sanitize_params checks ...
    $validation[] = $error ? $error : true;  // push into validation
}

Two “parallel” arrays:

  • $validation: filled on every iteration, error or not, so it stays aligned with $requests.
  • $matches: filled only for non-error sub-requests (the continue skips the push), so it is compacted.

4.2 The consume pass (dispatch)

foreach ( $requests as $i => $single_request ) {
    if ( is_wp_error( $single_request ) ) { /* ... */ continue; }

    $match = $matches[ $i ];                       // <- read by the $requests index
    if ( is_wp_error( $validation[ $i ] ) ) {
        $error = $validation[ $i ];
    }
    // ...
    $result = $this->respond_to_request( $single_request, $route, $handler, $error );
}

Here is the disaster: $matches is read by $i, where $i is the position in $requests. But $matches was compacted at build time (error entries are missing from it). From the first WP_Error sub-request at position k, every $matches[i] for i > k points at the handler of a different sub-request.

4.3 The desync, drawn out

Position (i):          0          1            2          3
                    +------+  +---------+  +------+  +------+
$requests    :      |  R0  |  | R1=ERR  |  |  R2  |  |  R3  |
                    +------+  +---------+  +------+  +------+
                        |          |           |         |
$validation  :       [ v0 ]     [ v1 ]      [ v2 ]    [ v3 ]     <- pushed every iteration  (ALIGNED)

$matches     :       [ m0 ]    (no push)    [ m1 ]    [ m2 ]     <- nothing pushed for R1    (SHIFTED +1)

Dispatch, i = 2:
   single_request = $requests[2] = R2        (carries MY params)
   handler        = $matches[2]  = m1        (the match built for R2... or R3? it is shifted)

Concretely: the request at position i is dispatched through the handler of a neighboring request. The allow_batch check and the parameter validation were evaluated, at build time, on the compacted sequence, so for a (request, handler) pair that is not the one actually executed at dispatch. Validation “passes” for one request, but a different one runs. That is the route confusion (CWE-436, “interpretation conflict”).

5. The chain

We stay at the mechanism level here: enough to fully understand the flow, without laying out a ready-to-fire payload. (The final SQLi to RCE jump is not public anyway; Searchlight did not disclose it.)

The trick plays out in two nestings:

Stage 1: bypass the method allow-list. You place, inside a batch, a POST /wp/v2/posts sub-request that itself carries a requests field. Because of the index shift, it ends up dispatched under the batch handler. Having been validated as a “posts” request, its internal requests list is never checked against the batch schema, so the allow-list of permitted methods is bypassed. The inner sub-requests can then use GET where the batch normally forbids it.

Stage 2: make the forbidden parameter travel. Inside that inner batch, a GET /wp/v2/users request carries an author_exclude as a string. The /wp/v2/users schema does not know that parameter, so validation lets it through untouched (recall prerequisite 2.1: a parameter unknown to the schema is not rejected). But thanks to the shift, the request is dispatched under posts get_items(), where author_exclude is precisely copied into the author__not_in query var, as a string.

And there we land right back on bug #1: WP_Query interpolates that raw string into NOT IN (...).

   outer batch
      \-- POST /wp/v2/posts  (dispatched under the BATCH handler -> allow-list bypass)
              \-- inner batch
                     \-- GET /wp/v2/users  + author_exclude="<payload string>"
                            (dispatched under posts::get_items -> author__not_in = string)
                                   \-- WP_Query -> NOT IN (<payload>) -> blind SQLi, PRE-AUTH

The result is a blind SQL injection (boolean-based and time-based) reachable without authentication. From there, in theory: read the database, recover the admin password hash, crack it offline, gain admin access, upload a plugin, execute code. Every step after the SQLi is outside the disclosed scope, so we stop at the mechanism.

The reachability condition. The path triggers when the WP_Query query actually runs against the database. Several sources mention “when no persistent object cache is active.” After reading the core, this is not a binary gate: the injected $where flows to SQL regardless of the object cache (wp_using_ext_object_cache() only drives the ID-query vs full-row split, not whether the query runs). Treat “we run Redis” as a factor affecting oracle reliability, not as a mitigation.


6. Diff analysis and the fix

The 7.0.1 -> 7.0.2 patch fixes both bugs, plus one defense in depth. Three changes.

6.1 Re-align the arrays (the heart of bug #2)

 foreach ( $requests as $single_request ) {
     if ( is_wp_error( $single_request ) ) {
         $has_error    = true;
+        $matches[]    = $single_request;
         $validation[] = $single_request;
         continue;
     }

One line. We now push into $matches too on the error branch. Both arrays stay index-aligned with $requests, and $matches[$i] always corresponds to $requests[$i]. No more shift, no more confusion.

6.2 Sanitize unconditionally (bug #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) ";
+    $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 )
+        );
+        $query_vars['author__not_in'] = $author__not_in_id_list;
+    }
 }

The if ( is_array(...) ) disappears entirely. In its place, wp_parse_id_list(), a helper that accepts both a string and an array, splits on commas and whitespace, and forces every element to an integer. Whatever the input type, you get a clean list of IDs before touching the SQL. The sink can no longer receive a raw string. The patch comment (“stable cache key generation”) reveals a side effect: normalizing the query var also stabilizes the WP_Query cache key.

6.3 Forbid reentrancy (defense in depth)

 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;
+    }

is_dispatching() already existed (via a $dispatching_requests[] stack pushed and popped inside dispatch()). The fix simply uses it: while a dispatch is in flight (so while a batch is being processed), it refuses to start a fresh top-level serve_request() cycle. Internal sub-requests must go through dispatch(). That is what breaks the “nesting twice” of stage 1.

6.4 What the fix teaches us

The patch is surgical, and that is instructive: the two real fixes each fit in one idea, one line (push into both arrays; sanitize without a type condition). That is the typical signature of a composition bug: the flaw is not a big chunk of faulty logic, but an implicit invariant the code assumed held without guaranteeing it.

7. The two patterns, in the abstract

Step out of the WordPress context: these two bugs are archetypes you find everywhere. That is what makes the story reusable for your own audits.

Pattern A: “the sanitizer is inside the guard, the sink is outside”

if (looks_safe(x)) {        // e.g. is_array(x), is_int(x), typeof x === 'number'
    x = sanitize(x)         // normalization lives HERE
}
sink(cast(x))               // ... but the sink ALWAYS runs, on a possibly raw x

The mental trap: looks_safe(x) is read as a guarantee about x, when it only guards a normalization branch. The cast() right before the sink ((array), String(), str()) is the accelerant: it prevents the crash that would have exposed the problem, and lets the hostile value flow. Audit signal: a sanitization inside an if (type check) whose variable reappears in a sink outside the block, especially preceded by a cast.

Pattern B: “two parallel arrays, filled by append, read by index”

for r in items:
    if error(r):
        A.append(r)        # only one of the two structures is fed
        continue
    B.append(match(r))
    A.append(validate(r))

for i, r in enumerate(items):
    use(B[i], A[i])        # assumes A[i] and B[i] are aligned with items[i]

As soon as one branch (continue, break, early return) feeds only a subset of structures that are meant to stay parallel, the alignment breaks, and any later cross-indexed read consumes desynchronized pairs. Audit signal: several .append() or [] = on distinct names inside one loop, a conditional continue, then elsewhere an A[i] / B[i] access sharing the index.

A Semgrep sketch

A starting point (needs tuning), for pattern A in PHP:

rules:
  - id: sanitizer-scoped-in-type-guard-sink-outside
    languages: [php]
    severity: WARNING
    message: >
      Sanitization gated behind a type guard, but the sink runs on the
      (cast) value outside the guard. Check the non-array case.
    patterns:
      - pattern: |
          if (is_array($X)) { ... }
          ...
          $SINK = implode($SEP, (array) $X);

This is not a production-ready rule (false positives on benign casts exist), but it captures the intuition: type guard plus a sink that casts outside the guard. Pattern B is better suited to manual review or a taint rule that tracks two co-indexed collections.

The meta-point

Both bugs share a root cause: an internal trust primitive treated as a security boundary.

  • The is_array() is read as “the type is guaranteed” when it only guards one branch.
  • The allow_batch check and the schema validation are read as “this request has been authorized” when they applied to a different (request, handler) pair than the one dispatched.

In both cases, defensive code exists, but its actual scope does not cover what you think it covers. In my view this is the most fertile bug family of 2026: not forgotten sinks, but guarantees whose perimeter is misunderstood.

8. Detection and remediation

The only necessary condition is the core version:

VersionStatus
6.9.0-6.9.4, 7.0.0-7.0.1full RCE chain
6.8.0-6.8.5SQLi only (route confusion absent)
6.8.6 / 6.9.5 / 7.0.2 / 7.1-beta2fixed

Check an instance (non-invasive, authoritative): wp core version (WP-CLI) or read $wp_version in wp-includes/version.php. Remote signals (the <meta name="generator"> tag, the RSS <generator> element, /readme.html) are spoofable and strippable, so do not treat them as reliable in an audit.

First-party checker (optional): Assetnote published a checker at https://wp2shell.com/ so admins can test their own site. Handy, but note it was reported as intermittently unreachable (404) around disclosure, and it is not authoritative. The version check above remains the source of truth.

Reachability: the batch endpoint (/wp-json/batch/v1 and its fallback ?rest_route=/batch/v1) must be reachable anonymously. That is the default.

Temporary mitigation if you cannot patch right away: block both forms of the route at the WAF (the pretty route and the rest_route parameter), or require authentication on the batch endpoint via the rest_pre_dispatch filter. Careful: this may break legitimate integrations that use batching. It is a bandage, not a fix.

Do not rely on the object cache as protection (see section 5): at best it complicates the oracle, it immunizes nothing.

Appendix: a passive detector for your estate

Understanding the bug is one thing; knowing which of your hosts are exposed is another, and for a large estate that is the part that actually costs you sleep. So I wrote a small companion tool: wp2shell-detector.

It takes a list of targets and tells you, in color, which ones run an affected WordPress version. Deliberately, it is passive: it fingerprints the core version from public signals (the generator meta tag, the RSS feed, readme.html, core asset ?ver= strings) and never sends a single exploit-shaped request. No batch route-confusion payload, no SQLi probe, nothing that touches serve_batch_request_v1() or WP_Query.

That is not a limitation, it is the point. For wp2shell the version is the authoritative gate: the vulnerable code lives in 6.9.0-6.9.4 and 7.0.0-7.0.1 and nowhere else. You do not need to fire the exploit to answer “is this host affected?”, you need the version. A passive detector is therefore safe to run across production and, with authorization, across partners’ assets, without the “am I attacking this?” question and without leaving artifacts on a live blind-SQLi path. Given that we are in the middle of a disclosure wave with public PoCs already circulating, shipping yet another active mass-scanner would be part of the problem, not the solution.

$ ./wp2shell-detect -i estate.txt

VULNERABLE   https://blog.example.com    ver 7.0.1  batch yes  conf 100%
NOT VULN     https://shop.example.com    ver 7.0.2  batch yes  conf 100%
SQLI-ONLY    https://old.example.com     ver 6.8.3  batch no   conf 70%
UNKNOWN      https://hardened.example    ver -      batch yes  conf -

Stdlib only, Python 3.8+, MIT. It cross-checks several version signals for a confidence score, notes whether the REST batch route is advertised, emits JSON for pipelines, and returns exit code 2 if anything is in the RCE range, so you can wire it into a nightly cron. The repository README carries the full mitigation checklist and the rules of engagement.

If you need dynamic proof, do it in a lab you own on an affected build. For inventory and triage at scale, version detection is the right tool, and the safe one.

9. Takeaways

  • For developers. Two reflexes. (1) Sanitization must live at the same scope as the sink, not inside an upstream if (type), or else the sink re-sanitizes. (2) Two “parallel” structures filled inside a loop with a continue: either they are fed together on every iteration, or you never read them by a shared index.
  • For auditors. Weaponizable bugs are not always naked sinks. Look for the implicit invariants: “this param is necessarily an array,” “this request was necessarily validated.” The patch for a composition bug often fits in one line, because the flaw was not a piece of code but an assumption.
  • The thread. An internal trust primitive (is_array, an array index, a validation verdict) is not a security boundary until its scope is exactly the one you ascribe to it.

Analysis reconstructed from the public 7.0.1 -> 7.0.2 diff. No working exploit is provided: the SQLi to RCE jump was not disclosed by the finders, and the goal here is understanding, not weaponization.

Sources and credits

  • Searchlight Cyber / Assetnote, advisory (details withheld): slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/
  • Finders: Adam Kues (route confusion); TF1T, dtro, haongo (SQLi)
  • WordPress 7.0.2 release announcement (wordpress.org/news/2026/07/)
  • Advisories: GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf; CVE-2026-63030, CVE-2026-60137
  • Third-party analyses: Rapid7 Labs, Beazley Security (BSL-A1193), Cloudflare (WAF rules)