
Non-destructive detector + Docker lab for wp2shell (CVE-2026-63030 REST /batch/v1 route confusion + CVE-2026-60137 author__not_in SQLi) in WordPress core 6.9.0-6.9.4 / 7.0.0-7.0.1
A self-contained lab, non-destructive detector, and full pre-auth RCE proof-of-concept for wp2shell — the pre-authentication vulnerability chain in WordPress core:
| CVE | Component | Class | CVSS |
|---|---|---|---|
| CVE-2026-60137 | WP_Query::author__not_in | SQL injection (CWE-89) | 9.1 |
| CVE-2026-63030 | REST /batch/v1 route confusion | interpretation conflict (CWE-436) → chains to RCE | 7.5 |
Affected: WordPress core 6.9.0–6.9.4 and 7.0.0–7.0.1 (the SQLi sink alone also affects 6.8.0–6.8.5). Fixed in 6.8.6 / 6.9.5 / 7.0.2. Reported by Adam Kues (Assetnote / Searchlight Cyber); SQLi also credited to TF1T, dtro, haongo. Stock-default RCE chain (oEmbed → changeset → re-entry) by Mustafa Can İPEKÇİ (nukedx).
Update first. WordPress shipped forced auto-updates for this. This repo exists to help you verify your own estate is patched and to understand the bug — not to attack anyone. See SECURITY.md.
The always-true primitive is an unauthenticated, no-plugin, stock-core SQL injection giving
full database read (admin password hashes, everything in wp_options/wp_users). That alone
earns the 9.1 and immediate patching.
The RCE is real and works on stock-default WordPress — no FILE privilege, no persistent
object cache, no plugins, no misconfigurations required. The chain uses the read-only SQLi as a
row-forgery primitive (UNION ALL SELECT injects fake wp_posts rows), then leverages
WordPress's own content-rendering pipeline to convert those forged rows into real database writes
via oEmbed caching. From there, changeset elevation and re-entrant parse_request run in admin
context, creating a new administrator account — all from a single unauthenticated HTTP request.
POST /?rest_route=/batch/v1)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.
The novelty is the composition, not any single bug — the individual gadgets are legitimate
WordPress behaviours. Naming them (per Adam Kues's writeup) makes the forged-row graph in exploit()
readable and flags what to re-audit after the entry points were patched:
wp_update_post() and prefers the in-memory post_type/post_status, letting an
oembed_cache row be re-typed into a real post/customize_changeset.post_content — the wp_insert_post_parent filter walks the parent
chain; on a detected cycle it calls a second wp_update_post() that fixes the parent without
overriding post_content. That is the critical link: it lets the forged changeset's malicious
post_content survive. (This is why the forged rows use self-/mutual-parent loops.)parse_request — publishing a post fires do_action("{$status}_{$type}");
a forged row with post_status=parse / post_type=request triggers parse_request, re-running the
batch pipeline while the changeset's assumed-admin identity (wp_set_current_user) still holds.These gadgets remain present in patched WordPress — only the two entry points (batch desync +
author__not_in scalar bypass) were closed. Any new primitive that forges in-memory post cache or
writes an oembed_cache row would re-enable the identical admin-takeover tail.
All four render-time write primitives confirmed live against 7.0.1 — each forged as an unauthenticated post, rendered via the batch confusion, and the resulting DB write verified by blind SQLi:
| Primitive | Trigger markup | Sink | Predicted identifier | Verified |
|---|---|---|---|---|
| oembed | [embed]<url>[/embed] | wp_posts row (oembed_cache) | post_name = md5(url+attrs) | post ID created |
| rss | wp:rss {feedURL} | wp_options site-transient | _site_transient_feed_<md5(url)> | option_id, 5192 B cached |
| navigation | wp:navigation | wp_posts row (wp_navigation) | post_name = 'navigation' (fixed) | ID created, slug navigation |
| calendar | wp:calendar | wp_options | wp_calendar_block_has_published_posts | option_id, value '1' |
What each result proves:
wp_posts row with an attacker-predicted slug
(md5(url+serialize(attrs))) and a real auto-increment ID. This is the only one that gives you
multiple, on-demand, attacker-named post rows — which is why the RCE chain uses it to back the
forged changeset/request graph._site_transient_feed_<md5(url)> is fully
predictable, and the stored bytes are the feed body the attacker's URL serves — i.e., attacker
controls both key and value. It's an wp_options write (no post ID), so it's an option-poisoning
primitive rather than a drop-in for the changeset backing.wp_navigation exists) with a fixed slug, so it can back at most one
forged object, unlike oEmbed's N rows.update_option path fires unauthenticated, but the option
name and its '1'/'0' value are fixed/DB-derived, so it's a "write happens" demonstration with
no attacker control over key or value.FILE privilege + a
web-served secure_file_priv + the drop readable by the web user. On normal/managed hosts none
of that holds.call_user_func('wp_insert_user', user_data_array) — requires
gc_enabled()=false + valid HMAC (wp_hash of exact serialized bytes, needs wp-config.php secrets).Requirements: Docker + Docker Compose v2, Python 3.8+ (stdlib only), make, curl.