wp2shell is the public name for an unauthenticated remote code execution chain in WordPress core, formed by two flaws disclosed together on 17 July 2026: CVE-2026-63030, a route-confusion bug in the REST API's batch endpoint, and CVE-2026-60137, a SQL injection in WP_Query's author__not_in parameter. Chained, they let an attacker with no account and no plugin dependency run code on a default WordPress 6.9.x or 7.0.x install. WordPress.org shipped fixes the same day and enabled forced automatic updates; public proof-of-concept exploits and confirmed in-the-wild attacks followed within hours.
This piece treats it as a defender's problem, not an attacker's toolkit. It explains the mechanism only as far as detection and remediation need it, and every request shown here is schematic, illustrating the shape researchers described rather than a working exploit. What follows: how the two bugs chain, which WordPress branches are affected and fixed, what exploitation looks like in logs and on disk, the indicators reported from real attacks, a small script to check a site, and what to do next.
Two facts should drive urgency. First, exploitation needs nothing from the visitor: no login, no plugin, no user interaction. Second, probing began almost immediately. Researchers recorded the first exploitation attempts within minutes of disclosure, with SQL injection attempts following 13 minutes later. Any site that ran an affected version between 17 July 2026 and when it patched should be checked for compromise, not just updated.
How the two CVEs chain into one exploit
CVE-2026-63030 lives in the REST API's batch processor, reachable at /wp-json/batch/v1. WordPress validates each sub-request in a batch before executing it, but the validation and execution run as separate loops: when wp_parse_url() fails on one entry, the two loops fall out of alignment, and a later sub-request executes under a different route handler than the one it was validated against.
That misrouting matters because CVE-2026-60137 sits behind an authorisation check that assumes normal routing. The flaw itself is textbook CWE-89: WP_Query does not sanitise the author__not_in parameter before building SQL, but ordinarily only an authenticated request can reach it. The batch route confusion is what lets an anonymous request reach author__not_in at all, at which point the injection runs with no credentials in the picture.
POST /wp-json/batch/v1 HTTP/1.1
Host: wp.lab.example
Content-Type: application/json
{
"validation": "require-all-validate",
"requests": [
{ "method": "GET", "path": "/wp/v2/users/me" },
{ "method": "GET", "path": "/wp/v2/posts?author__not_in[]=<value>" }
]
}
# researchers describe the second entry being validated against one
# route but dispatched to another once wp_parse_url() desyncs the
# validation and execution loops - <value> is illustrative, not a
# working injection string. Publicly reported post-exploitation activity goes well past a database read. Attackers have been observed using the SQL injection to manipulate stored state, then calling /wp-json/wp/v2/users?context=edit to harvest existing usernames, provisioning a rogue administrator account, and using it to upload a plugin that is, in practice, a PHP web shell.
Technical breakdown of the vulnerability chain
CVE-2026-63030's root cause is a two-loop implementation in the batch processor: WordPress validates every sub-request in one loop, then dispatches them in a second, separate loop. The failure mode is specific: when wp_parse_url() fails to parse one sub-request's path, the error is written into the validation array but never mirrored into the array of matched route handlers, so the two arrays drift out of alignment. Every sub-request queued after that point still passes validation, but dispatches under the route handler matched for a different entry - what researchers call route confusion, since no check is skipped, it just runs against the wrong target.
CVE-2026-60137 sits in WP_Query's author__not_in parameter, which WordPress does not sanitise before it reaches SQL - a textbook CWE-89 SQL injection. On its own, NVD scores the flaw 5.9, because WordPress restricts author__not_in to authenticated requests. CISA's advisory database scores it 9.1 once the batch-endpoint desync is factored in, since that desync bypasses the authentication check protecting the parameter and exposes the injection to anonymous requests. Testing found the resulting query supports UNION-based injection, letting an attacker append arbitrary SELECT statements rather than just alter the query's WHERE clause.
# illustrative only - not the actual WordPress implementation
validated = []
matched_handlers = []
for req in batch_requests:
parsed = wp_parse_url(req.path)
if parsed is None:
validated.append(Error("bad path"))
# bug: no corresponding entry pushed to matched_handlers
continue
validated.append(Ok(req))
matched_handlers.append(match_route(req.path))
# dispatch loop reads validated[i] but executes matched_handlers[i] -
# once one entry is skipped, every i after it is off by one, so a
# later sub-request runs under a handler matched for a different path.
for i, result in enumerate(validated):
if result.ok:
dispatch(matched_handlers[i], batch_requests[i]) | Source | Score | Vector | Basis |
|---|---|---|---|
| NVD | 5.9 MEDIUM | AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N | author__not_in reachable only when authenticated |
| CISA-ADP | 9.1 CRITICAL | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N | scored against the full chain with CVE-2026-63030 |
That gap between 5.9 and 9.1 is not a scoring error - the two numbers answer different questions. The 5.9 score describes the SQL injection assuming WordPress's own authentication gate holds; CISA's 9.1 describes it once CVE-2026-63030 has already removed that gate. The fact sheet above cites the chain's own assessed severity rather than either individual CVE's score, for the same reason: neither flaw's standalone number describes what an attacker can do with both in hand.
Affected versions and the fix
The SQL injection, CVE-2026-60137, reaches back to WordPress 6.8.0. The route-confusion bug, CVE-2026-63030, exists only in the 6.9.x and 7.0.x branches, which is also where the full unauthenticated RCE chain applies; a 6.8.x site carries the injection but, per current reporting, not the anonymous path to reach it. Versions before 6.8 are not affected by either flaw.
| Branch | Vulnerable range | Fixed in |
|---|---|---|
| 6.8.x | 6.8.0 - 6.8.5 | 6.8.6 |
| 6.9.x | 6.9.0 - 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 - 7.0.1 | 7.0.2 |
| 7.1 beta | pre-release | 7.1 beta2 |
WordPress.org enabled forced automatic security updates across affected supported installations on disclosure day, so many sites patched without action from their operators. Do not assume yours was one of them: managed hosts that disable core auto-updates, staging copies, and multisite networks with custom update hooks are common places the forced update does not reach. Confirm the running version directly rather than assuming it.
What exploitation looks like in logs
Exploitation leaves a trace in three places: the REST layer, the web-server access log, and the file system. The durable signal is the relationship between the web-server process and what it spawns next, since request-level strings are trivial for an attacker to vary once a public proof-of-concept exists.
# combined-format access log; source is a documentation-range address
198.51.100.44 - - [21/Jul/2026:03:14:02 +0000] "POST /wp-json/batch/v1 HTTP/1.1" 207 812 "-" "cve-2026-63030/1.0"
grep -E '"POST (/wp-json/batch/v1|/\?rest_route=/batch/v1)' access.log \
| grep -Ei 'cve-2026-63030|wp2shell|rezwp2shell' A hit on /wp-json/batch/v1 is not proof by itself; the endpoint has legitimate uses and a 207 Multi-Status response is its normal reply. What raises it from noise to finding is a known-bad user agent, an external source with no business calling the endpoint, or a second request immediately afterwards to an endpoint that should never follow a batch call from an anonymous session.
grep -E '"(GET|POST) (/wp-json/wp/v2/users\?context=edit|/wp-admin/plugin-install\.php|/wp-admin/update\.php\?action=upload-plugin)' access.log Correlate the two. A batch hit followed within seconds by a user-enumeration or plugin-upload request from the same source is close to certain evidence of exploitation, not merely an attempt.
Indicators of compromise
Reported attacks leave artefacts an operator can check for directly, without special tooling. None of these proves compromise on its own, but any one of them warrants a closer look.
wp2shell_* plugin directories
New directories matching wp2shell_ followed by a hex string under wp-content/plugins/, often carrying obfuscated PHP disguised as a normal plugin.
PHP files under wp-content/cache/
Randomly-named .php files dropped in the cache directory, which should never hold executable code; some return a fake 404 unless a password parameter is supplied.
temp-write-test-* files
Leftover temp-write-test- files in wp-content/, an artefact of the exploit probing whether the directory is writable.
Unexplained administrator accounts
New WordPress administrator users created outside your normal onboarding process, sometimes in bulk.
REST calls harvesting user data
Requests to /wp-json/wp/v2/users?context=edit, used to enumerate admin usernames and email addresses before account creation.
Unexpected plugin uploads
POST /wp-admin/update.php?action=upload-plugin from a session that never authenticated through the normal login form.
A script to check a site for wp2shell
The script below is a read-only version and filesystem check, meant to run on the WordPress host itself with normal file access; it makes no network requests and attempts no exploitation. It flags a vulnerable core version and the filesystem indicators reported in real attacks: wp2shell_ plugin directories, PHP files under a cache directory that should hold no executable code, and leftover probe artefacts.
#!/usr/bin/env python3
"""Read-only scan for CVE-2026-63030 / CVE-2026-60137 exposure.
Run on the WordPress host itself. No network access, no exploitation."""
import os, re, sys
WP_ROOT = sys.argv[1] if len(sys.argv) > 1 else "."
VULN_RANGES = [
((6, 8, 0), (6, 8, 5)),
((6, 9, 0), (6, 9, 4)),
((7, 0, 0), (7, 0, 1)),
]
def read_version(root):
path = os.path.join(root, "wp-includes", "version.php")
with open(path, "r", encoding="utf-8", errors="ignore") as f:
m = re.search(r"\$wp_version\s*=\s*'([\d.]+)'", f.read())
return tuple(int(p) for p in m.group(1).split(".")) if m else None
def in_range(v, lo, hi):
return lo <= v <= hi
def scan_iocs(root):
hits = []
plugins_dir = os.path.join(root, "wp-content", "plugins")
if os.path.isdir(plugins_dir):
for name in os.listdir(plugins_dir):
if re.match(r"^wp2shell_[0-9a-f]{4,}$", name, re.I):
hits.append(f"suspicious plugin dir: wp-content/plugins/{name}")
cache_dir = os.path.join(root, "wp-content", "cache")
if os.path.isdir(cache_dir):
for dirpath, _, files in os.walk(cache_dir):
for fn in files:
if fn.endswith(".php"):
hits.append(f"PHP file in cache dir: {os.path.join(dirpath, fn)}")
content_dir = os.path.join(root, "wp-content")
if os.path.isdir(content_dir):
for fn in os.listdir(content_dir):
if fn.startswith("temp-write-test-"):
hits.append(f"probe artefact: wp-content/{fn}")
return hits
def main():
version = read_version(WP_ROOT)
if version is None:
print("Could not read wp-includes/version.php - point the script at a WordPress root.")
return
vulnerable = any(in_range(version, lo, hi) for lo, hi in VULN_RANGES)
print(f"WordPress version detected: {'.'.join(map(str, version))}")
if vulnerable:
print("Status: VULNERABLE to the wp2shell chain - patch now")
else:
print("Status: not in a known-vulnerable range (verify you are current)")
hits = scan_iocs(WP_ROOT)
if hits:
print(f"\n{len(hits)} filesystem indicator(s) found:")
for h in hits:
print(f" - {h}")
else:
print("\nNo filesystem indicators found. This does not rule out compromise;")
print("correlate with access-log hits on /wp-json/batch/v1 and check for")
print("administrator accounts you did not create.")
if __name__ == "__main__":
main()
Treat a clean result as reassuring, not conclusive. The script cannot see log-level indicators, cannot detect a shell an attacker has since deleted, and cannot confirm a site was never reachable during the exposure window. Pair it with the log correlation above and an audit of administrator accounts before calling a site clean.
Remediation and what to do next
Patching removes the vulnerability; it does not undo any access already gained through it. Treat both as separate jobs, and do the second one even on a site that auto-updated cleanly, if it was reachable before the patch landed.
Perimeter blocking helps in the interim. Several security vendors shipped WAF rules for the anonymous batch-endpoint pattern within days of disclosure, and a self-managed WAF can do the same, but it is a stopgap, not a substitute for the core update.
Patch first
Update to 6.8.6, 6.9.5, or 7.0.2 (7.1 beta2 on the beta channel). Confirm the update actually applied rather than assuming the forced rollout reached you.
Block at the edge until patched
Deny anonymous access to /wp-json/batch/v1 and ?rest_route=/batch/v1 at the WAF or reverse proxy as a stopgap.
Run the filesystem check
Use the script above, or the equivalent, to look for wp2shell_ plugin directories, PHP under wp-content/cache, and probe artefacts.
Audit administrator accounts
List every administrator user and cross-check creation dates against your own onboarding history; remove anything you cannot account for.
Review installed plugins
Confirm every plugin in wp-content/plugins is one your team installed; obfuscated shells have been found disguised as ordinary plugins.
Rotate credentials if confirmed
If any indicator is confirmed, rotate database credentials, secret keys and salts, and any API tokens the WordPress install could reach.
The underlying lesson is about where authorisation checks live. The author__not_in gate assumed every request reached it through routing already validated for that exact request. Once a routing layer can validate one path and execute another, every check that trusts the router inherits the router's bug, and a future REST feature built the same way will fail the same way.