
CVE-2026-63030 + CVE-2026-60137 - „wp2shell“: nicht authentifizierte RCE im WordPress-Kern
Batch-Routen-Verwirrung der REST-API (CVE-2026-63030) verknüpft mit einer
WP_Query-author__not_in-SQL-Injection (CVE-2026-60137) → Pre-Auth Remote Code Execution gegen eine Standard-WordPress-Installation.Entdeckt von Adam Kues (Assetnote / Searchlight Cyber), veröffentlicht am 17.07.2026. Advisories: GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf.
| Kette (unauth RCE) | WordPress 6.9.0 – 6.9.4 und 7.0.0 – 7.0.1 |
| Nur SQLi (benötigt ein erleichterndes Plugin/Theme) | 6.8.0 – 6.8.5 |
| Nicht betroffen | ≤ 6.8 für die Batch-Verwirrung; 6.9.5 / 7.0.2 / 7.1-beta2 (gepatcht) |
| Voraussetzungen | REST-API erreichbar; kein persistenter Objekt-Cache (Redis/Memcached); ≥1 veröffentlichter Beitrag |
| Erforderliche Authentifizierung | keine |
| Auswirkung | nicht authentifiziert → neuer Administrator → Code-Ausführung (die SQLi extrahiert auch den Admin-Hash) |
https://github.com/user-attachments/assets/7f9cc52c-3f31-4339-9192-e31e506684f6
requests-Abhängigkeit und ohne defekte Funktionen.shell ohne Anmeldedaten fälscht einen Fake WP_Post mittels der Single-Post-UNION-Verwirrung, überbrückt den Customizer, um einen frischen Administrator zu erstellen (POST /wp/v2/users), meldet sich an und hinterlässt eine token-geschützte Webshell. Der SQLi-Admin-Hash-Dump (read --preset users) wird als zweiter, verifizierter Pfad beibehalten.block_cannot_read), der als primäre, nicht-destruktive check-Funktion dient.sqli), den die anderen PoCs nicht haben.$wp$2y$-Passwort-Hash (-m 35500).wp2shell/
├── README.md ← du bist hier
├── wp2shell.py ← das vereinheitlichte PoC (Einzeldatei, nur stdlib, von 0xsha)
└── lab/ ← reproduzierbare Docker-Labs + Zuverlässigkeitsmatrix
├── docker-compose.yml (Standard-6.9.4-Lab)
├── docker-compose.matrix.yml (parametrisiert: jede Version × MySQL/MariaDB)
├── docker-compose.sqli.yml (6.8.3 „Nur SQLi“-Lab)
├── matrix.sh (führt die gesamte Zuverlässigkeitsmatrix aus)
└── sqli-only/facilitator.php (Mu-Plugin: die erleichternde Senke für 6.8.x)
Die sechs öffentlichen PoCs, auf die dieses Tool zurückgreift, sind hier nicht beigelegt; sie sind unter Credits verlinkt.
Alles unten wurde im lokalen Docker-Lab verifiziert (siehe §4); Behauptungen, die nicht im Labor getestet wurden, sind als solche gekennzeichnet.
Die Kette verbindet zwei unabhängige Fehler. Zeilenangaben beziehen sich auf den echten WordPress-6.9.4-Quelltext (extrahiert aus wordpress:6.9.4-apache).
author__not_in SQL-Injection (CVE-2026-60137)wp-includes/class-wp-query.php, WP_Query::get_posts():
2403 if ( ! empty( $query_vars['author__not_in'] ) ) {
2404 if ( is_array( $query_vars['author__not_in'] ) ) { // ← Guard feuert NUR bei ARRAYS
2405 $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
2406 sort( $query_vars['author__not_in'] );
2407 }
2408 $author__not_in = implode( ',', (array) $query_vars['author__not_in'] ); // ← String geht direkt durch
2409 $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // ← rohe Interpolation
2410 } elseif ( ! empty( $query_vars['author__in'] ) ) {
...
2415 $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) ); // ← absint INNERHALB von implode
Ein String author__not_in überspringt den is_array()-Guard (2404); implode(',', (array)"…") gibt ihn unverändert zurück (2408) und er wird roh in das SQL eingefügt
(2409). Das Geschwister author__in (2415) wendet array_map('absint', …) innerhalb
von implode an und ist sicher – die eine fehlende array_map ist der Fehler. Der Wert landet als ... post_author NOT IN (<value>) ..., also schließt 0) <sql>-- - die Liste und fügt SQL an.
Einen String dort hineinzubekommen ist der schwierige Teil: Der REST-Posts-Endpoint mapped author_exclude → author__not_in (class-wp-rest-posts-controller.php:247), deklariert ihn aber als 'type' => 'array' von Ganzzahlen, sodass Core einen String erzwingt/ablehnt:
GET /wp-json/wp/v2/posts?author_exclude=1) OR SLEEP(3)-- -
→ 400 „author_exclude[0] is not of type integer.“ (verifiziert auf 6.8.3)
Deshalb ist Fehler A allein nur „erleichtert“. Fehler B schmuggelt den String auf 6.9+ an der Validierung vorbei.
wp-includes/rest-api/class-wp-rest-server.php, serve_batch_request_v1():
1720 if ( false === $parsed_url ) {
1721 $requests[] = new WP_Error( 'parse_path_failed', … ); // ein schlechter Pfad wird zu einem WP_Error IN $requests
1749 foreach ( $requests as $single_request ) {
1750 if ( is_wp_error( $single_request ) ) {
1752 $validation[] = $single_request; // ← wird zu $validation hinzugefügt …
1753 continue; // ← … aber $matches wird ÜBERSPRUNGEN
1754 }
1757 $matches[] = $match; // ← $matches wächst NUR für gültige Anfragen
1825 foreach ( $requests as $i => $single_request ) { // indiziert nach Position in $requests
1841 $match = $matches[ $i ]; // ← $matches ist KÜRZER → +1 Verschiebung
1861 $result = $this->respond_to_request( $single_request, $route, $handler, $error );
Eine WP_Error-Teilanfrage wird zu $validation[] hinzugefügt (1752), aber nicht zu $matches[] (das continue bei 1753 überspringt 1757), sodass $matches kürzer wird und $matches[$i] (1841) den nächsten Anfrage-Handler enthält. Anfrage i wird mit dem Handler von Anfrage i+1 abgearbeitet, wobei sie ihre eigenen Parameter und ihr eigenes (bestandenes) Validierungsergebnis mitführt.
Regressionsursprung (verifiziert 6.8.3 → 6.9.4-Diff): In 6.8.3 fügt die Schleife $matches[] = $match für jede Anfrage hinzu, und schlechte Pfade werden in der ersten Schleife verworfen – Arrays bleiben ausgerichtet, kein Desync. Die Umstrukturierung in 6.9.0 führte die Verschiebung ein. Genau deshalb ist 6.8.x „Nur SQLi“ und die RCE-Kette beginnt bei 6.9.0.
Der Patch fügt $matches[] auch für Fehlereinträge hinzu, verstärkt die Re-Eintrittsfähigkeit und parst author__not_in mit einem ID-Listen-Helfer. (6.9.5 war zum Zeitpunkt des Tests nicht auf Docker Hub, daher stammt dies aus den Advisories, nicht aus einem Labor-Diff.)