Tech10 min read

wp2shell: pre-auth RCE in WordPress Core via a REST API batch route

IkesanContents

TL;DR

What happened WordPress 6.9.0–6.9.4 and 7.0.0–7.0.1 are vulnerable to pre-auth RCE (remote code execution) from REST API batch-route confusion combined with SQL injection.
6.8.0–6.8.5 are outside the RCE chain, but are subject to the SQL injection where a plugin, theme, mu-plugin, or custom code passes untrusted values into WP_Query

What to do Update to WordPress 7.0.2, 6.9.5, or 6.8.6. Forced auto-updates were enabled, but verify the actually running version in the admin screen or via WP-CLI

Temporary measure Until you update, block REST API batch access from unauthenticated users. If your WAF can’t determine auth state, that means blocking /wp-json/batch/v1 and ?rest_route=/batch/v1 themselves — which also stops legitimate REST API integrations, so keep it short


WordPress 7.0.2 shipped on July 17, 2026.
It’s not a routine maintenance release but a security release containing one Critical and one High.
Citing the severity, WordPress.org also enabled a forced update via the automatic update system for affected sites.

One of the fixes covers a problem that combines REST API batch-route confusion with SQL injection to reach RCE.
Adam Kues of Searchlight Cyber reported it, and it’s called wp2shell.
Per the reporter, an anonymous user can exploit it against a standard WordPress with no plugins. Note the credit split: Adam Kues is credited on the RCE chain, while WordPress officially credits TF1T, dtro, and haongo for the SQL injection side.

The article I wrote a few days ago, WordPress REST API: locking down Application Passwords, CORS, and rate limiting separately, was about treating the REST API as an entry point for external integration.
With wp2shell, Core’s REST API batch processing and a SQL injection overlap and reach RCE.
This isn’t a plugin permission_callback mistake — the starting point is Core’s REST API batch processing.

The damage differs between 6.8.x and 6.9+

The official WordPress release states 7.0.2 fixes a “facilitated SQL injection” and a “REST API batch-route confusion and SQL injection issue leading to Remote Code Execution.”
The GHSAs are split into two as well. Severity differs by rater: WordPress officially rates the two Critical and High, while the GitHub Advisory rates the SQL injection side Moderate.

IdentifierDescriptionAffectedFixed
CVE-2026-60137 / GHSA-fpp7-x2x2-2mjfImproper sanitization of author__not_in in WP_Query. On its own, becomes SQL injection when a plugin, theme, mu-plugin, or custom code passes untrusted values6.8.0–6.8.5, 6.9.0–6.9.4, 7.0.0–7.0.16.8.6, 6.9.5, 7.0.2
CVE-2026-63030 / GHSA-ff9f-jf42-662qREST API batch-route confusion. Combined with the SQL injection above, yields RCE6.9.0–6.9.4, 7.0.0–7.0.16.9.5, 7.0.2

6.8.x is not in this RCE chain.
But the SQL injection side covers 6.8.0 through 6.8.5, so if you’re staying on 6.8.x, move up to 6.8.6.

6.9.0–6.9.4 and 7.0.0–7.0.1 are where the two connect.
WordPress 7.1 Beta 1 is also affected, with the fix landing in 7.1 Beta 2.

The batch route drifts onto another request’s processing

The documented entry point is /wp-json/batch/v1.
This is the route that bundles multiple REST API subrequests into a single HTTP request, present since WordPress 5.6. The batch feature wasn’t vulnerable from the start. The error handling that caused the route confusion was introduced in WordPress 6.9, which is why CVE-2026-63030’s affected range is limited to 6.9 and later.

In the published fix diff, batch processing tracked subrequests, their corresponding routes/handlers, and validation results in separate arrays.
Only when a malformed subrequest became a WP_Error did the handler array not get an element added, shifting the mapping between subsequent requests and handlers by one.
As a result, a subrequest gets processed with a handler and validation result other than its intended pair, allowing it to reach a handler the batch shouldn’t be able to call, or one whose validation was bypassed. From there, passing a scalar into WP_Query’s author__not_in concatenates the value into the SQL NOT IN clause without integer conversion.

flowchart TD
    A["Anonymous HTTP request"] --> B["/wp-json/batch/v1"]
    B --> C["Subrequest processing drifts"]
    C --> D["Reaches an unintended REST handler"]
    D --> E["Input enters WP_Query author__not_in"]
    E --> F["SQL injection"]
    F --> G["From 6.9 on, proceeds to the RCE chain"]

Searchlight Cyber has held back its own detailed technical write-up to give administrators time to update.
But the public 7.0.2 diff shows, in addition to preserving the array mapping, a re-entrancy guard preventing a separate top-level REST dispatch from starting mid-dispatch, and a fix that always converts author__not_in into a list of integer IDs.

Don’t wait for auto-update — confirm the running version

WordPress.org enabled forced auto-updates.
As the official notice conditions it — “will begin automatically on sites that support automatic background updates” — the forced update hasn’t necessarily completed on every site. WordPress also doesn’t explain in the release note how it applies to sites that disabled auto-updates.

Check not just the admin screen but, if WP-CLI is available, on the actual machine.

wp core version

Minimum required versions: 7.0.2 for the 7.0.x line, 6.9.5 for 6.9.x, 6.8.6 for 6.8.x.
Pre-6.8 is outside the two issues per the official release, but if you’re updating, move to 6.8.6 or later where this fix is included.

Even if your hosting panel says “auto-updated,” if you run multiple environments, verify production, staging, old domains, subdirectory installs, and per-customer instances separately.
WordPress is often installed multiple times on one server, and a single admin screen only shows the running version of your own install.

If you block with a WAF, block only the batch route, briefly

If you can’t update immediately, Searchlight Cyber suggests a temporary measure: block batch API access from unauthenticated users. If your WAF can’t reliably determine WordPress auth state, block the batch route itself until you update.
In the WAF (Web Application Firewall — a defensive layer that inspects HTTP requests and blocks attack patterns), target both of the following, after URL decoding and normalization:

  • /wp-json/batch/v1
  • ?rest_route=/batch/v1

Depending on permalink settings and server config, the WordPress REST API is reachable in both the clean-path form and the rest_route query form.
Because WordPress can override the HTTP method via the _method query and the X-HTTP-Method-Override header, don’t limit your WAF block condition to a received POST only. Also check subdirectory installs and direct-to-origin connections that bypass the WAF.

A plugin that blocks all unauthenticated REST API access is also floated as a temporary measure.
But on sites where public post retrieval, headless frontends, forms, external integrations, or AI integrations use the unauthenticated REST API, legitimate features stop too.
As I wrote in the previous REST API article, the more a site fails to separate its public read API from its privileged operation API, the more legitimate functionality breaks during an emergency block.

Cloudflare states that CVE-2026-63030’s RCE succeeds only “when you’re not using a persistent object cache.”
Sites actually using a persistent object cache like Redis or Memcached as WordPress’s object cache don’t meet the known RCE condition Cloudflare describes.
This is not a fix. The SQL injection side remains, and another path may surface later, so updating Core itself is still necessary.

If you use Cloudflare, the company deployed two rules — one for the SQL injection and one for the RCE — for free and paid plans at 17:03 UTC on July 17, 2026. Origin traffic not going through Cloudflare is out of scope, and on paid plans, verify Managed Rules are enabled and the action isn’t overridden to Log.

Check logs starting from REST batch and author_exclude

Since a PoC has appeared publicly, even if you’ve updated, review logs from the pre-patch exposure window. A published PoC and confirmed real-world exploitation are separate — as of July 19, 2026, neither CVE is in CISA’s KEV catalog.
First, pick up access to /wp-json/batch/v1 and rest_route=/batch/v1.
On a site that doesn’t normally use REST API batching, an anonymous POST there stands out on its own.

Even if the received method is something other than POST, include it if there’s a record of _method=POST or X-HTTP-Method-Override: POST. The batch API normally puts each subrequest’s result in the JSON response body and returns 207 Multi-Status for the batch as a whole, so the access log’s status code alone can’t tell you whether the attack succeeded.
The parameter name used in the external REST API is author_exclude, which WordPress Core converts internally to WP_Query’s author__not_in. Because author_exclude and SQL fragments live in the JSON request body, a string search won’t find them if your normal web access log doesn’t record the body. Check the log among WAF, CDN, reverse proxy, and application that retains the request body. If you use Cloudflare, also check Security Events for the two detection rules it deployed.

If you suspect RCE, confirming compromise is a separate task from updating Core.
Check recently created or modified PHP, unfamiliar administrator users, outbound traffic, and altered plugin/theme files. If you have EDR, FIM, or OS audit logs that record file access, also examine PHP written to wp-config.php and access from unusual processes. A normal web access log alone can’t tell you whether local files were read.
If compromise is confirmed, rotate — according to exposure scope — administrator passwords (reset), all sessions (invalidate), Application Passwords (revoke), auth salts, database credentials, and plugin or external API secrets.

References: