Anonymous RCE on 43% of the web through a batch off-by-one
On Friday July 17, 2026 WordPress shipped 6.9.5 and 7.0.2 as an emergency release and flipped the built-in forced auto-update on. By Sunday morning Hexastrike had honeypot hits, live incident response engagements, and the first public proof-of-concept sitting on GitHub as wp2shell. If your WordPress site was on 6.9 or 7.0 without auto-updates on, an anonymous stranger could reach a shell on your box over the weekend and you would not have seen a single failed login, because no login ever happened (SecurityWeek, The Hacker News).
The two arrays that fell out of step
CVE-2026-63030 lives inside WP_REST_Server::serve_batch_request_v1(), the handler behind the /wp-json/batch/v1 route shipped by default since WordPress 5.6. The batch endpoint takes a JSON envelope of sub-requests, dispatches each through the same REST plumbing as regular wp-json traffic, and returns responses in order. Internally it builds two arrays: $matches for the route match of each sub-request, $validation for the validation result, and dispatch walks both by the same index, trusting that entry $i in one describes entry $i in the other.
The bug is quiet. When a sub-request path fails wp_parse_url(), the handler pushes a WP_Error onto $validation[] and calls continue without appending a placeholder to $matches[]. From that point on every remaining sub-request is off by one: sub-request i is dispatched using the route match intended for sub-request i+1. The permission callback registered for that route runs against the later request context, but the actual handler runs on the earlier sub-request's body. Zsec's code trace lines the vulnerable PHP up next to the 6.9.5 fix, a one-liner that keeps $matches, $validation, and $requests aligned for the error case.
CVE-2026-60137 lives elsewhere, in WP_Query's author__not_in parameter. It is documented to take an array of user IDs, and when the value is not an array an early check is skipped and the raw string ends up concatenated into the SQL WP_Query emits. On its own the parameter is not reachable anonymously, because every route that takes it sits behind a permission callback, and that was the design assumption for years. Chained with the batch off-by-one, it stops holding.
From that off-by-one to a rogue admin
The public exploit runs the chain in a specific shape. It sends a batch envelope whose first sub-request is a deliberately malformed URL that fails wp_parse_url() and trips the desync. The next sub-request carries a string in author__not_in and lands on a route that would otherwise refuse it, because the dispatcher is now consulting a permission callback from a different entry. That yields time-based blind SQL injection as the web user, and the exploit builds from there.
A UNION-based injection forges a row in wp_posts that the oEmbed cache accepts as a real post, then a customizer changeset is written against that forged post authored by an ID the attacker controls. Publishing the changeset creates a fresh administrator through the same path the customizer uses for a real admin. The exploit then logs in as that account, uploads a small plugin that registers a REST endpoint, and runs commands through it. Eye Security's defender guide and Help Net Security trace the same end-to-end path, and public Python PoCs implement it. The chain fires against a default install of WordPress 6.9.0 through 6.9.4 or 7.0.0 through 7.0.1 with no plugins, no auth, and no user interaction, the only precondition being that /wp-json/batch/v1 is reachable, which is the default.
/wp-json/batch/v1 shifts the dispatcher's route matches by one slot, so the next sub-request runs against the wrong permission callback, which reaches a WP_Query SQLi, which forges a customizer changeset that creates a rogue admin and drops a plugin webshell.
Why a scanner queue puts this in the wrong place
Both CVEs land in your feed as high-severity WordPress issues, and a CVSS-first queue treats them as two separate items alongside every plugin advisory of the week. The chained impact is invisible in either individual CVSS vector because CVSS scores the primitive, not the composition: the batch off-by-one on its own is a route confusion in a permission callback, and the SQLi on its own is authenticated. Neither reads like "anonymous RCE on default install" until you notice that WordPress 6.9 is the version welding them into a chain. Two internal 6.9.4 blog engines behind an SSO reverse proxy are not the same finding as an unauthenticated site with a public /wp-json/, and a queue that flattens both to "critical WordPress core CVE" wastes the operator's Sunday.
TRIS on wp2shell, layer by layer
TRIS, the Threat and Risk Intelligence Scoring engine inside CVEasy AI, does not score CVEs in isolation. It scores an exposure, a CVE observed on a specific asset in a specific posture. Four layers decide where wp2shell lands for a given finding.
Exploitation status. Public PoC code sits on multiple GitHub repositories, Hexastrike honeypots caught scanning within roughly 48 hours of disclosure, and the same team already ran incident response on live intrusions per SecurityWeek. That is proven in-the-wild abuse, and it moves the exploitation weight to its top setting.
Reachability. The /wp-json/batch/v1 route is the entire vulnerability surface. If the endpoint answers to an unauthenticated request from the public internet the exposure is reachable; if a WAF strips batch requests, if the front-door reverse proxy denies /wp-json/, or if the site sits behind a company SSO, reachability drops sharply. TRIS reads posture off the asset, not the CVE.
Blast radius. WordPress powers roughly 43 percent of public websites per W3Techs, but that is a population number, not an exposure number. It is measured in the specific fleet: marketing sites sharing a database with the corporate identity provider score differently from a static export on an S3 bucket still labelled "wp".
Compensating controls. Managed hosts that took the forced auto-update on July 17 patched themselves; sites with the built-in auto-update disabled did not. WAF rules that block POST /wp-json/batch/v1 or reject non-array author__not_in cut off the chain. TRIS applies those controls as multipliers, not as a close-ticket toggle. The same two CVE IDs land in the top ACT band on an exposed 6.9.3 site with public /wp-json/ and no WAF, and in the far lower TRACK band on a 6.9.3 site behind SSO that already picked up 6.9.5.
What to do this week
Update to 6.9.5 or 7.0.2 first. If your sites run the built-in updater, WordPress has already forced the release out. Confirm the version in the dashboard, and if any site is still on 6.9.0 through 6.9.4 or 7.0.0 through 7.0.1, patch tonight, not next Patch Tuesday.
Look backwards for compromise before you assume you dodged it. On any site that ran a vulnerable 6.9.x or 7.0.x build with a reachable /wp-json/batch/v1 between the July 17 disclosure and the moment it was patched, hunt for POST requests to that endpoint, oversized batch envelopes, non-array author__not_in values, new administrator accounts, new plugins in wp-content/plugins/ with a single tiny PHP file that registers a REST route, and unfamiliar rows in wp_posts. Help Net Security lists the same indicators.
Interim mitigation if you cannot patch now. Block POST to /wp-json/batch/v1 at the web server or WAF, reject requests where author__not_in does not parse as a JSON array, and if a reverse proxy fronts WordPress, denying anonymous /wp-json/ removes the chain until the version update ships.
Rotate what the shell could have touched. If a site turns up with a rogue admin or a newly written plugin, the database left the building. Rotate WordPress salts in wp-config.php, force a password reset for every real administrator, rotate API tokens and integration secrets in wp_options, and audit services that trust the site as a caller.
How CVEasy AI surfaces wp2shell
CVEasy AI is the number one local-first CTEM platform. It ingests the primary sources for events like this one, the WordPress core bulletin, the researcher notes from Aikido and Eye Security, and the exploitation telemetry from honeypot networks, then runs TRIS against the asset inventory you already gave the platform. The output is not a red banner reading "critical WordPress CVE detected"; it is a per-asset priority reflecting whether /wp-json/batch/v1 answers anonymously, whether the auto-updater applied 6.9.5 or 7.0.2, and whether the WAF rejects the exact request shape the exploit needs. Because the platform runs on your hardware, the inventory stays local, which matters when the answer to "did we get hit over the weekend" is written in your access logs, your database rows, and your plugin directories.
Sources: SecurityWeek, The Hacker News, Zsec code trace, Eye Security defender guide, Help Net Security