Jul 20, 2026BTA Team9 min read

wp2shell Turns /wp-json/batch/v1 Into Unauth RCE: How a One-Index Shift in WordPress Core Hands Anonymous Attackers Admin

We noticed something at BlueTeamAutomation on Friday afternoon that made everyone stop scrolling. The two WordPress CVEs that dropped on July 17, CVE-2026-63030 and CVE-2026-60137 (nicknamed wp2shell by Assetnote's Adam Kues, who found the first half), do not chain through some exotic gadget. They chain because two arrays inside WordPress core's REST batch handler drift out of sync by exactly one slot whenever a sub-request fails validation, and that single index shift, in code that runs on roughly 43% of the public web, is enough for an anonymous HTTP request to walk out with a fresh administrator account on your site.

WordPress force-pushed the fix through its auto-update system the same day, but forced auto-updates are the kind of thing every seasoned admin turns off, and public exploits from several independent GitHub authors went live within hours of coordinated disclosure ending. It is worth understanding line by line, because parallel arrays that must stay index-aligned are the kind of mistake you have written yourself.

The batch endpoint anonymous scanners can already reach

WordPress core added a REST batch handler in 6.9 so plugins and themes could fan a burst of REST calls into one HTTP round trip. The endpoint sits at /wp-json/batch/v1 and is also reachable via the pretty-permalink-free form ?rest_route=/batch/v1, which matters because a WAF rule that only matches the first path leaves the second one wide open. The route itself is registered without a permission callback on the reasonable theory that each sub-request will be gated by its own handler's permission check when the dispatcher walks the list.

When a batch POST arrives, WP_REST_Server::serve_batch_request_v1() builds three parallel arrays that are supposed to stay index-aligned: $requests (the raw sub-requests), $validation (one entry per sub-request, either a matched route or a WP_Error), and $matches (the route and handler each sub-request will run against). The dispatcher iterates by index, calling the handler at $matches[i] against $requests[i] after its permission callback approves. Every sub-request the batch runs is supposed to be one the caller could have made alone, subject to the exact permission gate that lives on that route.

Two parallel arrays that were never meant to disagree

The bug is a housekeeping mistake in the validation loop. When a sub-request's path fails to match any registered route, for example the string http:// which trips the URL parser before matching, the loop pushes the resulting WP_Error into $validation[] but (correctly) does not push anything into $matches[]. What it forgets to do is push a placeholder so the arrays stay parallel by construction. From that point on the two lists are off by one, and every later sub-request gets dispatched against the route and permission callback that were calculated for the sub-request sitting one slot ahead of it, which Hadrian's engineers walk through in code.

The attacker's move is to pick the shift target on purpose: a deliberately broken URL early in the batch, followed by a sub-request whose parameters you actually want executed, followed by a benign public route whose permission callback returns true. The dispatcher then hands the attacker's parameters to the benign route's permission callback (which passes) and runs its handler against attacker-controlled inputs. Any handler whose permission gate you can satisfy with a public request becomes reachable with attacker-controlled data, which is unauth-to-privileged in a single HTTP request.

The author__not_in guard that only fires when it is an array

The second half of wp2shell, tracked as CVE-2026-60137 and disclosed by TF1T, dtro, and haongo, lives in WP_Query. When a caller passes author__not_in, WordPress builds a NOT IN (...) clause and, in the normal case, sanitizes each element as an integer because the surrounding block starts with if ( is_array( $author__not_in ) ) and iterates. Pass a string instead of an array and that guard skips the sanitizer entirely, so the raw string interpolates straight into the SQL. On its own the bug needs a caller already authorized to build a WP_Query with untrusted parameters; chained through the batch confusion, "already authorized" becomes "anyone with an internet connection".

From there the chain uses the SQLi as a UNION SELECT row-forgery primitive, faking rows that appear in the wp_posts result set. WordPress reads those forged rows through its own content-rendering pipeline, and the oEmbed cache path turns them into real writes back into wp_options. Once the attacker controls a serialized value there, changeset elevation plus a re-entrant parse_request execute the next hop in an admin request context (Aikido traces the exact sequence), and one POST ends with a fresh administrator account. The RCE half only fires when there is no persistent object cache in front of wp_options so the forged rows can round-trip, and stock WordPress ships with none.

Why this walks past your WAF and your SIEM

Every part of this attack is a legitimately shaped WordPress request: a POST to a public endpoint that core itself registers and real plugins hit constantly, with a JSON body that is exactly the shape the endpoint takes. No shell metacharacters, no obvious traversal, no known-bad user-agent, nothing antivirus can taste. The SQLi payload sits inside a nested params.author__not_in field of an inner sub-request, and the only real giveaway is a harmless-looking http:// string sitting in an earlier sub-request's path. Signature WAFs that keyed off "SQL keywords in query string" or "webshell in POST body" have nothing to bite on, which is why Cloudflare, Imperva, and others rolled out bespoke managed rules rather than relying on their generic sets.

The observable footprint is boring if you are not looking for it: on the wire, a single POST to /wp-json/batch/v1 or ?rest_route=/batch/v1 from an unauthenticated source, 200 response a few hundred bytes long; on the host, a new row in wp_users whose user_registered timestamp matches the request and whose wp_usermeta wp_capabilities is a:1:{s:13:"administrator";b:1;}. Attackers usually follow up within minutes with an authenticated admin session from a fresh IP, a plugin install or theme upload that writes PHP under wp-content/, and an outbound callback. Mapped to ATT&CK, this is T1190 for initial access, T1136.001 for the account creation, T1078.003 the moment they log in, and T1505.003 as soon as a webshell lands. If your only WordPress telemetry is "did WAF block anything" and "did the site return 200", none of it is visible until a customer notices the site is serving crypto ads.

What to hunt in EDR and SIEM, and what to rotate

Patch first. WordPress 6.8.6, 6.9.5, and 7.0.2 all shipped July 17, 2026, and the forced auto-update path should have reached most sites that did not explicitly disable it. Confirm the build by reading WP_VERSION in wp-includes/version.php on the actual filesystem, not by trusting the dashboard, because a compromised site can lie about its version in the admin UI. Blocking the endpoint at the edge is fine as an interim, but the rule needs to cover both paths (URI matches /wp-json/batch/v1 OR query string contains rest_route=/batch/v1), and keying it on unauthenticated requests only avoids breaking legitimate plugin batch calls after the patch.

For hunts, three queries pay for themselves. Start with web-tier logs: POSTs to either batch path from source IPs that have never authenticated to your site, response 200 or 500, last 30 days. Next, from the WordPress database or a wp-CLI user list, pull every user with the administrator capability whose user_registered timestamp lands after July 15 and whose email domain you do not recognize. Finally, on the web host, have EDR alert on new PHP files written under wp-content/plugins/, wp-content/mu-plugins/, or wp-content/uploads/ by the PHP-FPM or Apache worker user outside a normal deploy window; that one catches the follow-on webshell no matter which admin action was abused.

If anything comes back positive, rotate everything the compromised site had access to (secret keys and salts in wp-config.php, every admin password via a wp-CLI force reset, DB credentials, API keys in wp_options or plugin settings, and all active auth cookies), then reinstall core files from a known-good tarball rather than trusting the update-in-place, because a shell that landed before you patched can sit there through the upgrade. Then replay the exploit against the site after you patched: a broken URL as the pivot plus two batch sub-requests, and the response should be a boring 200 with no side effects. If you see the request confused-dispatched into a neighbouring handler, the patch did not take.

That last step, running the real exploit against the patched service and watching your detection pipeline light up (or not), is the loop BlueTeamAutomation automates end to end across BAS, EDR, SIEM, and SOAR, so the detection you built and the proof it still works stop being a one-off scramble.

Turn every advisory into a validated detection

BASzy replays real exploit traffic against your live stack, correlates the noise into your EDR and SIEM, and hands your SOC a signed record that the fix held. See how the whole loop runs.

Explore BASzy →