wp2shell — WordPress Core Pre-Auth SQLi → Row Forgery → Admin Creation → RCE (CVE-2026-63030 + CVE-2026-60137)
by Adam Kues (Assetnote / Searchlight Cyber) — route confusion + SQLi; Mustafa Can İPEKÇİ ([nukedx](https://github.com/mcipekci)) — stock-default RCE chain; SQLi also credited to TF1T, dtro, haongo · 2026-07-19
- Severity
- Critical
- CVE
- CVE-2026-63030 (REST /batch/v1 route confusion, CVSS 7.5), CVE-2026-60137 (author__not_in SQL injection, CVSS 9.1); GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf
- Category
- web
- Affected product
- WordPress core (REST API /batch/v1, WP_Query::author__not_in)
- Affected versions
- 6.9.0–6.9.4 and 7.0.0–7.0.1 for the full pre-auth RCE chain (the SQLi sink alone, without the route-confusion delivery, also affects 6.8.0–6.8.5). **Fixed in 6.8.6 / 6.9.5 / 7.0.2.**
- Disclosed
- 2026-07-19
- Patch status
- patched
Tags
References
Archive entry
intelseclab/poc-archiveMetadata
| Field | Value |
|---|---|
| Date Added | 2026-07-19 |
| Last Updated | 2026-07-19 |
| Author / Researcher | Adam Kues (Assetnote / Searchlight Cyber) — route confusion + SQLi; Mustafa Can İPEKÇİ (nukedx) — stock-default RCE chain; SQLi also credited to TF1T, dtro, haongo |
| CVE / Advisory | CVE-2026-63030 (REST /batch/v1 route confusion, CVSS 7.5), CVE-2026-60137 (author__not_in SQL injection, CVSS 9.1); GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf |
| Category | web |
| Severity | Critical |
| CVSS Score | 9.1 (CVE-2026-60137, CVSS 3.1); chained RCE impact exceeds either individual CVSS score |
| Status | Weaponized — full pre-auth RCE confirmed against stock-default WordPress core, no plugins/misconfiguration required |
| Tags | wordpress, wp-core, sql-injection, route-confusion, cwe-89, cwe-436, unauthenticated, remote, privilege-escalation, rce, oembed, changeset |
| Related | N/A |
Affected Target
| Field | Value |
|---|---|
| Software / System | WordPress core (REST API /batch/v1, WP_Query::author__not_in) |
| Versions Affected | 6.9.0–6.9.4 and 7.0.0–7.0.1 for the full pre-auth RCE chain (the SQLi sink alone, without the route-confusion delivery, also affects 6.8.0–6.8.5). Fixed in 6.8.6 / 6.9.5 / 7.0.2. |
| Language / Platform | PHP, WordPress core (no plugins involved) |
| Authentication Required | No |
| Network Access Required | Yes — a single unauthenticated HTTP request to POST /?rest_route=/batch/v1 |
Summary
A two-bug chain in stock WordPress core — no plugins, no misconfiguration, no special DB privileges required — that goes from a single unauthenticated HTTP request to a new administrator account and remote code execution. The always-true primitive is CVE-2026-60137: an unauthenticated SQL injection in WP_Query::author__not_in giving full database read (admin password hashes, everything in wp_options/wp_users) — that alone would justify its 9.1 CVSS and immediate patching. But the chain goes further: CVE-2026-63030’s REST batch-endpoint route confusion is used to reach the SQLi from a nominally “public” route, and the SQLi itself is turned from a read primitive into a write primitive by abusing WordPress’s own oEmbed caching behavior to forge database rows. Those forged rows back a changeset-elevation and re-entrant parse_request trick that ultimately creates a real administrator account — from which a plugin webshell gives full command execution.
Vulnerability Details
Root Cause
CVE-2026-63030 (route confusion, CWE-436): Double-nested REST API batch requests to /batch/v1 desync $matches/$validation, so a request intended to hit a restricted internal handler instead executes under posts::get_items() (a public route), reaching WP_Query with attacker-controlled input that should never have been reachable unauthenticated.
CVE-2026-60137 (SQL injection, CWE-89): The author__not_in query variable is string-concatenated directly into the generated SQL rather than parameterized. Setting per_page=-1 additionally bypasses WordPress’s split_the_query optimization (WP_Query treats -1 as “no limit,” producing empty $limits and split=false), so the full SELECT wp_posts.* statement is reachable and a UNION ALL SELECT with matching column count injects fully-controlled fake wp_posts rows.
The write-primitive escalation: the forged rows carry oEmbed shortcodes ([embed]<self-url>[/embed]); when WordPress renders them (context=view), its own oEmbed caching mechanism writes real wp_posts rows to the database with a predictable slug (md5(url + serialized attrs)) — converting a read-only SQL injection into an arbitrary, ID-predictable database write. A forged customize_changeset (with user_id set to the real admin) plus a forged post_type=request row with parent loops then drives an in-process re-entrant parse_request call in admin context, and a final POST /wp/v2/users in the same batch request passes the current_user_can('create_users') check — creating a new administrator with no credentials ever presented.
Attack Vector
1. Route confusion — double-nested batch desyncs $matches/$validation so a GET
/wp/v2/widgets runs under posts::get_items() (public), reaching
WP_Query's author__not_in with attacker-controlled input.
2. Row forgery — author__not_in is string-concatenated into SQL;
"1) AND 1=0 UNION ALL SELECT <23 cols> -- -" injects fake
WP_Post rows. per_page=-1 bypasses split_the_query (WP_Query
treats -1 as "no limit" -> empty $limits -> split=false ->
full SELECT wp_posts.* -> UNION columns match).
3. oEmbed write — forged posts carry [embed]<self-url>[/embed]; rendering via
context=view makes WordPress cache real oembed_cache posts in
the DB (turns read-only SQLi into writes with predictable IDs).
4. Elevation+re-entry — a forged customize_changeset (user_id = real admin) plus a
forged post_type=request row with parent loops drives an
in-process re-entrant parse_request in admin context.
5. Admin creation — POST /wp/v2/users in the same batch passes
current_user_can('create_users') -> new administrator.
6. RCE — login -> plugin webshell upload -> command execution -> cleanup.
Three additional render-time write primitives were independently confirmed live against 7.0.1 during research (each forged as an unauthenticated post and the resulting DB write verified by blind SQLi): wp:rss shortcodes write a fully attacker-controlled _site_transient_feed_<md5(url)> option (key and value attacker-controlled — a general option-poisoning primitive); wp:navigation creates one fixed-slug wp_navigation post (single-shot, skipped if any published navigation exists); wp:calendar confirms the render→update_option path fires unauthenticated but with a fixed key/value. oEmbed remains the primitive the RCE chain actually uses, since it’s the only one giving multiple, on-demand, attacker-named post rows.
Impact
Full unauthenticated compromise of any affected stock WordPress installation: database read of all credentials and secrets via the SQLi alone, and — via the full chain — creation of an arbitrary administrator account followed by remote code execution through a self-cleaning webshell plugin, all from a single HTTP endpoint with no prior access.
Environment / Lab Setup
Target: Stock WordPress 6.9.4 (vulnerable) or 7.0.2 (patched) + MySQL 8.0, via
docker-compose.yml in this folder — no plugins, no custom configuration.
The DB user gets the mysql-image default (ALL ON wordpress.*), excluding
the global FILE privilege, modeling a normal/managed host.
Attacker: Python 3 stdlib only (wp2shell_check.py has no external dependencies)
Tools: Docker + Docker Compose v2, make, curl
Setup Steps
| |
Proof of Concept
See
wp2shell_check.py,docker-compose.yml,Makefile,scripts/install-wp.sh, andupstream-SECURITY.mdin this folder — mirrored from dinosn/wp2shell-lab (17 stars, MIT license, created 2026-07-18).dinosnis a source already used elsewhere in this archive (MariaDB JSON-schema UDF RCE, Apache Camel CoAP header injection, Apache Mina deserialization bypass, ActiveMQ Jolokia RCE, WP Photo Album Plus SQLi). Verified before ingestion: read the full 767-linewp2shell_check.py— standard library only, noeval/exec/subprocesscalls outside of reading the target’s HTTP responses, no unexpected outbound network destinations, no obfuscation. The exploit logic matches the documented chain exactly (blind-SQLi differential detection,UNION-based row forgery, oEmbed cache seeding, changeset/re-entrant elevation, plugin-based webshell deployment with self-cleanup), and cites real, independently-verifiable sources (Assetnote/Searchlight Cyber research page, a public gist from the RCE-chain researcher, and the actual WordPress 7.0.2 security release announcement).
Step-by-Step Reproduction
| |
Exploit Code
| |
Expected Output
$ make exploit
[+] vulnerable (blind SQLi: 0.020s / 4.020s)
[*] seeding oEmbed caches ...
[*] extracting table prefix ...
[+] table prefix: wp_
[*] extracting admin user ID ...
[+] admin ID: 1
[*] recovering oEmbed cache post IDs ...
[+] cache IDs: [14, 15, 16]
[*] forging changeset + re-entry, creating administrator ...
[+] administrator created: w2s_...:W2s!... (w2s_...@wp2shell.local)
[*] logging in, deploying webshell, executing command ...
[+] RCE output:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ make patched
[not vulnerable] http://localhost:8093 (WordPress 7.0.2, outside-affected-range) [fast=0.02s slow=0.03s delta=0.01s]
Detection & Indicators of Compromise
Remediation
| Action | Detail |
|---|---|
| Patch | Upgrade to WordPress 6.9.5 / 7.0.2 (or 6.8.6 on the 6.8 branch). WordPress shipped this as a forced auto-update. |
| Interim mitigation | Block both /wp-json/batch/v1 and the query-string form ?rest_route=/batch/v1 at the edge — a rule on only the pretty-permalink path leaves the query-string route open — or require authentication on the batch route via a rest_pre_dispatch filter. |
| Incident response if already compromised | Treat as full site compromise: audit for unexpected administrator accounts, uploaded plugins, and forged wp_posts/wp_options entries; rotate all secrets and credentials. |
References
- Searchlight Cyber / Assetnote — wp2shell: Pre-Authentication RCE in WordPress Core
- mcipekci RCE gist
- WordPress 7.0.2 release announcement
- GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf
- Public lab — dinosn/wp2shell-lab
Notes
Flagged directly by the user, then verified before ingestion. Surfaced independently of this session’s NVD/KEV discovery sweeps (WordPress core CVEs of this kind aren’t always caught by CVSS-threshold-filtered scans in their first days). Two chained CVEs are archived together as one entry, matching the source repository’s own framing of this as a single named vulnerability chain (“wp2shell”) rather than two independent bugs — cross-referenced from both CVE numbers.
| |