Wp2shell Wordpress Rce

WP2Shell: How One Missing Line Broke WordPress Core

WP2Shell: How One Missing Line Broke WordPress Core
Executive Summary

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.

WordPress CoreCVE-2026-63030CVE-2026-60137Pre-Auth RCECISA KEVT1190 – Exploit Public-Facing AppT1505.003 – Web Shell
WP2SHELL — BY THE NUMBERS
60%
Orgs with a vulnerable instance
25%
Exposed one to the internet
<48h
Patch → mass exploitation
4d
Patch → CISA KEV listing
100+
Backdoor admins observed
1
Line of code in the fix

Briefing

Are you exposed?

EXPOSURE CHECKLIST
1
Are you running WordPress 6.9.0–6.9.4 or 7.0.0–7.0.1?
Full unauthenticated RCE chain. If it was internet-facing after 17 July → assume compromise, not just "patch it".

2
Are you running WordPress 6.8.0–6.8.5?
SQL injection only — the batch endpoint that makes it unauthenticated did not exist yet. Still patch: a plugin or theme passing untrusted input to WP_Query reaches it.

!
Is /wp-json/batch/v1 reachable from the internet?
This is the entry point. A stock install exposes it anonymously. No security plugin, hardening guide or low plugin count changed that.

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 AreaDetail
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

wp2shell — Disclosure & Exploitation Timeline (2026) Dec 2025 Window opens every release ships the chain Jul 17 Disclosure + patch 7.0.2 · 6.9.5 · 6.8.6 Jul 18–19 Public PoCs land 11,500+ sessions logged Jul 20 Confirmed in the wild web shells · rogue admins Jul 21 Both CVEs on CISA KEV 4 days from patch Ongoing Opportunistic scanning unpatched sites still falling Disclosure / response Exploitation Context / ongoing

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

BUSINESS / MANAGEMENT ACTIONS
1
Inventory every WordPress instance you own — including the ones you forgot
Campaign microsites, staging copies, agency-managed properties. Confirm versions; forced auto-update does not reach every deployment.

2
Assume-breach anything that was exposed and unpatched after 17 July
Public exploits were circulating within a day. Patching alone is not remediation — engage IR and hunt the indicators in the annex.

3
Confirm your agency and hosting partners have patched
Most organisations do not run their own WordPress. Ask for the version number and evidence of a compromise hunt, not a reassurance.

!
Rotate secrets if there is any sign of compromise
Credential exfiltration is a confirmed step in observed attacks. Database credentials, 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

CVEAffectedDetail
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.

The insight

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.

Read this twice

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

Attack Chain — wp2shell (MITRE ATT&CK) 01 T1190 Initial Access POST /wp-json/batch/v1 malformed sub-request desyncs arrays 02 T1190 SQL Injection scalar author_exclude → UNION SELECT forges phantom wp_posts rows 03 T1211 Write Primitive oEmbed cache persists forged rows read-only SQLi → real DB write 04 T1068 Privilege Escalation rows recast as customize_changeset re-entrant parse_request → admin ctx 05 T1136 Persistence POST /wp/v2/users genuine backdoor administrator 06 T1505 Execution — Web Shell malicious plugin upload PHP shell · ~150 KB “CMSmap” platform 07 T1552 Credential Access LFI → wp-config.php DB creds + auth keys · Overlord RAT

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

IndicatorValue / 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

ArtifactValue / 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

ArtifactValue / 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

Hunt — WordPress batch endpoint

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

REMEDIATION — PRIORITY ORDER
1
Update to 7.0.2 / 6.9.5 / 6.8.6 and verify the version changed
Forced auto-update was enabled, but do not assume it reached sites with filesystem restrictions or non-standard deployments.

2
Hunt before you declare clean
Prioritise the HTTP 207 log search — it distinguishes attempted from successful exploitation, which is the only distinction that changes your next move.

3
If you cannot patch immediately, block the endpoint at the WAF
Deny both /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.

4
Rotate every secret if there is any sign of compromise
Database credentials, all wp-config.php salts and auth keys, all user passwords. Rotating the salts also invalidates every existing session — including the attacker's.

5
Restrict anonymous REST API access going forward
Most sites do not need an unauthenticated REST API exposed to the whole internet. It would not have stopped this specific chain everywhere, but it collapses a large amount of adjacent attack surface.

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.

CVEWPScan (CNA)CISA-ADPCWE
CVE-2026-63030
route 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-60137
SQL 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
Lesson for vulnerability management

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)