On 17 July 2026, WordPress shipped an emergency patch for wp2shell — a pre-authentication remote code execution chain in WordPress core, not a plugin. Two flaws (CVE-2026-63030 and CVE-2026-60137) let an anonymous attacker take over a default install with a single HTTP request — no login, no plugins, no user interaction.
Public exploits appeared within a day. Both CVEs were on CISA KEV within four days. Attackers are deploying web shells, creating backdoor administrators and stealing database credentials. The fix to the critical half was one line of code.
Briefing
Are you exposed?
WP_Query reaches it./wp-json/batch/v1 reachable from the internet?What happened
WordPress vulnerabilities are overwhelmingly plugin vulnerabilities — which is why "keep your plugin count low" has been reasonable advice for years. wp2shell is different. The entire chain lives in core. A default install with zero third-party code was fully exploitable by an anonymous HTTP request.
Two separate bugs did it. A REST API batch endpoint added in WordPress 6.9 could be knocked out of alignment so that it validated one request and executed another. That bypass exposed a SQL injection in WP_Query that had been sitting in core since 6.8. Chained, they gave an unauthenticated attacker administrator access and then a shell.
The remarkable part is how the attacker gets from a read-only SQL injection to writing files on the server: they never gain a write primitive of their own. They convince WordPress to perform the writes for them, using features that are working exactly as designed. The technical annex below traces that step by step.
The one-line fix
Before anything else, look at the patch. This is commit c8bdf1f in wordpress-develop — one file, one line added:
// src/wp-includes/rest-api/class-wp-rest-server.php // in WP_REST_Server::serve_batch_request_v1() if ( is_wp_error( $single_request ) ) { $has_error = true; + $matches[] = $single_request; $validation[] = $single_request; continue; }
That is the whole fix for the critical half of wp2shell. Millions of exposed sites, a CISA emergency listing and tens of thousands of exploitation attempts — resolved by pushing one value into one array.
Risk
| Risk Area | Detail |
|---|---|
| Full site takeover | Pre-auth RCE on a default install. Attacker gets a real administrator account and arbitrary PHP execution — content, customer data and the underlying host are all in scope. |
| Credential theft | Confirmed post-exploitation step, not hypothetical. Local file inclusion against wp-config.php yields database credentials and auth keys. Rotate anything reachable from the web host. |
| Persistence beyond patching | Web shells, malicious plugins and rogue admin accounts survive the update. Patching closes the door; it does not evict anyone already inside. |
| Onward compromise | Attackers are installing Overlord RAT, a Golang remote access trojan. A compromised marketing site becomes a foothold into the rest of the estate. |
| Supply chain | Agencies and hosting partners running unpatched WordPress on your behalf expose your data and brand. Confirm they have patched and hunted. |
Disclosure timeline
Multiple vendors attribute the speed partly to AI-assisted patch diffing, and the original research is itself credited as LLM-assisted. Treat that causal claim as vendor commentary rather than established fact — but the measured outcome is not in dispute: the reverse-engineering gap defenders have quietly relied on for two decades collapsed to hours.
What you should do
wp-config.php salts and auth keys, all user passwords.Current status
✅ Patched — WordPress 7.0.2, 6.9.5 and 6.8.6 all shipped 17 July 2026. WordPress.org enabled forced auto-updates, a lever reserved for genuine emergencies. 7.1-beta2 also carries the fix.
🔴 Actively exploited — both CVEs on CISA KEV since 21 July. Public PoCs are widely available and mass scanning is ongoing.
⚠️ Patching is not remediation — attackers who landed before you updated still hold their administrator accounts and web shells.
Your security team should act on the Technical Annex below. All IOCs, detection and remediation guidance are in Part 2.
Technical Annex (for security / SOC teams)
Vulnerabilities
| CVE | Affected | Detail |
|---|---|---|
CVE-2026-63030 |
WordPress 6.9.0–6.9.4 7.0.0–7.0.1 |
REST API batch-route confusion in WP_REST_Server::serve_batch_request_v1(). Array desynchronisation causes validation and execution to apply to different sub-requests. CWE-436 Interpretation Conflict. Found by Adam Kues, Assetnote / Searchlight Cyber. |
CVE-2026-60137 |
WordPress 6.8.0–6.8.5 6.9.0–6.9.4 7.0.0–7.0.1 |
SQL injection in the author__not_in parameter of WP_Query. Sanitisation is gated on the value already being an array, so a scalar string reaches the query uncleaned. CWE-89. Reported by TF1T, dtro and haongo. |
How it works (brief)
An attacker sends one unauthenticated POST to /wp-json/batch/v1 containing a deliberately malformed sub-request. That desynchronises the batch handler's internal arrays, letting a following sub-request execute under a different request's handler and bypass its permission check. This delivers an unsanitised scalar to author_exclude, producing a read-only UNION SQL injection. The attacker uses it to forge wp_posts rows, lets WordPress's oEmbed cache persist them as real rows, recasts those as customizer changesets to trigger a re-entrant parse_request in administrator context, creates a genuine admin via POST /wp/v2/users, and uploads a plugin containing a PHP web shell.
Bug one — the array desynchronisation
The batch handler maintains two arrays indexed by position: $validation (is this sub-request permitted?) and $matches (which handler does it dispatch to?). The security model depends entirely on slot n in one describing slot n in the other.
When a sub-request's path failed wp_parse_url(), the error branch appended it to $validation but never appended a placeholder to $matches. From that index onward the arrays are off by one, and every subsequent sub-request is validated against one set of rules and executed with a different request's handler. WordPress checks the lock on one door and opens the door next to it.
Bug two — sanitisation that depended on type
The author__not_in parameter was sanitised properly, but only inside an is_array() branch running array_map( 'absint', ... ). Passing a plain string skips that branch entirely; the string is then (array)-cast into a single element, imploded, and concatenated directly into the SQL WHERE clause.
// Sanitised — the guard rail applies author__not_in = [1, 2, 3] // Not sanitised — walks straight into the query author__not_in = "1) AND 1=0 UNION ALL SELECT NULL,NULL..."
Over REST the parameter is exposed as author_exclude, a collection-only parameter normally sitting behind validation that would never pass a raw string. Bug one removes that guard. The 7.0.2 patch routes the value through wp_parse_id_list() unconditionally, so sanitisation no longer depends on input type.
Turning a read-only SQLi into a write primitive
This is the part worth studying. The injection is a UNION ALL SELECT — read-only. It cannot INSERT, cannot UPDATE, cannot create a user. Conventional wisdom caps an attacker in this position at data disclosure: dump the password hashes and leave.
Do not try to write to the database. Forge fake rows inside a query result, then let WordPress itself persist them for you.
The UNION injects fabricated wp_posts rows that exist only in the memory of one query. Those phantom posts are then fed through WordPress's normal content-rendering path, and when it encounters embeddable content it does what it always does — it caches the oEmbed result. That cache write is a genuine database INSERT, performed voluntarily by WordPress, using attacker-chosen content. The read-only primitive has become a write primitive.
The now-real cache post IDs are reinterpreted as customize_changeset posts. Combined with a forged post_type=request row, this triggers a re-entrant parse_request executing in administrator context — from which POST /wp/v2/users creates a real administrator, which installs a plugin, which drops a shell.
Every write in this chain is performed by WordPress, on the attacker's behalf, using features working exactly as designed. The oEmbed cache, the customizer, the REST user endpoint and the plugin installer are not vulnerable. They were simply pointed at each other.
Attack chain
Indicators of Compromise
Exploitation is HTTP-borne and database-driven. The highest-value indicators are log responses and database artifacts, not file hashes — attackers routinely delete their admin account afterwards.
Log indicators
| Indicator | Value / Notes |
|---|---|
| Entry point | /wp-json/batch/v1 or /?rest_route=/batch/v1 — requests alone indicate scanning. |
| Successful exploitation | HTTP 207 Multi-Status responses from those endpoints. The highest-fidelity signal available, and the one that separates "someone tried" from "someone succeeded". |
| Tool signatures | User-Agent containing wp2shell or rezwp2shell — purpose-built exploitation frameworks. |
| SQLi attempt | Non-integer author_exclude carrying UNION, SLEEP(, or comment-obfuscated AND/OR. |
Database artifacts
| Artifact | Value / Notes |
|---|---|
| Rogue administrators | Usernames matching wp2_<hex>, wpsvc_<hex>, w2s_<hex>. Any admin created after 17 July you cannot account for. |
| Attacker email domains | @wp2shell.invalid, @wp2shell.*, @shellcode.*, @wordpress-svc.internal, @wordpress-noreply.net, @x.lol |
| Deleted-admin trace | Gaps in the user-ID sequence plus orphaned user metadata — the fingerprint of a create-then-delete admin. Look for this even if the user table looks clean. |
| Chain artifacts | Unexpected customize_changeset posts dated after 17 July; anomalous _oembed_* option entries. |
Filesystem artifacts
| Artifact | Value / Notes |
|---|---|
| Malicious plugins | Plugin files or directories matching wp2shell_*. |
| PHP in uploads | Any .php file under /wp-content/uploads/. There is essentially never a legitimate reason for one to exist there. |
| Obfuscated shells | PHP modified after 17 July containing eval(), base64_decode(), or ${ variable-variable patterns. |
Detection & hunting
Alert on any external request to /wp-json/batch/v1 or ?rest_route=/batch/v1, and escalate immediately on HTTP 207 responses from them. Grep historical access logs back to 17 July — this is retrospective, and most organisations have the data.
Hunt for administrator accounts created outside your change process, PHP files written under wp-content/uploads/, plugin directories that do not correspond to an approved install, and outbound connections from the web host to unfamiliar infrastructure (Overlord RAT beaconing). Community tooling exists: the Compromise Scanner for wp2shell plugin on WordPress.org, and InstaWP/wp2shell-scan on GitHub, which is bulk-runnable across a fleet and read-only by default.
Remediation checklist
/wp-json/batch/v1 and ?rest_route=/batch/v1. A tourniquet, not a fix — and batch requests are used by the block editor in some workflows, so test first.wp-config.php salts and auth keys, all user passwords. Rotating the salts also invalidates every existing session — including the attacker's.A note on the severity scores
Published writeups quote contradictory CVSS numbers for wp2shell. That is not sloppy reporting — it reflects a genuine disagreement between two scoring bodies, and NVD has published no score of its own on either CVE.
| CVE | WPScan (CNA) | CISA-ADP | CWE |
|---|---|---|---|
CVE-2026-63030route confusion |
9.8 CRITICAL AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
7.5 HIGH AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
CWE-436 |
CVE-2026-60137SQL injection |
5.9 MEDIUM AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N |
9.1 CRITICAL AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
CWE-89 |
The two scorers disagree about which half of the chain owns the impact — WPScan assigns full C/I/A to the route confusion and scores the SQLi as hard-to-exploit and read-only; CISA-ADP does the reverse. Neither bug is independently a 9.8. The chain is. If your triage pipeline sorts on a single CVSS number per CVE, wp2shell is exactly the shape of thing it will under-rank — twice, independently. WordPress's own advisories sidestep the arithmetic and simply label the chain Critical; that is the more useful signal.
References (authoritative)
- WordPress — GHSA-ff9f-jf42-662q: REST API batch-route confusion (github.com)
- WordPress — GHSA-fpp7-x2x2-2mjf: SQL injection in
author__not_in(github.com) - wordpress-develop — commit
c8bdf1f: "REST API: Ensure errors in batch requests propagate" (github.com) - WordPress.org — WordPress 7.0.2 Security Release (wordpress.org)
- NIST NVD — CVE-2026-63030 (nvd.nist.gov)
- NIST NVD — CVE-2026-60137 (nvd.nist.gov)
- CISA — Known Exploited Vulnerabilities Catalog (cisa.gov)