Ende September konnte ich mich nicht mehr in mein eigenes WordPress einloggen. Den Grund zeigte die Benutzerliste: 75 Administratoren, 74 davon nicht von mir. Angreifer hatten abow.info über wp2shell übernommen, eine Lücke im WordPress-Core, und das zehn Wochen lang, ohne dass ich etwas gemerkt habe. In diesem Beitrag steht, woran du erkennst, ob es dich auch erwischt hat, wie ich aufgeräumt habe und was ich seitdem anders mache.
Ziel
- Prüfen, ob deine WordPress-Installation verwundbar ist oder schon übernommen wurde
- Eine übernommene Seite sauber neu aufbauen, statt einzelne Dateien zu löschen
- Die Seite so absichern, dass der nächste Einbruch nicht klappt oder zumindest sofort auffällt
Hintergrundwissen: Was ist wp2shell?
Am 17. Juli 2026 hat Searchlight Cyber zwei Lücken im WordPress-Core veröffentlicht, die zusammen eine Codeausführung ohne Login erlauben. Ein Plugin braucht es dafür nicht, eine Standardinstallation reicht.
CVE-2026-63030 steckt im Batch-Endpunkt der REST-API, erreichbar unter /wp-json/batch/v1 und gleichwertig unter /?rest_route=/batch/v1. Über diesen Endpunkt kann ein Client mehrere REST-Anfragen in einem Aufruf bündeln. Eine präparierte Bündelung bringt WordPress dazu, eine Teilanfrage im falschen Sicherheitskontext auszuführen, die Berechtigungsprüfung fällt weg.
CVE-2026-60137 ist eine SQL-Injection im Parameter author__not_in von WP_Query. Hinter dem ausgehebelten Batch-Endpunkt ist sie ohne Anmeldung erreichbar. Die öffentlichen Exploit-Tools legen damit einen Administrator an und installieren darüber eine Webshell.
| WordPress-Version | Betroffen | Fix |
|---|---|---|
| 6.8.0–6.8.5 | nur die SQL-Injection, keine vollständige Kette (die Batch-Route kam erst mit 6.9) | 6.8.6 |
| 6.9.0–6.9.4 | vollständige Kette, Übernahme ohne Login | 6.9.5 |
| 7.0.0–7.0.1 | vollständige Kette, Übernahme ohne Login | 7.0.2 |
Die Patches kamen am selben Tag wie die Veröffentlichung. Die US-Behörde CISA hat die Lücke am 21. Juli in ihren Katalog aktiv ausgenutzter Schwachstellen aufgenommen, am 22. Juli tauchte funktionierender Exploit-Code öffentlich auf. Auf meiner Seite entstand der erste fremde Administrator am 19. Juli, also schon vorher.
So lief es bei mir

Im Juni bin ich von Strato auf einen VPS mit Docker umgezogen (Umzug im Detail). Die Seite lief auf WordPress 7.0. Den Fix vom 17. Juli hat sie nie bekommen. Die wahrscheinlichste Ursache: Ich hatte die Dateien aus dem Strato-Backup per SFTP als root hochgeladen. WordPress läuft im Container als www-data und durfte seine eigenen Core-Dateien damit nicht überschreiben. Plugins haben sich weiter aktualisiert, deshalb fiel es mir nicht auf. Eine Fehlermail hätte ich auch nicht bekommen, denn Mailversand war auf dem neuen Server noch nicht eingerichtet.
Ab dem 19. Juli kamen dann ein bis drei neue Administratoren pro Tag dazu, mit wechselnden Namensmustern: wp2_…, w2s_…, wpsvc_…, wordpress_…, dazu erfundene Adressen auf @abow.info und Konten wie adm.abows0367c6, die meinem eigenen Namen ähneln. Das waren nicht ein Angreifer, sondern viele automatisierte Scanner mit denselben öffentlichen Werkzeugen.
Was sie hinterlassen haben:
- zwei Webshells in der
functions.phpmeines eigenen Themes (26. Juli), die bei jedem Seitenaufruf mitladen - Backdoor-Plugins mit Namen wie
wp2shell_624cd70aund einen Ordner mit Zufallsnamen - fremde Theme-Ordner (
twk-…) und Must-Use-Plugins mit Base64-Payload, die automatisch laden und in keiner Plugin-Liste auftauchen - PHP-Dateien im
uploads-Ordner - 167 Datensätze des Exploits in der Datenbank, darunter neun veröffentlichte Beiträge mit dem Titel „x“, gebucht auf mein Konto
Am 23. August wurde der letzte Fremd-Admin angelegt. Eine Minute später tauchten zwei neue Must-Use-Plugins auf: firewall.php und wp2shell-batch-guard.php. Der letzte Angreifer hat die Lücke hinter sich geschlossen, damit ihm niemand die Seite streitig macht. Danach kamen keine neuen Konten mehr.
Aufgefallen ist mir das Ganze erst am 28. September, als mein Login nicht mehr ging und auch die Mail zum Zurücksetzen des Passworts nicht rausging.
Über die SQL-Injection konnten die Angreifer die komplette Datenbank lesen. Damit gelten als abgeflossen: die Passwort-Hashes aller Konten und alle Zugangsdaten, die Plugins in ihren Einstellungen speichern, also API-Keys, Tokens und Verbindungen zu Google-Diensten.
Befehl: Bin ich betroffen?
Die Prüfung läuft am einfachsten mit WP-CLI im WordPress-Verzeichnis. Läuft WordPress im Docker-Container, stellst du docker exec -u www-data <container> php wp-cli.phar statt wp voran.
# 1) Version
wp core version
# 2) Administratoren mit Anlagedatum
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
# 3) Core und Plugins gegen die Originale von wordpress.org
wp core verify-checksums
wp plugin verify-checksums --all
# 4) Spuren im Dateisystem
grep -rl "wp2shell" wp-content/ 2>/dev/null
grep -rlE "RXST:|RXEND" wp-content/ 2>/dev/null
ls -la wp-content/mu-plugins/
find wp-content/uploads -type f \( -iname "*.php*" -o -iname "*.phtml" -o -iname "*.phar" \)
find wp-content/plugins wp-content/themes -maxdepth 1 -newermt 2026-07-17
# 5) Treffer auf den Batch-Endpunkt im Access-Log
grep -E '"POST /(wp-json/batch/v1|\?rest_route=/batch/v1)' access.log | grep '" 207 '
Die Datenbank hat eigene Spuren. wp db query braucht den MySQL-Client, der im offiziellen WordPress-Docker-Image fehlt. Dort führst du die Abfragen im Datenbank-Container aus. Das Tabellen-Präfix wp_ ersetzt du durch deins.
-- Hilfsdatensätze des Exploits: pro angelegtem Admin meist ein Paar
SELECT post_type, post_status, COUNT(*) AS anzahl
FROM wp_posts
WHERE post_type IN ('customize_changeset', 'request')
AND post_modified >= '2026-07-17'
GROUP BY post_type, post_status;
-- Menü-Einträge, die auf die Exploit-Werkzeuge zeigen
SELECT post_id, meta_value FROM wp_postmeta
WHERE meta_key = '_menu_item_url' AND meta_value LIKE '%wp2shell%';
So sah eine der beiden Webshells in meiner functions.php aus, gekürzt und entschärft:
/* wp2shell */ if(hash_equals('…32 Zeichen…',(string)($_GET['t']??''))){$c=$_GET['c'].' 2>&1'; … @shell_exec($c) … echo 'RXST:'.base64_encode($o).':RXEND'; exit;}
Wer den geheimen Wert im Parameter t kennt, führt über c beliebige Befehle auf dem Server aus. Die Markierung /* wp2shell */ und die Klammer RXST: … :RXEND sind gute Suchbegriffe.
Erklärung
| Prüfung | Worauf du achtest |
|---|---|
wp core version | 6.9.0–6.9.4 oder 7.0.0–7.0.1: Die Seite war verwundbar. Sofort aktualisieren und die übrigen Prüfungen trotzdem machen, ein Update entfernt keine Hintertüren. |
| Admin-Liste | Konten, die du nicht kennst, vor allem mit Anlagedatum ab dem 17.07.2026. Achte auf Namen, die deinem ähneln. |
verify-checksums | Jede geänderte und jede zusätzliche Datei. Ausnahme im Docker-Image: wp-config-docker.php gehört dort hin. |
grep nach wp2shell, RXST | Die verbreiteten Exploit-Tools markieren ihren Code so. Plugin-Ordner heißen oft wp2shell_…. |
mu-plugins | Alles hier lädt automatisch und steht in keiner Plugin-Übersicht. Kennst du eine Datei nicht, ist das ein Alarmzeichen. |
PHP in uploads | Hat dort nichts zu suchen. Ausnahme: leere index.php und .htaccess-Dateien, die Plugins wie WPForms zum Schutz anlegen. |
| HTTP 207 im Log | „Multi-Status“ ist die normale Antwort des Batch-Endpunkts. Bei einem verwundbaren Core kann sie bedeuten, dass der Exploit durchgegangen ist. Bei mir passte ein 207-Burst sekundengenau zur Anlage eines Fremd-Admins. |
| Changesets und Requests | Pro Fremd-Admin entstand bei mir ein Paar aus customize_changeset und request. Den Request-Status parse gibt es in WordPress regulär nicht. |
Aufräumen: neu aufbauen statt putzen
Einzelne Webshells zu löschen ist ein Ratespiel. Du findest nie sicher alle, und eine übersehene reicht. Ich habe die Seite deshalb auf einem frischen Volume neu aufgebaut und aus der alten Installation nur übernommen, was ich prüfen konnte.
- Offline nehmen und Beweise sichern: Datenbank-Dump, Kopie der Dateien und die Access-Logs. Läuft WordPress in Docker, hängen die Logs am Container und sind beim nächsten Neuerzeugen weg. Also zuerst
docker logs <container> > access.log. - Frischer Core aus dem Original, bei Docker in einem neuen, leeren Volume.
- Plugins und Themes neu von wordpress.org installieren, nicht aus dem Backup kopieren. Bei der Gelegenheit alles weglassen, was du nicht brauchst.
- Aus
uploadsnur Mediendateien übernehmen, per Whitelist nach Dateiendung. - Eigene Themes und Plugins aus deiner eigenen Quelle (Git, ZIP) neu einspielen. Hast du keine saubere Kopie, jede Datei prüfen, die sich seit dem 17.07. geändert hat.
- Datenbank bereinigen, siehe unten.
- Alles neu setzen: WordPress-Passwörter, Salts, Datenbank-Passwort, API-Keys und Tokens aus den Plugin-Einstellungen, den Login-Pfad. Und prüfen, ob du das alte WordPress-Passwort irgendwo anders benutzt hast.
- Datenschutz prüfen: Hast du Kommentare oder Formulardaten gespeichert, waren die für die Angreifer lesbar. Ob eine Meldung an die Datenschutzbehörde nötig ist (Art. 33 DSGVO, 72 Stunden ab Kenntnis), lässt du am besten rechtlich klären.
Die Mediendateien habe ich so übernommen:
rsync -a --prune-empty-dirs --include='*/' \
--include='*.'{jpg,jpeg,png,gif,webp,avif,pdf,mp4,webm,mp3,txt,csv,ps1,zip} \
--exclude='*' \
alt/wp-content/uploads/ neu/wp-content/uploads/
SVG-Dateien stehen bewusst nicht auf der Liste, weil sie JavaScript enthalten können. Die habe ich einzeln angesehen.
Für die Datenbank vorher einen Dump ziehen. Fremde Konten entfernst du mit WP-CLI ohne --reassign, dann verschwinden ihre Beiträge mit:
wp user delete <ID> <ID> <ID> --yes
Den Rest per SQL:
-- alle Sitzungen und Application Passwords entfernen (auch bei deinem eigenen Konto!)
DELETE FROM wp_usermeta WHERE meta_key IN ('session_tokens', '_application_passwords');
-- Hilfsdatensätze des Exploits
DELETE FROM wp_posts
WHERE post_type = 'customize_changeset' AND post_status = 'publish'
AND post_modified >= '2026-07-17';
DELETE FROM wp_posts WHERE post_type = 'request' AND post_status = 'parse';
-- verwaiste Metadaten
DELETE pm FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL;
Danach noch einmal nach Beiträgen suchen, die seit dem 17.07. geändert wurden und nicht von dir stammen. Die neun „x“-Beiträge bei mir liefen auf mein eigenes Konto und wären beim Löschen nach Autor durchgerutscht.
Absichern

Auto-Updates, die wirklich laufen
Das hätte den Einbruch sehr wahrscheinlich verhindert. WordPress braucht Schreibrechte auf seine eigenen Dateien:
chown -R www-data:www-data /var/www/html
wp eval 'require_once ABSPATH."wp-admin/includes/admin.php"; require_once ABSPATH."wp-admin/includes/class-wp-upgrader.php"; var_dump((new WP_Automatic_Updater())->is_disabled());'
bool(false) heißt: Auto-Updates sind nicht deaktiviert. Richte außerdem SMTP ein, sonst bekommst du die Mail über ein gescheitertes Update nie.
Beim offiziellen Docker-Image liegt der Core im Volume. docker pull wordpress:latest aktualisiert eine bestehende Installation nicht, das Image füllt nur ein leeres Volume.
Batch-API, XML-RPC und Benutzerliste zu
Ein kleines Must-Use-Plugin unter wp-content/mu-plugins/abow-hardening.php:
<?php
/**
* Plugin Name: abow Hardening
* Description: Batch-API nur fuer angemeldete Nutzer, XML-RPC aus, keine User-Aufzaehlung fuer Gaeste.
*
* .NOTES
* Name: abow-hardening
* Author: Andreas Bowitz
* Version: 0.2
* LastUpdated: 2026-Sep-28
*/
defined( 'ABSPATH' ) || exit;
// Batch-Endpunkt nur fuer angemeldete Nutzer, egal ueber welchen Weg die Route kommt
add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
if ( 0 === strpos( $request->get_route(), '/batch/v1' ) && ! is_user_logged_in() ) {
return new WP_Error( 'abow_batch_forbidden', 'Batch-API nur fuer angemeldete Nutzer.', array( 'status' => 401 ) );
}
return $result;
}, 0, 3 );
// XML-RPC abschalten
add_filter( 'xmlrpc_enabled', '__return_false' );
add_filter( 'xmlrpc_methods', '__return_empty_array' );
// Keine Benutzer-Aufzaehlung ueber die REST-API fuer Gaeste
add_filter( 'rest_endpoints', function ( $endpoints ) {
if ( ! is_user_logged_in() ) {
unset( $endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
}
return $endpoints;
} );
// Keine Benutzernamen ueber /?author=1 fuer Gaeste (vor redirect_canonical)
add_action( 'template_redirect', function () {
if ( ! is_user_logged_in() && isset( $_GET['author'] ) ) {
wp_safe_redirect( home_url( '/' ), 301 );
exit;
}
}, 1 );
Die Sperre sitzt absichtlich in WordPress selbst und nicht nur in einer Firewall-Regel vor dem Server (warum, steht unter Stolperfallen). Ergänzend in der .htaccess im WordPress-Verzeichnis:
<Files "xmlrpc.php">
Require all denied
</Files>
Und in wp-content/uploads/.htaccess, damit dort nie wieder PHP ausgeführt wird:
<FilesMatch "\.(?i:php[0-9]?|phtml|phar|pht|phps)$">
Require all denied
</FilesMatch>
Gefährliche PHP-Funktionen sperren
Beide Webshells in meinem Theme brauchten shell_exec, exec oder system. WordPress und meine Plugins brauchen diese Funktionen nicht. Klick nach dem Sperren trotzdem einmal durch Frontend und Backend, manche Backup- oder Bild-Plugins rufen externe Programme auf. Als PHP-Konfiguration (bei Docker als Datei nach /usr/local/etc/php/conf.d/ mounten):
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
expose_php = Off
Eine eingeschleuste Webshell läuft damit ins Leere. WP-CLI nutzt teils selbst proc_open, dort rufst du PHP mit php -d disable_functions= wp-cli.phar … auf. Die Webseite bleibt gesperrt.
Überwachen und sichern
Zehn Wochen unbemerkt waren das eigentliche Problem. Auf dem Server laufen jetzt drei kleine Skripte, die sich per Discord-Webhook melden:
- ein Admin-Wächter, der alle 15 Minuten prüft, ob es außer mir einen Administrator gibt
- ein täglicher Integritäts-Check: Core und Plugins gegen die Prüfsummen von wordpress.org, eigene Themes,
mu-plugins,.htaccessund ausführbare Dateien inuploadsgegen eine Baseline - ein tägliches Backup von Datenbank,
wp-contentund Stack-Konfiguration, mit GPG verschlüsselt und per rclone auf OneDrive
Die Skripte stelle ich auf GitHub bereit. Ein Backup zählt übrigens erst, wenn du es einmal zurückgespielt hast, und zwar mit der Passphrase aus deinem Passwort-Manager, nicht mit der Datei auf dem Server.
Stolperfallen
- Eine Firewall-Regel nur für
/wp-json/batch/v1reicht nicht. Der Endpunkt ist auch über/?rest_route=/batch/v1erreichbar, und WordPress liestrest_routesogar aus dem POST-Body. Dann steht im Log nurPOST /. Laut Patchstack ließen sich fast alle WAF-Regeln aus den ersten Stunden nach der Veröffentlichung so umgehen. In meinem Log standen über 1.200 POST-Anfragen auf/. Welche davon Exploit-Versuche waren, lässt sich ohne den Request-Body nicht mehr sagen. docker pullpatcht dein WordPress nicht. Der Core liegt im Volume, nicht im Image.- Dateien als root hochgeladen, Auto-Updates still kaputt. Plugins aktualisierten sich weiter, der Core nicht. Ohne Mailversand erfährst du davon nichts.
- Ein neues Passwort allein hilft nicht. Webshells, Application Passwords und offene Sitzungen bleiben davon unberührt.
- Aufräumen nach Autor verfehlt Artefakte. Die „x“-Beiträge und die Exploit-Datensätze liefen bei mir auf mein eigenes Konto.
- Eigene Themes vergisst man leicht. Beim Neuaufbau aus wordpress.org fehlt ein selbst gebautes Theme, und wer es einfach aus dem alten Verzeichnis kopiert, kopiert die Webshell in der
functions.phpmit. - Container-Logs sind flüchtig. Mein Access-Log reichte nur bis zum 10. August zurück, vermutlich weil der Container damals neu erzeugt wurde. Den Anfang des Angriffs konnte ich deshalb nicht mehr nachvollziehen.
- „Die Seite sieht doch normal aus.“ Das tat sie die ganze Zeit. Die Angreifer wollten offenbar den Server und nicht mein Layout.
Ergebnis
abow.info läuft wieder auf einem frischen Core 7.1.2 mit funktionierenden Auto-Updates. Plugins und Theme sind sauber, alle Fremdkonten und Exploit-Reste sind aus der Datenbank entfernt, alle Passwörter und Schlüssel neu gesetzt. Die Batch-API ist für Gäste gesperrt, XML-RPC ist aus, und neue Administratoren oder veränderter Code melden sich innerhalb von Minuten beziehungsweise am nächsten Morgen per Discord. Das verschlüsselte Backup liegt zusätzlich auf OneDrive, die Wiederherstellung ist getestet.
Bonus: Schnellcheck von außen mit PowerShell
Test-WpExposure.ps1 prüft von deinem Windows-Rechner aus, ob deine Seite für wp2shell verwundbar ist und welche weiteren Angriffsflächen für Gäste offen sind: sichtbare WordPress-Version, Batch-API über beide Routen, XML-RPC, Benutzer-Aufzählung per REST-API und /?author=1 sowie die PHP-Version im Header. Das Skript schickt nur harmlose Anfragen und ändert nichts. Setz es ausschließlich gegen eigene Seiten ein.
Es läuft unter Windows PowerShell 5.1 und PowerShell 7, bringt eine vollständige Hilfe mit (Get-Help .\Test-WpExposure.ps1 -Full) und gibt einen Exitcode zurück, den du in einer geplanten Aufgabe auswerten kannst: 0 alles ok, 1 Warnung oder kritisch, 2 Seite nicht erreichbar.
<#PSScriptInfo
.VERSION 0.2
.GUID 4279b966-262d-4b19-8f41-7c97b2d9d781
.AUTHOR Andreas Bowitz
.COMPANYNAME abow.info
.COPYRIGHT (c) 2026 Andreas Bowitz. Alle Rechte vorbehalten.
.TAGS WordPress Security wp2shell CVE-2026-63030 CVE-2026-60137 Hardening
.LICENSEURI
.PROJECTURI https://abow.info
.ICONURI
.EXTERNALMODULEDEPENDENCIES
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES
0.2 (2026-Sep-28) Vollstaendige Hilfe, Script-Metadaten, Banner, Bewertungsstufen,
Exitcodes, -PassThru, -TimeoutSec, Autoren-Aufzaehlung, X-Powered-By.
0.1 (2026-Sep-28) Erste Version fuer den Beitrag auf abow.info.
.PRIVATEDATA
#>
<#
.SYNOPSIS
Prueft von aussen, ob eine eigene WordPress-Seite fuer wp2shell
(CVE-2026-63030 / CVE-2026-60137) angreifbar ist und welche weiteren
Angriffsflaechen fuer Gaeste offen sind.
.DESCRIPTION
Test-WpExposure schickt ausschliesslich harmlose Lese- bzw. Leeranfragen an
eine WordPress-Seite und bewertet die Antworten. Auf dem Server wird nichts
veraendert, es werden keine Exploits ausgefuehrt.
Geprueft wird:
1. Erreichbarkeit der Seite
2. Oeffentlich sichtbare WordPress-Version (Meta-Generator, RSS-Feed) und
Abgleich mit den von wp2shell betroffenen Versionen
(6.9.0-6.9.4 und 7.0.0-7.0.1, Fix in 6.9.5 bzw. 7.0.2)
3. REST-Batch-Endpunkt fuer Gaeste, ueber beide Routen
(/wp-json/batch/v1 und /?rest_route=/batch/v1)
4. XML-RPC (Angriffsflaeche fuer Passwort-Rateversuche)
5. Benutzer-Aufzaehlung ueber die REST-API und ueber /?author=1
6. PHP-Versionsangabe im Header X-Powered-By
Bewertungsstufen: OK, Hinweis, Warnung, Kritisch.
Nur gegen eigene Seiten oder mit ausdruecklicher Erlaubnis des Betreibers
einsetzen.
.PARAMETER Url
Basis-URL der WordPress-Seite inklusive https://, z. B. https://example.com
.PARAMETER TimeoutSec
Zeitlimit pro Anfrage in Sekunden. Standard: 20.
.PARAMETER PassThru
Gibt die Ergebnisse zusaetzlich als Objekte zurueck, z. B. fuer Export-Csv
oder eigene Auswertungen.
.EXAMPLE
.\Test-WpExposure.ps1 -Url https://example.com
Prueft die Seite und zeigt eine farbige Uebersicht mit Zusammenfassung.
.EXAMPLE
.\Test-WpExposure.ps1 -Url https://example.com -PassThru |
Export-Csv -Path .\wp-check.csv -NoTypeInformation -Encoding UTF8
Schreibt die Ergebnisse zusaetzlich in eine CSV-Datei.
.EXAMPLE
.\Test-WpExposure.ps1 -Url https://example.com
if ($LASTEXITCODE -eq 1) { Write-Warning 'Handlungsbedarf' }
Nutzt den Exitcode, etwa in einer geplanten Aufgabe.
.INPUTS
Keine. Die URL wird als Parameter uebergeben.
.OUTPUTS
Mit -PassThru: PSCustomObject mit den Eigenschaften
Pruefung, Ergebnis, Stufe und Bewertung.
.NOTES
Name: Test-WpExposure
Author: Andreas Bowitz
Version: 0.2
LastUpdated: 2026-Sep-28
Voraussetzungen: Windows PowerShell 5.1 oder PowerShell 7.x
Exitcodes: 0 = keine Warnung, 1 = Warnung oder Kritisch gefunden,
2 = Seite nicht erreichbar
Downloadete Skripte ggf. vorher freigeben: Unblock-File .\Test-WpExposure.ps1
.LINK
https://abow.info
.LINK
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0, HelpMessage = 'Basis-URL der WordPress-Seite, z. B. https://example.com')]
[ValidatePattern('^https?://[^\s/$.?#].[^\s]*$')]
[string]$Url,
[ValidateRange(3, 120)]
[int]$TimeoutSec = 20,
[switch]$PassThru
)
#region Grundeinstellungen
$ScriptName = 'Test-WpExposure'
$ScriptVersion = '0.2'
$ScriptAuthor = 'Andreas Bowitz'
$UserAgent = "$ScriptName/$ScriptVersion (+https://abow.info)"
if ($PSVersionTable.PSVersion.Major -lt 6) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
}
$Url = $Url.TrimEnd('/')
$results = New-Object System.Collections.Generic.List[object]
#endregion
#region Hilfsfunktionen
function Get-HttpResult {
<#
.SYNOPSIS
Fuehrt eine HTTP-Anfrage aus und liefert Status, Inhalt, Header und End-URL,
auch bei Fehlerstatus. Kompatibel mit PowerShell 5.1 und 7.x.
#>
param(
[Parameter(Mandatory)][string]$Uri,
[string]$Method = 'GET',
[string]$Body,
[string]$ContentType = 'application/json'
)
$p = @{
Uri = $Uri
Method = $Method
UseBasicParsing = $true
ErrorAction = 'Stop'
TimeoutSec = $TimeoutSec
UserAgent = $UserAgent
}
if ($Body) { $p.Body = $Body; $p.ContentType = $ContentType }
try {
$r = Invoke-WebRequest @p
$content = $r.Content
if ($content -is [byte[]]) { $content = [Text.Encoding]::UTF8.GetString($content) }
$final = $null
if ($r.BaseResponse.ResponseUri) { $final = $r.BaseResponse.ResponseUri.AbsoluteUri } # PS 5.1
elseif ($r.BaseResponse.RequestMessage) { $final = $r.BaseResponse.RequestMessage.RequestUri.AbsoluteUri } # PS 7
[pscustomobject]@{ Status = [int]$r.StatusCode; Content = [string]$content; Headers = $r.Headers; FinalUri = $final }
}
catch {
$resp = $_.Exception.Response
if ($resp) {
[pscustomobject]@{ Status = [int]$resp.StatusCode; Content = ''; Headers = $null; FinalUri = $null }
}
else {
[pscustomobject]@{ Status = -1; Content = $_.Exception.Message; Headers = $null; FinalUri = $null }
}
}
}
function Add-Result {
param(
[string]$Pruefung,
[string]$Ergebnis,
[ValidateSet('OK', 'Hinweis', 'Warnung', 'Kritisch')][string]$Stufe,
[string]$Bewertung
)
$results.Add([pscustomobject]@{
Pruefung = $Pruefung
Ergebnis = $Ergebnis
Stufe = $Stufe
Bewertung = $Bewertung
})
}
function Test-Wp2shellVersion {
param([string]$Version)
$parts = @($Version.Split('.'))
while ($parts.Count -lt 3) { $parts += '0' }
$v = [version]($parts[0..2] -join '.')
return (($v -ge [version]'6.9.0' -and $v -le [version]'6.9.4') -or
($v -ge [version]'7.0.0' -and $v -le [version]'7.0.1'))
}
#endregion
#region Banner
Write-Host ''
Write-Host " $ScriptName v$ScriptVersion | $ScriptAuthor | abow.info" -ForegroundColor Cyan
Write-Host " Ziel: $Url" -ForegroundColor Gray
Write-Host " Nur gegen eigene Seiten einsetzen. Es werden keine Daten veraendert." -ForegroundColor DarkGray
Write-Host ''
#endregion
#region 1) Erreichbarkeit
$start = Get-HttpResult -Uri "$Url/"
if ($start.Status -lt 0) {
Write-Host " Seite nicht erreichbar: $($start.Content)" -ForegroundColor Red
exit 2
}
Add-Result 'Erreichbarkeit' $start.Status 'OK' 'Seite antwortet'
#endregion
#region 2) WordPress-Version
$version = $null
$quelle = $null
if ($start.Content -match '<meta[^>]+name=["'']generator["''][^>]+content=["'']WordPress\s+([0-9.]+)') {
$version = $Matches[1]; $quelle = 'Meta-Generator'
}
if (-not $version) {
foreach ($feedPath in '/feed/', '/?feed=rss2') {
$feed = Get-HttpResult -Uri "$Url$feedPath"
if ($feed.Content -match 'wordpress\.org/\?v=([0-9.]+)') { $version = $Matches[1]; $quelle = "Feed $feedPath"; break }
}
}
if ($version) {
if (Test-Wp2shellVersion $version) {
Add-Result 'WordPress-Version' "$version ($quelle)" 'Kritisch' 'wp2shell-verwundbar: sofort updaten und auf Einbruch pruefen'
}
else {
Add-Result 'WordPress-Version' "$version ($quelle)" 'Hinweis' 'nicht im betroffenen Bereich, Version aber oeffentlich sichtbar'
}
}
else {
Add-Result 'WordPress-Version' '-' 'OK' 'nicht oeffentlich sichtbar, bitte im Backend pruefen'
}
#endregion
#region 3) Batch-API (beide Routen)
foreach ($route in '/wp-json/batch/v1', '/?rest_route=/batch/v1') {
$r = Get-HttpResult -Uri "$Url$route" -Method POST -Body '{"requests":[]}'
if ($r.Status -in 401, 403) {
Add-Result "Batch-API $route" $r.Status 'OK' 'fuer Gaeste gesperrt'
}
elseif ($r.Status -eq 404) {
Add-Result "Batch-API $route" $r.Status 'OK' 'nicht vorhanden'
}
elseif ($r.Status -lt 0) {
Add-Result "Batch-API $route" 'keine Antwort' 'Hinweis' 'Anfrage lief ins Leere, ggf. Firewall'
}
else {
Add-Result "Batch-API $route" $r.Status 'Warnung' 'fuer Gaeste erreichbar, nur mit gepatchtem Core vertretbar'
}
}
#endregion
#region 4) XML-RPC
$xml = '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>'
$r = Get-HttpResult -Uri "$Url/xmlrpc.php" -Method POST -Body $xml -ContentType 'text/xml'
if ($r.Status -eq 200 -and $r.Content -match '<string>') {
Add-Result 'XML-RPC' $r.Status 'Warnung' 'offen, Ziel fuer Passwort-Rateversuche'
}
else {
Add-Result 'XML-RPC' $r.Status 'OK' 'gesperrt oder ohne Methoden'
}
#endregion
#region 5) Benutzer-Aufzaehlung
foreach ($route in '/wp-json/wp/v2/users', '/?rest_route=/wp/v2/users') {
$r = Get-HttpResult -Uri "$Url$route"
if ($r.Status -eq 200 -and $r.Content -match '"slug"\s*:\s*"([^"]+)"') {
Add-Result "Benutzerliste $route" $r.Status 'Warnung' "Benutzernamen oeffentlich, z. B. '$($Matches[1])'"
}
else {
Add-Result "Benutzerliste $route" $r.Status 'OK' 'nicht auslesbar'
}
}
$r = Get-HttpResult -Uri "$Url/?author=1"
if ($r.FinalUri -match '/author/([^/?#]+)') {
Add-Result 'Autoren-Weiterleitung /?author=1' $r.Status 'Hinweis' "verraet Benutzernamen '$($Matches[1])'"
}
else {
Add-Result 'Autoren-Weiterleitung /?author=1' $r.Status 'OK' 'kein Benutzername erkennbar'
}
#endregion
#region 6) PHP-Version im Header
$powered = $null
if ($start.Headers) { $powered = $start.Headers['X-Powered-By'] }
if ($powered) {
Add-Result 'Header X-Powered-By' ([string]$powered) 'Hinweis' 'PHP-Version sichtbar, expose_php = Off setzen'
}
else {
Add-Result 'Header X-Powered-By' '-' 'OK' 'keine PHP-Version sichtbar'
}
#endregion
#region Ausgabe
$farbe = @{ OK = 'Green'; Hinweis = 'Cyan'; Warnung = 'Yellow'; Kritisch = 'Red' }
$w1 = ($results | ForEach-Object { $_.Pruefung.Length } | Measure-Object -Maximum).Maximum
$w2 = ($results | ForEach-Object { ([string]$_.Ergebnis).Length } | Measure-Object -Maximum).Maximum
foreach ($e in $results) {
Write-Host (' {0,-10}' -f "[$($e.Stufe)]") -ForegroundColor $farbe[$e.Stufe] -NoNewline
Write-Host (" {0} {1} " -f $e.Pruefung.PadRight($w1), ([string]$e.Ergebnis).PadRight($w2)) -NoNewline
Write-Host $e.Bewertung -ForegroundColor Gray
}
$kritisch = @($results | Where-Object Stufe -eq 'Kritisch').Count
$warnung = @($results | Where-Object Stufe -eq 'Warnung').Count
$hinweis = @($results | Where-Object Stufe -eq 'Hinweis').Count
Write-Host ''
if ($kritisch + $warnung -eq 0) {
Write-Host " Ergebnis: keine Warnung, $hinweis Hinweis(e)." -ForegroundColor Green
$exitCode = 0
}
else {
Write-Host " Ergebnis: $kritisch kritisch, $warnung Warnung(en), $hinweis Hinweis(e)." -ForegroundColor Yellow
$exitCode = 1
}
Write-Host ''
if ($PassThru) { $results }
exit $exitCode
#endregion
Aufruf:
Unblock-File .\Test-WpExposure.ps1
.\Test-WpExposure.ps1 -Url https://example.com
# Ergebnisse zusaetzlich als CSV
.\Test-WpExposure.ps1 -Url https://example.com -PassThru |
Export-Csv -Path .\wp-check.csv -NoTypeInformation -Encoding UTF8
So sieht die Ausgabe gegen eine ungehärtete Testinstanz mit WordPress 7.0.1 aus:
Test-WpExposure v0.2 | Andreas Bowitz | abow.info
Ziel: https://test.example
[OK] Erreichbarkeit 200 Seite antwortet
[Kritisch] WordPress-Version 7.0.1 (Meta-Generator) wp2shell-verwundbar: sofort updaten und auf Einbruch pruefen
[Warnung] Batch-API /wp-json/batch/v1 207 fuer Gaeste erreichbar, nur mit gepatchtem Core vertretbar
[Warnung] Batch-API /?rest_route=/batch/v1 207 fuer Gaeste erreichbar, nur mit gepatchtem Core vertretbar
[Warnung] XML-RPC 200 offen, Ziel fuer Passwort-Rateversuche
[Warnung] Benutzerliste /wp-json/wp/v2/users 200 Benutzernamen oeffentlich, z. B. 'admin'
[Warnung] Benutzerliste /?rest_route=/wp/v2/users 200 Benutzernamen oeffentlich, z. B. 'admin'
[Hinweis] Autoren-Weiterleitung /?author=1 200 verraet Benutzernamen 'admin'
[Hinweis] Header X-Powered-By PHP/8.3.9 PHP-Version sichtbar, expose_php = Off setzen
Ergebnis: 1 kritisch, 5 Warnung(en), 2 Hinweis(e).
Nach der Härtung aus diesem Beitrag stehen alle Zeilen auf [OK]: Batch-API 401, XML-RPC 403, Benutzerlisten 404, keine Version und keine PHP-Angabe sichtbar. Eine Warnung bei der Batch-API heißt bei gepatchtem Core noch nicht, dass jemand eingebrochen ist. Es bleibt aber Angriffsfläche für die nächste Lücke in diesem Endpunkt. Die installierte Version prüfst du dann im Backend unter Dashboard → Aktualisierungen.
Fazit: Der Fix lag zwei Tage bereit, bevor es mich erwischt hat. Gescheitert ist es an einem Rechteproblem nach dem Umzug und daran, dass zehn Wochen lang niemand hingeschaut hat. Prüf also heute, ob sich dein WordPress wirklich selbst aktualisiert, und richte dir irgendeine Meldung ein, wenn ein neuer Administrator auftaucht. Beides kostet eine halbe Stunde.
Quellen
- WordPress: WordPress 7.0.2 Release
- Searchlight Cyber: wp2shell: Pre Authentication RCE in WordPress Core
- GitHub Security Advisories: GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf
- Bitdefender: Technical Advisory: wp2shell
- Wiz: Exploitation in the Wild of wp2shell
- Cyber Kendra: WP2Shell: Checker, Patch & Detection Guide (Hinweis auf umgehbare WAF-Regeln laut Patchstack)
- Elastic Security Labs: wp2shell: detecting WordPress pre-auth RCE
