
Nicht-destruktiver Detektor + Docker-Labor für wp2shell (CVE-2026-63030 REST /batch/v1 route confusion + CVE-2026-60137 author__not_in SQLi) im WordPress-Kern 6.9.0-6.9.4 / 7.0.0-7.0.1
Ein eigenständiges Lab, ein nicht-destruktiver Detektor und ein vollständiger Pre-Auth-RCE-Proof-of-Concept für wp2shell – die Pre-Authentication-Schwachstellenkette im WordPress-Core:
| CVE | Komponente | Klasse | CVSS |
|---|---|---|---|
| CVE-2026-60137 | WP_Query::author__not_in | SQL-Injection (CWE-89) | 9.1 |
| CVE-2026-63030 | REST /batch/v1 Route Confusion | Interpretationskonflikt (CWE-436) → verkettet zu RCE | 7.5 |
Betroffen: WordPress-Core 6.9.0–6.9.4 und 7.0.0–7.0.1 (die SQLi-Senke allein betrifft auch 6.8.0–6.8.5). Behoben in 6.8.6 / 6.9.5 / 7.0.2. Gemeldet von Adam Kues (Assetnote / Searchlight Cyber); SQLi ebenfalls zugeschrieben an TF1T, dtro, haongo. Stock-Default-RCE-Kette (oEmbed → Changeset → Re-Entry) von Mustafa Can İPEKÇİ (nukedx).
Zuerst aktualisieren. WordPress hat für diese Schwachstelle erzwungene Auto-Updates ausgeliefert. Dieses Repo existiert, um dir zu helfen, zu verifizieren, dass dein eigenes Umfeld gepatcht ist, und um den Bug zu verstehen – nicht, um jemanden anzugreifen. Siehe SECURITY.md.
Das always-true-Primitiv ist eine nicht authentifizierte SQL-Injection im Stock-Core ohne Plugins, die
vollständigen Datenbank-Lesezugriff gewährt (Admin-Passwort-Hashes, alles in wp_options/wp_users). Das allein
rechtfertigt die 9.1 und sofortiges Patchen.
Die RCE ist real und funktioniert auf Stock-Default-WordPress – kein FILE-Privileg, kein persistenter
Object-Cache, keine Plugins, keine Fehlkonfigurationen erforderlich. Die Kette nutzt die read-only-SQLi als
Row-Forgery-Primitiv (UNION ALL SELECT injiziert gefälschte wp_posts-Zeilen) und nutzt dann
WordPress' eigene Content-Rendering-Pipeline, um diese gefälschten Zeilen über oEmbed-Caching in echte Datenbank-Schreibvorgänge
umzuwandeln. Von dort aus laufen Changeset-Elevation und re-entrant parse_request im Admin-
Kontext und erzeugen ein neues Administrator-Konto – alles aus einer einzigen nicht authentifizierten HTTP-Anfrage.
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.
Die Neuheit ist die Komposition, nicht ein einzelner Bug — die einzelnen Gadgets sind legitime
WordPress-Verhaltensweisen. Sie zu benennen (gemäß Adam Kues' Writeup) macht den gefälschten-Zeilen-Graphen in exploit()
lesbar und markiert, was nach dem Patchen der Einstiegspunkte erneut geprüft werden muss:
wp_update_post() ab und bevorzugt den im Speicher befindlichen post_type/post_status, wodurch eine
oembed_cache-Zeile in einen echten post/customize_changeset umtypisiert werden kann.post_content bewahrt — der wp_insert_post_parent-Filter durchläuft die Eltern-
Kette; bei einem erkannten Zyklus ruft er ein zweites wp_update_post() auf, das den Elternteil korrigiert, ohne
post_content zu überschreiben. Das ist das entscheidende Glied: Es lässt den bösartigen
post_content des gefälschten Changesets überleben. (Deshalb verwenden die gefälschten Zeilen Selbst-/gegenseitige-Eltern-Schleifen.)parse_request — das Veröffentlichen eines Beitrags löst do_action("{$status}_{$type}") aus;
eine gefälschte Zeile mit post_status=parse / post_type=request löst parse_request aus und führt die
Batch-Pipeline erneut aus, während die vom Changeset angenommene Admin-Identität (wp_set_current_user) noch gilt.Diese Gadgets bleiben im gepatchten WordPress vorhanden — nur die beiden Einstiegspunkte (Batch-Desync +
author__not_in-Skalar-Bypass) wurden geschlossen. Jedes neue Primitiv, das den In-Memory-Post-Cache fälscht oder
eine oembed_cache-Zeile schreibt, würde den identischen Admin-Takeover-Schwanz wieder aktivieren.
Alle vier Render-Zeit-Schreibprimitive wurden live gegen 7.0.1 bestätigt — jedes als unauthentifizierter Beitrag gefälscht, über die Batch-Verwirrung gerendert und der resultierende DB-Schreibvorgang durch blinde SQLi verifiziert:
| Primitiv | Auslöse-Markup | Senke | Vorhergesagter Bezeichner | Verifiziert |
|---|---|---|---|---|
| oembed | [embed]<url>[/embed] | wp_posts-Zeile (oembed_cache) | post_name = md5(url+attrs) | Post-ID erstellt |
| rss | wp:rss {feedURL} | wp_options-Site-Transient | _site_transient_feed_<md5(url)> | option_id, 5192 B zwischengespeichert |
| navigation | wp:navigation | wp_posts-Zeile (wp_navigation) | post_name = 'navigation' (fest) | ID erstellt, Slug navigation |
| calendar | wp:calendar | wp_options | wp_calendar_block_has_published_posts | option_id, Wert '1' |
Was jedes Ergebnis beweist:
wp_posts-Zeile mit einem vom Angreifer vorhergesagten Slug
(md5(url+serialize(attrs))) und einer echten Auto-Increment-ID. Dies ist das einzige, das dir
mehrere, bei Bedarf, vom Angreifer benannte Post-Zeilen liefert — weshalb die RCE-Kette es verwendet, um den
gefälschten Changeset/Request-Graphen zu stützen._site_transient_feed_<md5(url)> ist vollständig
vorhersagbar, und die gespeicherten Bytes sind der Feed-Body, den die URL des Angreifers ausliefert — d. h. der Angreifer
kontrolliert sowohl Schlüssel als auch Wert. Es ist ein wp_options-Schreibvorgang (keine Post-ID), also ein
Option-Poisoning-Primitiv und kein direkter Ersatz für die Changeset-Stützung.wp_navigation existiert) mit einem festen Slug, sodass er höchstens ein
gefälschtes Objekt stützen kann, anders als oEmbed mit N Zeilen.update_option-Pfad unauthentifiziert ausgelöst wird, aber der Options-
name und sein '1'/'0'-Wert sind fest/DB-abgeleitet, sodass es eine „Schreibvorgang findet statt"-Demonstration ohne
Angreiferkontrolle über Schlüssel oder Wert ist.