Vulnerability Research

wp2shell: WordPress Core Pre-Auth RCE (CVE-2026-63030)

By Pranav Khune

22 min read

wp2shell: pre-authentication RCE in WordPress core (CVE-2026-63030 and CVE-2026-60137) shown as a laptop with the WordPress logo, a red alert, a webshell terminal, and a breached security shield

CVE-2026-63030 & CVE-2026-60137: wp2shell: Pre-Authentication RCE in WordPress Core via REST Batch-Route Confusion and SQL Injection

A pre-authentication RCE in WordPress core, not a plugin, not a theme, the engine itself, is about as high-impact as web bugs get. WordPress runs a large share of the public web, and on July 17, 2026 the security team shipped an emergency release, 6.9.5 and 7.0.2, then took the rare step of forcing automatic updates onto affected sites. The trigger was a bug chain nicknamed wp2shell, reported by Adam Kues of Assetnote (Searchlight Cyber) through WordPress’s HackerOne program.

What makes wp2shell notable is not a single clever primitive but the composition: a moderate-rated SQL injection that is uninteresting on its own becomes a critical pre-auth RCE once it is reached through a second flaw in the REST API’s batch endpoint. A default install (no plugins, no special configuration, no authenticated session) is exploitable by a single anonymous HTTP request. This post walks through what is publicly known about the two CVEs, how they chain, how to determine whether you are exposed, and what to do about it.

Vulnerability Overview

wp2shell is tracked as two distinct CVEs that are only dangerous together:

  • CVE-2026-60137 (GHSA-fpp7-x2x2-2mjf): a SQL injection in the author__not_in parameter of the internal WP_Query class. Rated Moderate in isolation, because on a stock install there is no unauthenticated code path that feeds attacker input into that parameter.
  • CVE-2026-63030 (GHSA-ff9f-jf42-662q): a REST API batch-route confusion weakness at /wp-json/batch/v1. Rated Critical, because it is what lets an anonymous request reach the injection above and turn it into remote code execution.

The affected and fixed versions differ between the two, which matters for triage:

  • 6.8.0 – 6.8.5: SQL injection only (CVE-2026-60137). No RCE chain on this branch. Fixed in 6.8.6.
  • 6.9.0 – 6.9.4: full RCE chain. Fixed in 6.9.5.
  • 7.0.0 – 7.0.1: full RCE chain. Fixed in 7.0.2.
  • 7.1 beta: fixed in 7.1 beta2.
  • Anything below 6.8.0 is not affected by the RCE.

The distinction is worth restating because it is easy to misread: the batch-route confusion (the RCE-enabling half) only exists on the 6.9 and 7.0 branches. A 6.8.x site has the injection but not the delivery mechanism, so it should be treated as an important-but-not-emergency SQLi patch. A 6.9.x or 7.0.x site is a pre-auth RCE and should be treated as an incident.

Background: The REST API Batch Endpoint

Since WordPress 5.6, core has shipped a batch controller registered at /wp-json/batch/v1. Its job is to accept several REST sub-requests in a single HTTP call and dispatch them internally, returning an array of responses that line up positionally with the array of requests that came in. It exists to cut round-trips for clients like the block editor that need to fire many small API calls at once.

Two properties of that design are relevant here. First, the batch controller re-dispatches each sub-request through the internal REST machinery rather than over the network, so the sub-requests inherit the dispatch context of the batch call. Second, the controller maintains parallel arrays (the incoming requests on one side, the tracking and response state on the other) and relies on those arrays staying aligned by index as it iterates. Any endpoint that maintains positional correspondence between two collections is a place to look for confusion bugs, and that is precisely the class CVE-2026-63030 falls into.

CVE-2026-60137: SQL Injection in WP_Query’s author__not_in

WP_Query is the core class that translates high-level query arguments into SQL against the posts table. Several of its arguments accept lists of IDs (post__in, post__not_in, author__in, author__not_in) and the long-standing contract is that these are passed as arrays of integers. When the value is an array, core casts each element with absint() before building the IN (...) / NOT IN (...) clause, which is why these parameters have historically been safe.

The root cause of CVE-2026-60137 is a type-handling gap: when author__not_in receives a string instead of an array, the per-element integer casting that protects the array path is bypassed, and the value flows into the generated SQL without the sanitization the code assumes has already happened. In other words, the defense was correct for the expected shape of the input and silently absent for an unexpected one: a classic “validate the array, forget the scalar” defect. The fix normalizes the parameter so the integer-casting invariant holds regardless of whether a caller passes a string or an array.

On its own this is only Moderate severity, and the reason is important: on a stock WordPress site there is no unauthenticated, attacker-controlled path that hands a raw string to author__not_in. The parameter is reachable through internal query construction and through some authenticated flows, but not, by itself, from an anonymous HTTP request. That gap is exactly what the second bug closes.

CVE-2026-63030: Batch-Route Confusion

The batch controller’s contract is that request N in the input maps to permission check N, dispatch N, and response N. CVE-2026-63030 is a route confusion bug: an attacker can craft a batch payload such that the controller’s internal bookkeeping arrays fall out of alignment, and a sub-request is dispatched under a context (routing, and by extension the permission and parameter handling associated with a different slot) than the one it should have been bound to.

The practical consequence is a confused-deputy condition: the batch endpoint, which is itself reachable without authentication for the sub-routes it exposes, becomes a vehicle for driving an internal query with attacker-chosen parameters that would not normally be reachable anonymously. That is what promotes the Moderate SQLi to a Critical, no-preconditions RCE: the batch route supplies the missing unauthenticated path to author__not_in.

Assetnote held the granular exploitation details (the exact batch payload shape and the specific misalignment primitive) at disclosure, publishing a non-exploitative checker at wp2shell.com instead, so sites could patch first. We keep the raw payload out of the prose above for the same reason: the mechanism is enough to reason about exposure and defense. With the fix now public, our own runnable proof-of-concept and a self-contained lab are linked below for authorized validation.

Composing the Two: From Anonymous Request to Shell

The chain, at the level of abstraction that matters for defenders, is:

  1. An anonymous request hits /wp-json/batch/v1 with a crafted batch body.
  2. The batch-route confusion (CVE-2026-63030) desynchronizes the controller’s per-slot tracking, so a sub-request is dispatched with parameters and a context it should never have had anonymously.
  3. That mis-dispatched sub-request reaches an internal WP_Query with a string author__not_in value under attacker control.
  4. The type-handling gap (CVE-2026-60137) lets that string reach the SQL layer, giving injection into the query against the posts table.
  5. From SQL injection, the attacker escalates to remote code execution: the standard routes being object-injection through unserialized data, writing attacker-controlled option/meta values that are later executed, or otherwise leveraging DB write/read primitives that WordPress’s own architecture exposes to a sufficiently capable injection.

The reason WordPress rated the composite Critical and forced auto-updates is step 1: there are no preconditions. No account, no nonce, no configuration toggle. If the site is on an affected 6.9.x or 7.0.x version and the REST API is reachable (which, on a default install, it is), the site is exploitable.

Watch: The Chain, Start to Shell

Our research team walked the chain end to end: the anonymous request to the batch endpoint, the route desync, and the injection landing against the posts table. The demo shows the reachability seam the two CVEs open up, enough to see how a stock install falls.

The Eight-Step Attack Chain

The wp2shell chain end to end — one unauthenticated POST to /wp-json/batch/v1 escalates through unauth SQLi, admin creation, and plugin upload to a persistent PHP webshell.

Step 1 — Fingerprint. An unauthenticated attacker hits GET /wp-json/ and reads the X-WP-* response headers or the HTML meta generator tag to confirm the WordPress version. Any version in the range 6.9.0 – 6.9.4 or 7.0.0 – 7.0.1 is fully exploitable.

Step 2 — Reentrancy probe. The attacker sends POST /wp-json/batch/v1 (or its query-string equivalent POST /?rest_route=/batch/v1) with a nested requests[] array whose sub-requests carry their own inner request. The response is HTTP 207 (Multi-Status). The confirmation markers are a pair of error codes in the response body: parse_path_failed on the first sub-request (the “primer,” which uses a deliberately unparseable path such as “///” or “http://:”) and block_cannot_read on the second (the “carrier”). The block_cannot_read code is the signal that the carrier sub-request re-entered the dispatcher and was served in a context it should never have reached from an anonymous batch — the reentrancy is live.

Step 3 — SQLi confirmation. The attacker fires the same batch with the inner request carrying ?author _ exclude=0) AND (SELECT 1 FROM (SELECT SLEEP(3))_z)– -. The reentrant dispatch means this parameter reaches WP_Query’s internal author __ not_in, which — receiving it as a string rather than an array — bypasses the per-element integer casting and string-interpolates the value directly into the WHERE clause. A three-second response delay confirms the injection is live, fired without authentication, without a nonce, without any current _ user _ can() check.

Step 4 — Read wp_users. The attacker enumerates INFORMATION_SCHEMA to discover the table prefix, then UNION-selects user_login, user_pass, and ID from the users table.

Step 5 — Admin acquisition. Two branches: (a) Hash crack: the wp_users.user_pass field is a bcrypt-HMAC-SHA384 hash on modern WordPress — expensive but not immune to offline cracking against weak passwords. (b) Customizer bridge: the more sophisticated path uses UNION SELECT to forge wp_posts rows carrying an oEmbed/customizer changeset, providing an authenticated admin context for a subsequent POST /wp/v2/users?roles=administrator call. WordPress writes the new admin row through wp_insert_user(), passing every capability check because the changeset makes it appear authorized. This branch only works on sites without a persistent object cache (Redis/Memcached). Additional escalation paths include option/meta value writing for later execution and object injection through unserialized data stored in the database.

Step 6 — Authenticate to wp-admin. With the new or recovered admin credentials, the attacker logs in via /wp-login.php and pulls admin nonces from /wp-admin/.

Step 7 — Plugin upload. A multipart POST /wp/v2/plugins (or the legacy POST /wp-admin/update.php?action=upload-plugin) delivers a ZIP containing {slug}/{slug}.php with a valid plugin header. WordPress unzips it into wp-content/plugins/{slug}/.

Step 8 — RCE. The webshell inside the plugin PHP responds to HTTP requests. The attacker now runs shell commands as the web-server user with no further authentication. All observed exploit tools self-clean by deactivating and deleting the plugin after the session ends.

Proof of Concept

Now that the patched releases are out, we have published a working proof-of-concept for authorized testing: github.com/securelayer7/WordPresShell. It chains CVE-2026-63030 and CVE-2026-60137 into unauthenticated admin creation and a webshell, and ships with a Docker Compose lab (a vulnerable 7.0.1 alongside a patched 7.0.2) so you can reproduce the bug and confirm the fix without touching anything you do not own.

The tool has three modes: verify (a non-destructive reachability check), dump (read from the database), and exec (single or interactive command execution). Authorized testing only. Run it against systems you own or have written permission to assess. The fastest safe use is to point verify at the bundled lab, or at your own staging host, to confirm your patch status.

Root Cause Analysis

The Batch Endpoint — What It Is Supposed to Do

WordPress’s REST API introduced a batch endpoint in version 5.6 to solve a genuine performance problem: admin interfaces sometimes needed to send multiple API calls in a row, each carrying its own round-trip cost. The endpoint at POST /wp-json/batch/v1 accepts a JSON body of the shape:

{
  "requests": [
    { "method": "POST", "path": "/wp/v2/posts", "body": {} },
    { "method": "PATCH", "path": "/wp/v2/media/42", "body": {} }
  ],
  "validation": "require-all-validate"
}

Internally, each sub-request is supposed to be executed through WP_REST_Server::dispatch() — the *internal* dispatch path, which runs a request against a matched route handler without re-entering the public HTTP entry point. The public entry point, serve_request(), is the one that establishes the top-level trust context: it reads authentication material, applies the rest_authentication_errors filters, and decides what the caller is allowed to do. The batch endpoint is deliberately reachable without authentication, because many of the individual routes it multiplexes are themselves public. That design decision is fine — as long as a sub-request can never climb back up to serve_request() and be re-interpreted as a brand-new top-level request.

CVE-2026-63030 — Dispatch Reentrancy ()

The pre-patch dispatcher does not enforce that boundary. A crafted sub-request — specifically one whose body carries another batch/REST request — can cause the server to re-enter serve_request() while a dispatch is already in flight. When that happens, the inner request is interpreted as a fresh top-level REST cycle: it re-runs the entry-point machinery and is served in a trust context detached from the outer request’s authorization decision. The same wire bytes are interpreted two different ways by two different consumers — the textbook shape of a CWE-436 interpretation conflict.

REST batch reentrancy dispatch
CVE-2026-63030 root cause: pre-patch, a nested sub-request re-enters serve_request() instead of dispatch() and is re-interpreted in a detached trust context; the is_dispatching() guard (right→left) is the fix.

The practical consequence is exactly the primitive wp2shell needs: an unauthenticated outer batch drives an inner request that is handled as though it arrived through a legitimate, separately-authorized channel. The malformed “primer” path (“///”, “http://:”) and the block_cannot_read marker are the externally observable symptoms of the request crossing that boundary — a route that should never be reachable from an anonymous batch gets invoked anyway.

reentrancy probe on the wire
The reentrancy probe on the wire: an HTTP 207 batch response carrying `parse_path_failed` on the primer and `block_cannot_read` on the carrier — the tell that the sub-request re-entered the dispatcher in the wrong context. (Illustrative capture.)

The Nested-Batch Trick — Where Reentrancy Becomes Reachable

Reentrancy on its own only re-serves an adjacent request; to reach WP_Query from an anonymous context the exploit needs two levels. The wp2shell payload nests a batch inside a batch: the outer “carrier” sub-request is a POST /wp/v2/posts whose body carries an inner batch, and that inner batch is what re-enters the dispatcher. The two-level nesting is what allows the author_exclude SQLi payload to ride all the way down into a posts/users-collection handler that honors query parameters — a handler the outer, unauthenticated request was never authorized to drive. This is the mechanism fa72c12879fbfda17d450fc1fd919f698f549f4d shuts off (see Patch Diffing).

CVE-2026-60137 — The Type-Handling Gap

WP_Query expects array inputs for ID-based parameters like author__not_in. When it receives an array, it applies per-element integer casting (absint()) before SQL construction — that casting is the sanitization. When it receives a string instead, the pre-patch code’s (array) cast simply wraps the raw string as a single-element array, so no per-element sanitization ever touches the payload, and the value flows into the generated SQL without escaping:

-- What WP_Query generates (simplified) when author__not_in receives a string:
WHERE post_author NOT IN(0) AND (SELECT 1 FROM (SELECT SLEEP(3))_z)-- -)
Time-blind confirmation
Time-blind confirmation: the SLEEP(3) payload in `author_exclude` pushes the batch response past three seconds, proving the string reached the SQL layer unsanitized and unauthenticated. (Illustrative capture.)

The wire parameter author_exclude (used by the REST API layer) is bound to author__not_in internally. The dispatch reentrancy means a re-served handler receives this wire parameter in a context where it was never meant to arrive — and the type-handling gap means the string value goes straight to the database. Type-handling gaps deserve more respect than they usually get. “Validate the array, forget the scalar” sounds like a cosmetic inconsistency until an unexpected input shape reaches it through a path the original author never modeled.

Why Reentrant Dispatch Is a Recurring Hazard

Multiplexing endpoints — anything that accepts a bundle of sub-requests and fans them out — are a persistent source of trust-boundary confusion in web frameworks. The failure mode is structural: the outer request passes one authorization check, and then a sub-request is allowed to re-enter a code path that establishes its *own* trust context, so the two disagree about who the caller is and what they may do. Whenever a framework exposes a batch/multiplex primitive, the security-critical invariant is that internal sub-requests execute through an internal dispatch path that can never climb back to the public entry point. WordPress’s fix enforces exactly that invariant.

Patch Diffing

The two commits most often cited alongside wp2shell fix two different CVEs in two different files. They are not “two coupled commits that fix the batch bug” — one closes the reentrancy (CVE-2026-63030), the other closes the SQL injection (CVE-2026-60137).

Two CVEs in two files
Two CVEs in two files: fa72c128 adds the serve_request() reentrancy guard (CVE-2026-63030); 97b5a752 replaces the (array) cast with wp_parse_id_list() in WP_Query (CVE-2026-60137).

CVE-2026-63030 — fa72c12879fbfda17d450fc1fd919f698f549f4d

Commit title: *”REST API: sub-requests must always use dispatch.”* This is the reentrancy guard. It refuses to start a fresh top-level REST cycle while another dispatch is already in flight, forcing internal sub-requests down the internal dispatch() path instead of back through the public entry point.

--- a/src/wp-includes/rest-api.php
+++ b/src/wp-includes/rest-api.php
@@ function rest_api_loaded() {
+	// Short-circuit before define()/die() if a REST dispatch is already in flight.
+	// serve_request() enforces this too; guarding here avoids the trailing die().
+	if ( isset( $GLOBALS['wp_rest_server'] )
+		&& $GLOBALS['wp_rest_server'] instanceof WP_REST_Server
+		&& $GLOBALS['wp_rest_server']->is_dispatching()
+	) {
+		return;
+	}
 
--- a/src/wp-includes/rest-api/class-wp-rest-server.php
+++ b/src/wp-includes/rest-api/class-wp-rest-server.php
@@ 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;
+		}

The is_dispatching() gate is the whole fix for the interpretation conflict: once a dispatch is in flight, no sub-request can re-enter serve_request() and be re-interpreted as a new top-level request. Everything downstream in the wp2shell chain depends on that reentry, so this commit alone breaks it.

CVE-2026-60137 — 97b5a75246f6c82fe9d9f0ba492f06d93b602b98

Commit title: *”Query: Force author__not_in values to be integers.”* This is entirely inside WP_Query — it has nothing to do with the batch dispatcher. It replaces the unsafe (array) cast (which wraps a raw string as a single element and lets it reach the query verbatim) with wp_parse_id_list(), which coerces every element to an integer before the value is ever interpolated.

--- a/src/wp-includes/class-wp-query.php
+++ b/src/wp-includes/class-wp-query.php
@@ 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 )
+				);
+				$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) ";

wp_parse_id_list() normalizes to an array and forces each element through integer parsing regardless of the input’s original type, so a string payload can no longer survive to the WHERE clause. Either commit, applied alone, breaks the wp2shell chain at a different link: fa72c128… removes the anonymous reach into WP_Query; 97b5a752… removes the injection even if a caller does reach it. WordPress shipped both because defense-in-depth across the seam is cheaper than betting the site on one of them.

Am I Affected?

Check your core version first. From the dashboard it is at the bottom-right of any admin screen; from the CLI:

wp core version

Map the result against the table above. If you are on 6.9.0–6.9.4 or 7.0.0–7.0.1, treat it as a live pre-auth RCE exposure. If you are on 6.8.0–6.8.5, you have the SQLi but not the RCE chain. Still patch, but the urgency profile is different. If you are already on 6.8.6, 6.9.5, 7.0.2, or later, you are patched.

You can also confirm the batch endpoint is exposed at all, since many hardened deployments already block or disable it:

curl - s - o /dev/null -w "%{http_code}n" https://YOUR-SITE/wp-json/batch/v1

Assetnote’s checker at wp2shell.com performs a non-destructive test against your own instance if you prefer an external confirmation. As always, only test systems you own or are authorized to assess.

Detection

Because the entry point is a specific, rarely-hit core route, this chain leaves reasonably distinctive traces. In access logs, look for:

  • Unauthenticated POST requests to /wp-json/batch/v1 (or the query-string form /?rest_route=/batch/v1), particularly from IPs with no prior authenticated session.
  • Batch bodies containing unusually large or malformed request arrays, or sub-requests referencing post/author query parameters.
  • SQL errors or anomalous query latency in the database log correlated with those requests; injection attempts frequently generate parse errors before a working payload lands.
  • Post-exploitation indicators: new admin users, unexpected changes to the active_plugins option, new files under wp-content/uploads with a .php extension, or modifications to wp-config.php and theme files.

If you find evidence of exploitation rather than just probing, treat it as a full compromise: an RCE at the WordPress process level generally means the database, all stored secrets, and any credentials reachable from the host should be considered exposed. Patching after the fact does not evict an attacker who already has a shell or a persistence mechanism.

Mitigation

Update core. That is the fix. Move to 6.8.6, 6.9.5, or 7.0.2 (or newer) for your branch. WordPress enabled forced auto-updates for affected versions, so many sites are already patched, but confirm rather than assume, especially where auto-updates were disabled via WP_AUTO_UPDATE_CORE, a constant in wp-config.php, or a management plugin.

If you genuinely cannot patch immediately, the following reduce exposure as temporary cover only. None is a substitute for the update:

  • Block the batch route at the edge. A WAF or reverse-proxy rule that denies POST to /wp-json/batch/v1 (and the ?rest_route=/batch/v1 equivalent) removes the RCE delivery path. This is the highest-value single mitigation and is low-risk for most sites, which do not depend on the batch endpoint for front-end functionality.
  • Disable the batch endpoint with a must-use plugin. A small mu-plugin can unregister the batch/v1 route or require authentication on it, closing the anonymous path without touching the rest of the REST API.
  • Restrict unauthenticated REST access more broadly if your architecture allows it, for example requiring authentication on /wp-json/ where no anonymous consumer needs it.

Edge blocking is preferable to application-layer filtering here because it keeps the crafted request away from the vulnerable code entirely. Whichever stopgap you choose, schedule the core update as the actual remediation and remove the workaround afterward so it does not silently break the block editor or other legitimate batch consumers later.

The Broader Lesson

wp2shell is a clean illustration of a pattern we see repeatedly in mature codebases: two individually-unremarkable bugs that are each correctly triaged as low or moderate severity, right up until someone finds the edge that connects them. The SQL injection was “safe” because nothing unauthenticated could reach it. The batch route was a “confusion” bug whose impact depended entirely on what it could be pointed at. Neither team’s severity rating was wrong in isolation; the danger lived in the seam between them.

This is also why type-handling gaps deserve more respect than they usually get. “Validate the array, forget the scalar” sounds like a cosmetic inconsistency until an unexpected input shape reaches it through a path the original author never modeled. Defensive coding that normalizes input at the boundary, coercing to the expected type before any trust decision, is what turns these seams into dead ends instead of exploitation primitives.

Impact

A pre-authentication RCE in WordPress core is, by reach alone, one of the most consequential web vulnerabilities of 2026. It requires no credentials, no user interaction, and no non-default configuration, and it targets software that fronts a very large fraction of the internet. WordPress’s decision to force automatic updates was proportionate to that reach.

For defenders the action is unambiguous: confirm your core version, patch to the fixed release for your branch, and if you cannot patch on the spot, block /wp-json/batch/v1 at the edge until you can. Then check your logs for the request signatures above. A forced patch protects you going forward, but it says nothing about whether someone reached you in the window before it landed.

References and further reading

Credit

The RCE chain (CVE-2026-63030, the REST batch-route confusion) was found and reported by Adam Kues of Assetnote / Searchlight Cyber, through WordPress’s HackerOne program. The underlying SQL injection (CVE-2026-60137, WP_Query‘s author__not_in) is credited to TF1T, dtro, and haongo. The fixes landed in the WordPress core 6.8.6, 6.9.5, and 7.0.2 security releases; the maintainers coordinated the disclosure and forced auto-updates. This write-up is our analysis of their work. Assetnote held their granular exploit at disclosure so sites could patch; with the fixed releases now shipped, our own proof-of-concept (linked above) is provided for authorized testing.

SecureLayer7’s research team finds chains like wp2shell before they ship: the seam between two “moderate” bugs that no single scanner flags. If WordPress is load-bearing in your estate, book a scoping call. We’ll test for the unauthenticated-reachability paths a version bump never shows you.