Insight wp2shell

wp2shell klingt nach der üblichen Abkürzung: Request rein, Shell raus. Genau so funktioniert die Lücke nicht.

Die öffentlich diskutierte Pre-Auth-RCE ist kein direkter Sprung von einer SQL-Injection zu PHP oder zum Betriebssystem. Die Kette verschafft dem Angreifer zuerst einen echten WordPress-Administrator. Danach geht es mit normalen WordPress-Funktionen weiter: anmelden, Plugin hochladen, Plugin aktivieren. Die Shell ist das Ergebnis, nicht der eigentliche Fehler.

Wir haben die Kette auf einem realistischen WordPress-Clone Schritt für Schritt verifiziert und anschließend gegen WordPress 7.0.2 negativ getestet. Dabei waren die Stellen am aufschlussreichsten, an denen ein einfacher PoC scheiterte oder nach dem vermeintlichen Cleanup noch Spuren übrig blieben.

Und ja: Dieser Text über eine WordPress-Core-Lücke erscheint in WordPress. Immerhin auf einer gepatchten Installation. So viel Selbstachtung muss sein.

Vom anonymen Request zum Administrator

wp2shell verkettet zwei Fehler im WordPress-Core:

  • CVE-2026-63030 bringt im REST-Batch-Dispatcher Request, Route und Handler durcheinander.
  • CVE-2026-60137 lässt einen nicht normalisierten String über die Query-Variable author__not_in in WP_Query in eine SQL-Klausel gelangen.
anonymer REST-Batch
→ Request und Handler geraten auseinander
→ ein String erreicht author__not_in
→ SQL-Injection
→ flüchtige WP_Post-Objekte aus einer UNION-Abfrage
→ WordPress erzeugt persistente oEmbed-Datensätze
→ Customizer-/REST-Re-Entry im Administratorkontext
→ WordPress legt einen Administrator an
→ Login und regulärer Plugin-Upload
→ PHP-Codeausführung

Die SQL-Injection führt keinen direkten INSERT in wp_users aus. Sie liefert WordPress gefälschte Objekte. WordPress verarbeitet diese Objekte, schreibt dabei eigene Datensätze und gelangt in einen privilegierten Kontext. Erst dort legt die reguläre REST-API einen Administrator an.

Ab diesem Moment ist PHP-Ausführung keine Überraschung mehr. Ein Administrator darf Plugins installieren. Der Plugin-Upload ist deshalb keine zweite Schwachstelle, sondern die erwartbare Folge des erlangten Administratorstatus.

Wo die Route Confusion entsteht

Der REST-Batch-Handler verarbeitet mehrere interne Requests. Zu jedem Request gehören Route, Handler, Schema, Permission-Callback und Validierungsergebnis. Diese Zustände müssen zusammenbleiben.

Im verwundbaren Code lagen Teile davon in parallelen Arrays. Ein absichtlich nicht parsebarer Pfad erzeugte zwar einen Fehler im Request- und Validierungszustand, aber keinen passenden Eintrag bei den Route-Matches. Ab dort waren die Indizes verschoben. Vereinfacht lief Request[i] unter dem Handler und Permission-Callback von Request[i+1].

if ( is_wp_error( $single_request ) ) {
    $has_error    = true;
    $validation[] = $single_request;
    continue;
}

Auf diesem Fehlerpfad wuchs $validation, $matches aber nicht. Genau diese fehlende Zeile verschob die Zuordnung für alle folgenden Subrequests.

Das war mehr als eine kaputte Eingabeprüfung: Der Dispatcher hatte die Bindung zwischen einem Request und seinem Sicherheitskontext verloren.

Die Angriffskette nutzt diesen Fehler doppelt verschachtelt. So bleibt author_exclude als String erhalten, landet trotzdem im Posts-Handler und wird dort zu author__not_in. Der verwundbare WP_Query-Code normalisierte den Wert nur, wenn er bereits als Array vorlag. Ein String übersprang diesen Zweig und erreichte die SQL-Abfrage.

if ( is_array( $query_vars['author__not_in'] ) ) {
    $query_vars['author__not_in'] = array_unique(
        array_map( 'absint', $query_vars['author__not_in'] )
    );
    sort( $query_vars['author__not_in'] );
}

$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";

Das nachträgliche (array) sieht auf den ersten Blick defensiv aus, bereinigt den String aber nicht. Es verpackt ihn nur als einziges Array-Element. implode() setzt denselben Inhalt anschließend wieder für die SQL-Interpolation zusammen.

Sieben Nachweise für die Exploitkette

Ein fertiger PoC kann am Ende „RCE erfolgreich“ ausgeben. Für eine technische Prüfung ist das zu wenig. Wir haben die Kette deshalb in einzelne Gates zerlegt:

  1. den Handler-Shift über die charakteristische Fehlerfolge des Batch-Dispatchers;
  2. den SQL-Sink mit gepaarten Timing-Tests und einem eng aktivierten MariaDB-General-Log;
  3. einen flüchtigen WP_Post, der in der REST-Antwort auftauchte, aber nicht in der Posts-Tabelle lag;
  4. persistente oEmbed-Cache-Posts als WordPress-eigene Schreibwirkung;
  5. Tabellenpräfix, Administrator-ID und notwendige Objekt-IDs ausschließlich über die Web-SQLi;
  6. einen neuen Benutzer mit der Rolle administrator und user_level 10;
  7. serverseitige PHP-Ausführung über ein minimales Marker-Plugin.

Das Marker-Plugin hatte keine Schnittstelle für Betriebssystemkommandos. Es gab nur einen festen, nicht parametrisierbaren Wert zurück. Der lokale und der serverseitige PHP-Dateihash waren identisch. Mehr brauchte es für den Ausführungsnachweis nicht.

Warum der erste UNION-Test scheinbar scheiterte

Unser erster UNION-Test erreichte nachweislich MariaDB. Der künstliche Post fehlte trotzdem in der REST-Antwort. In einem kleinen Testsystem hätte man jetzt leicht „PoC funktioniert nicht“ notiert und Feierabend gemacht.

Der Clone war kein frisch installiertes WordPress. Er enthielt viele Artikel und Medien. 7.471 reale Datensätze erfüllten den verbleibenden Autorenfilter, das Ergebnisfenster endete aber nach 500 Einträgen. Die angehängte UNION-Zeile lag dahinter und wurde deshalb nicht zurückgegeben.

Die robuste Variante setzte die reale Ergebnismenge vor dem UNION ALL auf null. Danach erschien der kontrollierte Fake-Post vollständig in der Antwort, ohne dass eine entsprechende Zeile in der Datenbank existierte.

Das ist ein brauchbarer Warnhinweis für Scanner und manuelle Tests: Ein fehlender Marker kann bedeuten, dass die Schwachstelle nicht vorhanden ist. Er kann aber auch bedeuten, dass der Nachweis an echten Anwendungsdaten scheitert. Ein sauberes Negativergebnis braucht mehr als eine leere HTTP-Antwort.

WordPress schreibt für den Angreifer

Die SQL-Injection selbst schrieb nichts in die Datenbank. Sie erzeugte flüchtige Post-Objekte mit kontrolliertem Inhalt. Darin lagen oEmbed-Verweise auf URLs derselben WordPress-Installation.

Beim Rendern verarbeitete WordPress diese Embeds und legte dafür echte Cache-Posts an. Bis dahin hatte die SQL-Injection nur Daten geliefert. Nun entstand eine persistente Schreibwirkung, weil legitime Anwendungslogik auf gefälschte Objekte hereinfiel.

Diese Stufe braucht funktionierendes Self-Loopback: WordPress muss seine eigene kanonische URL aus dem Container oder der Laufzeitumgebung erreichen können. Kaputte interne Namensauflösung kann den vollständigen PoC stoppen, obwohl Route Confusion und SQL-Injection weiterhin funktionieren.

Darauf sollte sich niemand als Schutz verlassen. Es erklärt aber, warum „SQLi bestätigt“ und „vollständige Kette fehlgeschlagen“ gleichzeitig wahr sein können.

Der Administrator macht den Rest

Mit den realen oEmbed-Backing-Posts und weiteren flüchtigen UNION-Zeilen ließ sich ein Objektgraph aufbauen, der durch Customizer-Verarbeitung und einen verschachtelten REST-Re-Entry führte. Im Re-Entry lief der Benutzer-Request in einem Administratorkontext. POST /wp/v2/users legte daraufhin einen echten Administrator an.

Damit ist „Pre-Auth RCE“ als Endergebnis korrekt, als Beschreibung des unmittelbaren Fehlers aber zu grob:

Root Cause: REST-Batch Route Confusion.
Primäre Schwachstelle: unauthentifizierte SQL-Injection.
Privilege Impact: ein WordPress-Administrator.
Ultimate Impact: PHP-Codeausführung über reguläre Administratorfunktionen.

Für die Erkennung ändert diese Trennung einiges. Im ursprünglichen Request muss keine Webshell und kein Betriebssystemkommando stehen. Die auffällige Folge kann stattdessen so aussehen: Batch-Request, Administratoranlage, erfolgreicher Login, Plugin-Upload, Plugin-Aktivierung.

Aufräumen hinterlässt Dreck

Nach dem Test entfernten wir den erzeugten Administrator, das Marker-Plugin, die Backing-Posts und die offensichtlichen Testartefakte. Die Nachkontrolle fand trotzdem noch:

  • neun verwaiste nav_menu_item-Postmeta-Zeilen;
  • zwei Loginzeilen des erzeugten Administrators in der Protokolldatei eines Security-Plugins.

Eine verwaiste Metazeile allein beweist keine wp2shell-Kompromittierung. Dafür braucht es zeitliche und technische Korrelation. Der Versuch zeigt aber, warum das Löschen eines unbekannten Administrators und eines verdächtigen Plugins kein belastbarer Gegenbeweis für frühere Ausnutzung ist.

Drei Schutzschichten statt einer Payload-Blacklist

WordPress 7.0.2 schließt drei Stellen der Kette:

  1. Der Batch-Dispatcher bleibt indexstabil. Changeset 62770 ergänzt auf dem Fehlerpfad den fehlenden Eintrag in $matches.
  2. Der SQL-Sink normalisiert selbst. Changeset 62771 verwendet wp_parse_id_list() und wandelt author__not_in unabhängig vom Eingangstyp in Integer-IDs um.
  3. Der REST-Re-Entry wird blockiert. Changeset 62769 prüft is_dispatching(); während eines laufenden Dispatches startet kein neuer Top-Level-REST-Zyklus.

Das ist sauberer als ein Spezialfall für den gerade bekannten Exploit. Der Dispatcher hält seinen Zustand zusammen, der Sink verlässt sich nicht blind auf vorgelagerte Typprüfungen und der globale REST-Zustand lässt sich nicht mehr auf dieselbe Weise verschachteln.

Auf einem Core, der bytegenau dem WordPress-7.0.2-Release entsprach, fanden unsere wiederholten Primärtests keinen Handler-Shift, kein Timing-Signal, keinen injizierten Ausdruck im MariaDB-General-Log und keinen reflektierten Fake-Post.

Patchen, dann rückwärts suchen

Betreiber sollten auf mindestens WordPress 6.8.6, 6.9.5 oder 7.0.2 aktualisieren. Neuere gefixte Versionen schließen die Lücke ebenfalls. Der SQL-Sink betrifft auch 6.8.0 bis 6.8.5. Die vollständige Route-Confusion-Kette betrifft dagegen die verwundbaren 6.9- und 7.0-Zweige.

Danach lohnt sich ein kurzer Blick zurück. Der äußere Access-Log-Pfad reicht dafür nicht, denn der Exploitgraph steckt im JSON-Body des Batch-Requests. Interessant sind vor allem:

  • anonyme POST-Requests auf /wp-json/batch/v1 oder die entsprechende rest_route-Variante;
  • verschachtelte requests-Arrays, nicht parsebare interne Pfade und SQL-Ausdrücke in author_exclude;
  • zeitlich passende oEmbed-, Customizer- und Navigation-Metadaten;
  • neue oder inzwischen gelöschte Administratoren, nachfolgende Logins und Plugin-Aktivität;
  • neue PHP-Dateien sowie ausgehende Verbindungen des Webcontainers oder PHP-FPM-Prozesses.

Bei belastbaren Hinweisen auf erfolgreiche PHP-Ausführung reicht kosmetisches Aufräumen nicht. Dann sollte die Instanz isoliert, die Beweislage gesichert und aus vertrauenswürdigen Quellen neu aufgebaut werden. Erreichbare Zugangsdaten und Secrets gehören rotiert.

Fazit

Bei wp2shell gibt es keinen magischen Sprung von SQL zu einer Shell. Der Dispatcher verliert die Bindung zwischen Request und Sicherheitskontext. Ein String landet in SQL. Gefälschte Datenbankergebnisse werden zu WordPress-Objekten. WordPress schreibt die benötigten Datensätze selbst, gerät in einen Administratorkontext und legt einen neuen Administrator an. Der lädt anschließend ein Plugin hoch.

Die Kette ist kompliziert. Ihre Lehre ist simpel: Anwendungen können einen Angriff mit völlig legitimen Funktionen zu Ende führen. Wer nur nach dem offensichtlichen „RCE-Sink“ sucht, sieht den entscheidenden Teil nicht.

Quellen und Credits

Adam Kues von Assetnote/Searchlight Cyber meldete die REST-Batch Route Confusion und die Kette bis zur RCE. Für die SQL-Injection über author__not_in in WP_Query nennt WordPress außerdem TF1T, dtro und haongo. Bei unserer praktischen Untersuchung halfen die später veröffentlichten PoCs von Icex0, Mustafa Can İPEKÇİ und dinosn.

Die verlinkten PoCs enthalten aktive Exploitlogik und gehören ausschließlich in eine isolierte, ausdrücklich autorisierte Testumgebung.

Relevante WordPress-Commits: REST-Re-Entry blockieren (Changeset 62769), Batch-Indizes stabilisieren (Changeset 62770) und author__not_in normalisieren (Changeset 62771).