WordPress-Hack analysieren: von der ersten Spur im Juni über Backdoors und Application Passwords bis zur Detailanalyse einer Shop-Kampagne, mit Zahlen aus vier Servern
Anfang September stand in der Benutzerliste eines Multisite-Servers ein halbes Dutzend Admin-Konten, die niemand angelegt hatte. Gut fünf Wochen später liegt ein Stapel Auswertungen von vier Servern vor mir, und die Geschichte ist größer geworden als das Aufräumen eines einzelnen Hacks. Die Analyse bezieht nur vier Server ein, weil diese besonders exponiert und durch die WordPress-Instanzen in einem besonderen Fokus stehen.
Der Beitrag hat vier Teile. Teil A erzählt den Hack auf Server A von der ersten Spur bis zur Bereinigung. Teil B beschreibt, was Angreifer tun, bevor etwas gelingt: Aufklärung, Scans, Botnetze, Sonden. Teil C zeigt, wie eine Detailanalyse konkret ablief, am Beispiel einer automatisierten „Pentest“-Kampagne gegen einen Shop-Server am 8. und 9. Oktober. Teil D sammelt die Gegenmaßnahmen, dann folgen Indikatoren und eine Checkliste.
Kurzfassung: so lässt sich ein WordPress-Hack analysieren
- Auf Server A waren Angreifer nachweislich seit dem 12. Juni 2026 aktiv. Entdeckt wurden sie am 8. September, knapp drei Monate später.
- Es gab zwei Generationen Schadcode: einen als Plugin getarnten Loader mit sechs Megabyte verschleiertem PHP und manipulierte Cache-Drop-ins samt Theme-Dateien. Beides fanden Prüfsummen und Marker.
- Der hartnäckigste Zugang war keine Datei. Es waren fünf Application Passwords im Super-Admin-Konto. Sie überlebten Passwortwechsel, neue Salts und die Dateibereinigung und lieferten bis zum 29. September rund 370 Spam-Beiträge.
- Dieselben Netzbereiche tauchen auf mehreren Servern auf, mal als SSH-Botnet, mal als SQL-Injection-Scanner, mal als Login-Brute-Force. Dahinter stehen offenbar wenige Betreiber mit mehreren Werkzeugen.
- Gewirkt haben Dauersperren für eindeutige Muster, eine fail2ban-Datenbank mit 200 Tagen Gedächtnis, wachsende Sperrzeiten, Blocklisten samt Tor-Exits an den Login-Endpunkten, ein Rate-Limit für Passwort-Resets und das Abschalten der Application Passwords.
- Offen bleibt der Einstieg. Die Logs aus Juni gab es nicht mehr, als ich danach suchte.
Die Umgebung und die Methode
Ich habe die Server anonymisiert. Domains, Kundendaten und Namen von Mitarbeitenden fehlen. Die Infrastruktur der Angreifer bleibt konkret, weil sie der eigentliche Erkenntnisgewinn ist.
| Kürzel | Rolle | Besonderheiten |
|---|---|---|
| Server A | WordPress-Multisite mit zehn Blogs und eine eigenständige WordPress-Installation mit Shop | 28 nginx-Vhosts, ein gemeinsamer PHP-Benutzer, Ubuntu 22.04, fail2ban 0.11 mit dem Plugin wp-fail2ban |
| Server B | Produktiver WooCommerce-Shop hinter Varnish und Redis | nginx, ipset, fail2ban |
| Server B2 | Entwicklungsinstanz zu Server B | HTTP Basic Auth vor der ganzen Seite |
| Server C | Multi-Domain-Server einer Organisation mit mehreren WordPress-Seiten, Nextcloud, URL-Shortener und Docker-Diensten | Ubuntu 24.04, fail2ban 1.0 |
Quellen waren die nginx-Access- und Error-Logs, die auth.log, die fail2ban-Datenbank, die WordPress-Datenbanken, wp-cli und das Dateisystem. Die Auswertung lief per SSH und war zuerst nur lesend. Änderungen kamen erst nach meiner Freigabe, jeweils mit Sicherung und einem Weg zurück.
Alle Zeiten sind UTC, wo nichts anderes steht. WordPress speichert Beitragsdaten in der Ortszeit der Seite (hier Europe/Berlin, im Sommer UTC+2), nginx loggt UTC. Das hat mich einmal richtig in die Irre geführt, dazu mehr in Abschnitt A.7.
Teil A: der Hack auf Server A
A.1 Zeitleiste
Die Zeitleiste setzt sich aus vier Quellen zusammen: den Metadaten der Application Passwords, den Registrierungsdaten der Benutzer, dem nginx-Log und den Dateizeitstempeln. Die letzten sind die unsicherste Quelle, weil sie sich fälschen lassen.
| Zeit (UTC) | Was passiert | Woher wir es wissen |
|---|---|---|
| 12.06., 11:11 | Erstes Application Password namens „sentinel“ im Super-Admin-Konto. Um 15:47 wird es erstmals benutzt, von einer US-Adresse (107.158.19.26) | Felder created, last_used, last_ip |
| 12.06., 13:30 | Neuer Admin admin_c3755d auf dem Hauptblog | user_registered |
| 15. bis 26.06. | Vier weitere Application Passwords: „WP Admin Bot“ (15.06., 09:59), „sentinel“ (15.06., 12:16 und 17.06., 05:15), „auto-bootstrap“ (26.06., 17:57) | created, per date -d @epoch umgerechnet |
| 19. bis 21.07. | 27 erfolgreiche Beiträge über die REST-API mit Basic Auth, von drei IPs aus zwei Netzen (107.158.93.143, 204.77.129.12, 204.77.129.175) | nginx-Log, Benutzername im Log-Feld |
| 08.08., 12:21 | Mindestens neun neue Admin-Konten gleichzeitig, auf allen Blogs und auf der zweiten Installation. Muster: admin_, administrator_ oder backup_ plus zehn Hex-Zeichen, mit einer Mailadresse auf der jeweiligen Blog-Domain | user_registered |
| 08. bis 14.08. | Dutzende IPs probieren Basic-Auth-Logins auf /wp-json/wp/v2/users/me, mit den Benutzernamen beider Installationen. nginx beantwortet den Pfad mit 404, die Versuche bleiben wirkungslos | nginx-Log |
| 20.08. | Brute-Force-Welle auf wp-login.php: 121 IPs, etwa 930 POSTs auf einen Blog, kein einziger Login. Laut Dateizeitstempeln werden in diesen Tagen Cache-Drop-ins (20.08., 02:43), Theme-Dateien und auf der zweiten Installation ein Plugin (21.08., 06:30) verändert | nginx-Log, Dateizeitstempel |
| 25.08., 17:52 | Eine Datei about.php im Backup-Ordner eines Migrations-Plugins antwortet einem Azure-Scanner mit 200 und 2.444 Byte. Unter Dutzenden Treffern war es der einzige, der nicht 404 war | nginx-Log |
| 08.09., 19:32 und 09.09., 03:29 | Passwort-Logins mit zwei der Rogue-Admin-Konten, von 82.40.119.184 und 66.67.167.152. Die zweite IP ruft drei Sekunden später die Plugin-Upload-Seite auf (HTTP 500) | wp-fail2ban-Einträge im Syslog, nginx-Log |
| 09.09. | Entdeckung und Bereinigung: Backdoor-Dateien entfernt, Admins gelöscht, Passwörter und Salts erneuert, Neustart um 07:40 | Server-Protokoll |
| 11. bis 29.09. | Rund 340 Spam-Beiträge über die REST-API, authentifiziert per Application Password | nginx-Log |
| 29.09. | Der Spam fällt auf. Alle fünf Application Passwords werden widerrufen, die Funktion wird abgeschaltet | |
| 01.10. | Vier weitere POSTs auf /wp-json/wp/v2/posts mit demselben Benutzernamen, von 204.3.206.87. Antwort: 401 | nginx-Log |
Ob es Spuren vor dem 12. Juni gab, kann ich nicht sagen. Das nginx-Log reichte nur 52 Tage zurück, die auth.log drei Wochen. Wie die Angreifer hereinkamen, bleibt deshalb offen. Die Kandidaten sind eine Brute-Force-Welle, ein verwundbares Plugin oder das Backup-Verzeichnis des Migrations-Plugins. Belegen kann ich keinen davon.
A.2 Die Backdoor, erste Generation
Aufgefallen ist zuerst eine sechs Megabyte große PHP-Datei im Verzeichnis für Must-Use-Plugins. Solche Plugins lädt WordPress bei jedem Request automatisch, und in der Plugin-Liste tauchen sie nicht auf. Die Datei trug den Header eines Plugins namens „Ridge Migrator Live“ mit erfundenem Autor und einer Plugin-URL, die nirgendwohin führt. Zusätzlich stand sie als normales Plugin in active_plugins von acht der zehn Blogs.
Der Code ist unleserlich gemacht. Funktionsnamen sind Zufallsstrings, Zeichenketten liegen in riesigen Arrays und werden zur Laufzeit zusammengesetzt. Eine Suche nach curl_exec, eval oder $_POST findet in der Datei keinen einzigen Treffer im Klartext. Ein Scanner, der nach solchen Wörtern sucht, läuft damit ins Leere.
Die Malware verwaltet sich selbst. In versteckten Ordnern und Dateien mit Hex-Suffix (.sc_, .kk_, .gk_ und weitere Präfixe) lagen Statusdateien: eine JSON-Datei mit Version 4.5.3, Zeitstempel und Pfad der Loader-Datei, eine mit Zeitstempel, Prozess-ID und Version als Herzschlag und eine mit dem Inhalt cli:<PID>. Der letzte Eintrag verrät, dass der Code auch von der Kommandozeile lief, also ohne Webrequest.
Während wir auf dem Server arbeiteten, schrieb die Malware weiter Statusdateien, und um 05:19 und 05:26 UTC entstanden zwei neue Admin-Konten. Das hörte erst auf, als der Prozess mit dieser PID beendet und die Dateien entfernt waren.
A.3 Zweite Generation: Cache-Drop-ins und Themes
Der WordPress-Kern und alle Plugins stimmten mit den offiziellen Prüfsummen von wordpress.org überein. Fünf andere Dateien fanden sich erst über einen Marker-Grep nach den Versionsstrings der ersten Backdoor. Die advanced-cache.php war von etwa einem Kilobyte auf 60 Kilobyte angewachsen. Die object-cache.php bestand zu hundert Prozent aus Schadcode und war als Cache-Datei getarnt. In drei functions.php von Themes steckten je rund 215 Kilobyte zwischen Markern wie SC_TH_BEGIN und SC_TH_END.
Drop-ins laufen vor allen Plugins, und viele Scanner sehen sie nicht an. Das macht sie zu einem guten Versteck.
Eine Beobachtung, die ich nicht beweisen kann: Meine erste Plugin-Inventur zeigte sechs WooCommerce-Plugins, die es auf der Platte nie gab. Nach dem Entfernen der object-cache.php waren sie aus der Liste verschwunden. Ich vermute, dass das Drop-in WordPress-Abfragen verfälscht hat. Die Lehre gilt auch ohne Beweis: Solange die Integrität offen ist, gehört eine Inventur gegen das Dateisystem geprüft und nicht allein gegen WordPress-Funktionen.
A.4 Der Zugang ohne Datei: Application Passwords
Nach der Bereinigung war scheinbar Ruhe. Dateiscans und Prüfsummen waren sauber, Passwörter und Salts erneuert. Am 29. September stand trotzdem ein neuer Spam-Beitrag auf dem Hauptblog, als Autor der Super-Admin.
Den Hinweis lieferte das nginx-Log. Browser-Sitzungen laufen über Cookies, im dritten Feld jeder Log-Zeile steht dann ein Minus. Requests mit HTTP Basic Auth tragen dort den Benutzernamen. Wer alle Zeilen mit eingetragenem Benutzernamen herausfiltert, sieht jede Nutzung eines Application Passwords:
151.247.123.36 - <superadmin> [29/Sep/2026:11:37:53 +0000] "GET /wp-json/wp/v2/users/me HTTP/2.0" 404 106 ... Firefox/148.0
151.247.123.36 - <superadmin> [29/Sep/2026:11:37:53 +0000] "POST /wp-json/wp/v2/posts HTTP/2.0" 201 26512 ...
151.247.123.36 - <superadmin> [29/Sep/2026:11:37:54 +0000] "POST /wp-json/wp/v2/posts HTTP/2.0" 201 27241 ...
151.247.123.36 - <superadmin> [29/Sep/2026:11:37:55 +0000] "POST /wp-json/wp/v2/posts HTTP/2.0" 201 29492 ...
Die Zeitpunkte passten auf die Sekunde zu den Spam-Beiträgen, und die IP stand als last_ip bei einem Application Password namens „sentinel“. In der Datenbank lagen fünf davon, angelegt zwischen dem 12. und 26. Juni, dazu die Namen „WP Admin Bot“ und „auto-bootstrap“.
Application Passwords gibt es seit WordPress 5.6. Sie sind ein eigener Weg, sich über REST-API oder XML-RPC zu authentifizieren, und hängen am Benutzer, nicht an der Sitzung. Ein neues Login-Passwort, neue Salts, ausgeloggte Sessions: nichts davon berührt sie. Sie stehen in der Tabelle usermeta unter _application_passwords, also in keiner Datei, die ein Dateiscan oder eine Prüfsumme sehen könnte.
Die Gegenmaßnahme hat drei Schritte. Erstens alle löschen (wp user application-password delete <user> --all). Zweitens das Feature abschalten, mit einem kleinen Must-Use-Plugin. Drittens in der usermeta-Tabelle nachsehen, ob irgendein anderer Account eines hat.
<?php
add_filter( 'wp_is_application_passwords_available', '__return_false' );
A.5 Die Spam-Kampagne
Im Log stehen 367 erfolgreiche Beiträge über die REST-API mit Basic Auth: 27 im Juli und etwa 340 zwischen dem 11. und 29. September, mit der Spitze am 19. September (90 an einem Tag). Noch online waren am Ende 29 Beiträge. Die früheren Wellen hatte der Seitenbetreiber offenbar schon gelöscht.
Die Technik ist einfach. Vor jedem Schub fragt das Skript /wp-json/wp/v2/users/me ab, um zu prüfen, ob das Credential noch lebt (nginx antwortet mit 404, das Skript macht trotzdem weiter). Dann folgen drei bis fünf Beiträge pro Sekunde, mit einem gefälschten Firefox-148-User-Agent unter Windows. Die Beiträge sind Casino- und Wett-Artikel in sieben Sprachen (Deutsch, Englisch, Portugiesisch, Spanisch, Tschechisch, Aserbaidschanisch, Russisch). Jeder trägt oben einen großen grünen „SPIELEN“-Button zu einer Wegwerf-Domain mit der Endung .top und ein Inhaltsverzeichnis. Ziel ist Suchmaschinen- und Affiliate-Verkehr auf Kosten einer vertrauenswürdigen Domain.
A.6 Warum zwei Installationen betroffen waren
Zwei völlig getrennte WordPress-Installationen mit derselben Schadsoftware haben eine banale Ursache. Alle 28 Vhosts laufen unter demselben PHP-Benutzer. Wer auf einer Seite Code ausführen kann, kann in jedes andere Webroot schreiben. Die Backdoor hat das ausgenutzt, auf der zweiten Installation lag sie in einem Plugin-Ordner und in Theme-Dateien.
Dazu kam Schlamperei auf meiner Seite: Beide Installationen hatten dieselben WordPress-Salts, später auch dasselbe Datenbankpasswort, vermutlich aus derselben Vorlage. Die Empfehlung ist ein PHP-FPM-Pool pro Vhost mit eigenem Systembenutzer. Umgesetzt ist das noch nicht.
A.7 Was ich falsch eingeschätzt habe
Eine Auswertung wird nicht besser, wenn man die Fehler weglässt. Fünf davon waren lehrreich.
- Die Dateizeitstempel von April 2024 und 2025 hielt ich anfangs für den Beginn der Infektion. Ein
touch -treicht, um sie zu fälschen, und von derselben Datei existierten mehrere Kopien mit verschiedenen Daten. Ich habe die Datierung zurückgezogen. Belegt ist der 12. Juni 2026. - Die Application-Password-Zeitstempel habe ich zuerst im Kopf umgerechnet und auf Juni 2025 gelegt.
date -d @epochzeigt 2026. Seitdem rechne ich keine Epochen mehr im Kopf. - WordPress speichert Beitragszeiten in Ortszeit, nginx loggt UTC. Ich habe beide verglichen und zeitweise geschlossen, das Löschen eines Plugins habe den Spam nicht gestoppt. Es hatte ihn gar nicht verursacht.
- Ein KI-Plugin mit MCP-Schnittstelle und gespeichertem API-Key sah wie die Ursache aus, weil die Zeitstempel passten. Es war ein Nebenschauplatz. Erst die Log-Zeilen mit Benutzername zeigten den wirklichen Weg. Eine CVE für das Plugin zu schreiben, wäre ein Fehler gewesen.
- „Sauber“ hieß in mehreren Zwischenständen nur, dass die Methode nichts fand. Dateiprüfungen sehen keine Datenbankinhalte.
Teil B: Angriffsvorbereitung
Bevor ein Angriff gelingt, passiert viel, und das meiste davon steht in den Logs, wenn man danach sucht. Dieser Teil beschreibt, was die vier Server in den letzten Wochen gesehen haben, bevor irgendetwas schiefging.
B.1 Aufklärung: wer wohnt hier, und wie heißt der Admin?
Ein Server mit vielen Domains verrät sich selbst. Die Namen stehen in den Certificate-Transparency-Logs, und jede Reverse-IP-Suche listet sie auf. Auf Server C sieht man, wie das genutzt wird: Seit Beginn der Aufzeichnung probieren Bots SSH-Benutzernamen, die aus den gehosteten Domains abgeleitet sind. Die vier häufigsten kamen auf 4.365, 3.876, 2.463 und 2.329 Versuche. Die neue Botnet-Welle vom 26. September hat diese Namen in ihr Wörterbuch übernommen.
Auf Server B sieht man dasselbe Prinzip bei WordPress. Ein Aufruf von /author/<login>/ antwortete mit 301, ein erfundener Name mit 404. Damit ließ sich jeder Login bestätigen. Ein Admin-Login stand außerdem als Anzeigename im öffentlichen Kommentar-Feed der REST-API. Weitere Konten bestanden aus einem Präfix und den Initialen von Mitarbeitenden, die auf der Firmenseite stehen. Dieselben Abfragen kamen schon seit mindestens dem 24. September aus 47.128.31.x.
Dazu kommen die üblichen Fingerabdrücke: Plugin-Inventar über readme.txt, Versionen über readme.html und license.txt, Verzeichnis-Scans auf /wp-includes/, /wp-admin/ und /wp-content/plugins/. Und eine Sorte Anfrage, die man auf einem Shop nicht erwartet: SSRF-Sonden über oEmbed, die den Server auffordern, 127.0.0.1 oder 169.254.169.254 (die Metadaten-Adresse der Cloud) abzurufen.
Auf Server A fiel ein anderes Muster auf. Zwischen dem 8. und 14. August probierten Dutzende IPs Basic-Auth-Logins auf /wp-json/wp/v2/users/me, jeweils ein bis sieben Mal pro IP, mit den Benutzernamen beider Installationen. Eine Liste von Zugangsdaten wird offenbar an viele Bots verteilt. Das spricht dafür, dass jemand einen Satz Credentials hatte, die er prüfen wollte.
B.2 Massenscans nach Geheimnissen
Die größte Gruppe sind Scanner, die nach Dateien fragen, die nie im Webroot liegen sollten: .env, .git/, .aws/credentials, .stripe/, firebase-config.json, google-services.json, claude_desktop_config, Symfony-Profiler-Seiten mit phpinfo, wp-config.php.bak, SQL-Dumps. Ein Treffer ist Gold: Ein einziges .env enthält oft Datenbank-, Mail- und API-Schlüssel.
Auf Server A hat eine fail2ban-Regel für solche Abfragen zwischen dem 9. September und dem 7. Oktober 1.979 IPs gesperrt, 473 davon in den ersten 20 Tagen und 1.506 in den folgenden acht. Auf Server C erkannte derselbe Filter an einem einzigen Tag 635 Versuche. Die Quellen sind überwiegend Cloud-Adressen: Google Cloud (34.x, 35.x), IBM Cloud (169.40.142.138) und einzelne Dauerläufer wie 207.175.59.209 mit 52 Treffern an einem Tag. Aus 45.148.10.x kamen zusätzlich Path-Traversal-Versuche (/..%c0%afproc/self/cmdline) und Anfragen nach .stripe/. Solche Anfragen lassen sich schon am Webserver abfangen, wie ich es in einer Web-Firewall aus Open-Source-Bordmitteln beschrieben habe.
B.3 Login-Brute-Force, XML-RPC und Credential-Tests
Am 20. August lief auf Server A eine Welle gegen wp-login.php: 121 IPs, rund 930 POSTs auf einen Blog. Eine IP, 91.92.40.172, schickte im Sekundentakt Anmeldungen mit jedes Mal anderem User-Agent (Firefox, Chrome, Safari, unter Windows 10 und 11, macOS und Linux) und trug die eigene Login-Seite als Referer ein. Gleichzeitig kamen Anfragen von Adressen, die jeweils nur ein oder zwei Versuche machten. Ein einziger erfolgreicher Login, erkennbar an einer Weiterleitung (302), war nicht dabei.
XML-RPC bleibt ein Dauerthema. Server A hat bisher 864 IPs wegen XML-RPC-Missbrauch gesperrt. Auf Server B kamen an einem Tag 942 XML-RPC-Anfragen, alle mit 403 beantwortet, gerichtet an echte Admin-Namen. Bei den gesperrten Adressen auf Server A und den Auslösern der Reset-Stürme auf Server B fallen Tor-Exit-Blöcke auf (192.42.116.x, 185.220.100.x und 185.220.101.x, 171.25.193.x, 204.8.96.x, 109.70.100.x, 193.189.100.x). Auf Server B sind vier davon über die offizielle Exit-Liste bestätigt: 192.42.116.45, 185.220.101.129, 109.70.100.13 und 193.189.100.197.
B.4 Das SSH-Botnet auf Server C
Server C hatte in 30 Tagen rund 240.000 fehlgeschlagene SSH-Anmeldungen. Interessant ist der Verlauf. Bis zum 26. September lag der Wert bei etwa 2.500 pro Tag, danach über 20.000, inzwischen fällt er wieder auf etwa 9.000. Die Welle setzte auf die Stunde genau ein: Bis 9 Uhr kamen rund 100 Versuche pro Stunde, um 10 Uhr 2.242, um 11 Uhr 2.674.
Mehrere Dinge sprechen dafür, dass eine einzige Stelle die Bots steuert. An dem Tag tauchten 390 bisher unbekannte IPs auf (sonst 30 bis 80). Um 11:26 Uhr gab es 56 Anmeldeversuche mit dem Benutzernamen ubuntu innerhalb einer Minute von verschiedenen IPs, ein Wörterbuch wird also auf viele Bots verteilt. Jede neue IP machte im Schnitt 225 Versuche, 108 der 390 waren nach dem 5. Oktober noch aktiv.
Die Benutzernamen zeigen das Ziel. Vorher dominierten root und admin. Ab dem 26. September kamen Cloud-Standardkonten (ubuntu etwa 50.000 Mal, deploy 11.000 Mal, ec2-user) und ein Krypto-Wörterbuch (blockchain, wallet, bitcoin, solana, etherscan, trustvault, usdt, binance). Die Kampagne sucht schlecht gesicherte VMs mit Wallets oder Nodes. Der Server ist zufällig im Raster gelandet.
Die Absender sind überwiegend selbst gekaperte Maschinen: DigitalOcean, OVH, Oracle Cloud, Contabo, webgo, Korea Telecom, Viettel, Chinanet, dazu viele aus Indonesien und Brasilien. Als Dauerangreifer schon vor der Welle fielen Bulletproof-Hoster auf, genannt wurden DMZHOST/Techoff (Rumänien, Bulgarien), UNMANAGED LTD, Feo Prest (Rumänien) und Cipher Operations (Serbien). Allein 45.148.10.0/24 und 213.209.159.0/24 kommen zusammen auf über 18.000 Versuche.
fail2ban sperrte zehn Minuten lang, danach kamen dieselben IPs zurück. 2.146 von 2.674 SSH-Angreifer-IPs machten mehr als vier Versuche. Nur 33 von ihnen tauchten auch in den Web-Logs auf, keine einzige aus der Welle vom 26. September. SSH-Botnet und Web-Scanner arbeiten getrennt.
B.5 SQL-Injection-Sonden gegen den REST-Batch-Endpunkt
Ab dem 30. Juli tauchten auf Server B in den Error-Logs SQL-Fehler auf, ausgelöst über POST /wp-json/batch/v1 (auch als /index.php?rest_route=/batch/v1 und /index.php/wp-json/batch/v1). Die Payloads kamen in Familien. Zuerst UNION-SELECTs mit hex-kodierten Werten und einem Erkennungsstring:
UNION SELECT 999999,2,0x323032302d30312d30312030303a30303a3030,...,
CONCAT(0x7c7c,HEX(CAST((SELECT 0x4f4b)AS CHAR)),0x7c7c),...-- -)
Die Hex-Werte dekodieren zu 2020-01-01 00:00:00 und ||4F4B|| („OK“). Wenn der String in der Antwort auftaucht, ist die Lücke offen. Danach kamen Timing-Angriffe (SELECT IF(1=1,SLEEP(1.2),0)), boolesche Varianten und EXTRACTVALUE. Am 3. August erschien eine neue Sonde:
AND 1=0 UNION ALL SELECT 99999999,...,CONCAT(0x776f726470726573733078,
HEX(CAST((SELECT 'wp2shell_probe') AS CHAR))),...
Der Hex-Wert ist wordpress0x, der Teststring wp2shell_probe. Der Angreifer prüft, ob sich über die Datenbank eine Webshell einschleusen lässt. Die Datenbank wies alle Anfragen mit einem Syntaxfehler ab. Mehrere Wellen pro Nacht, meist drei Requests innerhalb weniger Sekunden, pro Welle eine andere URL-Variante.
Ein Nebeneffekt: Die manipulierten Requests brachten ein Mehrsprachigkeits-Plugin mit PHP 8.5 jedes Mal zum Absturz (ein TypeError). Angriffe finden manchmal Fehler, die sonst niemand gefunden hätte. Das Plugin-Update beseitigte den Absturz.
Auf Server A traf derselbe Endpunkt in 52 Tagen auf 1.831 IPs mit zusammen 35.551 POSTs. 51 dieser IPs bekamen mindestens einmal HTTP 200, 204 oder 201, was nur heißt, dass der Endpunkt antwortet, und nicht, dass die Ausnutzung gelang. Die meisten Treffer kamen aus 195.178.110.x, 45.148.10.x, 93.123.109.x und 62.60.130.x, eine einzelne IP bis zu 145 Mal in 30 Sekunden. Keine dieser Adressen gehörte zu meinen eigenen Editor-Sitzungen, was für die spätere Jail-Schwelle wichtig war (Abschnitt D.1).
B.6 Sonden für bekannte Lücken
Zwischen den Hauptkampagnen laufen Einzelsonden für bekannte Schwachstellen. Beispiele aus den Logs: eine PHP-CGI-Argumentinjektion mit Soft-Hyphen (POST /hello.world?%ADd+allow_url_include%3d1+%ADd+auto_prepend_file%3dphp://input, User-Agent libredtail-http), Path-Traversal in /cgi-bin/ mit doppelt kodierten Punkten bis zu /bin/sh, ein Remote-Code-Versuch gegen /setSystemCommand, der an das REST-Plugin eines Affiliate-Systems ging, GraphQL- und Nextcloud-Sonden. Antworten: 404, 400, 301, 444. Auf Server B gelang bei einem Plugin ein Request durch, der zum Absturz, aber nicht zur Ausführung führte. Nebenbei fiel auf, dass der Backend-Port 8080 von außen erreichbar war. Er ist inzwischen zu.
Ein Scanner nennt sich selbst metabase-cve-2026-72898-detect/1.0 (benign detection probes only) und fragt trotzdem überall /api/session/properties ab. Selbstdeklarierte „harmlose“ Scanner gibt es also, und niemand überprüft, ob sie wirklich harmlos sind.
B.7 Hintergrundrauschen richtig lesen
Ein Lehrstück gab es auf Server A. Eine Datei namens about.php im Backup-Ordner eines Migrations-Plugins wurde in einer Woche dutzende Male abgefragt, von acht Azure-Adressen aus (20.x, 40.85.x, 52.173.x, 104.209.x, 168.62.x, 172.182.x), alle mit leerem User-Agent. Ich hielt das zuerst für einen gezielten Zugriff. Das Error-Log zeigte etwas anderes: Die Anfragen kamen mit erratenen Host-Headern (remote.…, dev.…, tresor.…, postfach.…) auf dem Default-Vhost an, dessen Docroot /usr/share/nginx/html ist. Die Scanner raten Hostnamen, vermutlich aus Zertifikatslisten.
Der eine Treffer mit Status 200 stammt dagegen aus einem Aufruf, der auf dem richtigen Vhost landete. Das Log enthielt keinen Host, deshalb war das erst nach dem Gegencheck im Error-Log klar. Die Lehre aus diesem Fall: Das Host-Feld gehört ins Access-Log. Wie man nginx-Logs als JSON ausgibt, steht in diesem Artikel, die Umsetzung hier in Abschnitt D.6.
Auf Server A fragte ein Azure-Scanner am 18. August außerdem Dutzende zufällige fünfstellige PHP-Dateinamen im Webroot ab (rymmm.php, weozh.php, dlvqo.php) und bekannte Webshell-Namen wie wp_filemanager.php im Ordner eines Plugins namens „hellopress“. Das ist die Art Scan, die prüft, ob irgendeine frühere Kampagne dort schon etwas abgelegt hat.
B.8 Die Infrastruktur der Angreifer
| Netz | Typ | Gesehen auf | Verhalten |
|---|---|---|---|
| AS213954 (GTS Global Transit Systems): 157.22.x.x in fünf /22-Blöcken, 104.245.240.0/22, 172.83.252.0/22 und 17 weitere angekündigte Präfixe | Hosting und VPN. Laut Whois auf drei US-Firmen registriert: Atlas Network Holdings (Wyoming, Netz seit 30.10.2025), VitalKey (Virginia), Caache LLC (Kalifornien, seit 08.07.2025) | B (rund 90 Prozent der Kampagne vom 8.10.), A (Test von /batch/v1 am 28.09.) | Rotierende IPs, meist ein bis zwei Requests je Adresse, später Ausweichen auf andere Präfixe |
| 159.253.248.0/24 (AIRE Networks, Spanien), 91.92.241.0/24 (Omegatech, Seychellen, laut Whois erst am 25.09.2026 registriert) | Hosting | B (8.10.), danach auch A (10 Pakete verworfen) | Beteiligt an der Kampagne |
| 91.92.40.172 (gleicher /16-Bereich, Zuordnung zum selben Betreiber ungeprüft) | Hosting | A (20.08.) | Login-Brute-Force im Sekundentakt mit wechselnden User-Agents |
| 45.148.10.0/24 und 213.209.159.0/24 | Bulletproof-Hoster (genannt: DMZHOST/Techoff und weitere) | A, C | C: SSH, über 18.000 Versuche. A: REST-Batch-Scans mit gefälschtem wp-admin-Referer, Secrets-Scans, Path-Traversal |
| 195.178.110.0/24, 93.123.109.0/24, 62.60.130.0/24 | Hosting, Betreiber nicht geprüft | A, B | A: Batch-Scans (bis 145 Requests in 30 s), wp-login-Brute-Force. B: 62.60.130.230 mit SQL-Injection |
| Tor-Exits (192.42.116.0/24, 185.220.100.0 bis 185.220.101.255, 109.70.100.0/24, 171.25.193.0/24, 204.8.96.0/24, 193.189.100.0/24) | Anonymisierung | A, B, B2 | Login- und XML-RPC-Brute-Force, Passwort-Reset-Stürme, Basic-Auth-Tests |
| Azure-Scanner (20.x, 40.85.x, 52.173.x, 104.209.x, 168.62.x, 172.182.x) | Cloud-Scanner | A | Signatur-Scans nach about.php und Zufallsnamen, leerer User-Agent, Raten von Hostnamen |
| 107.158.x.x, 204.77.129.x, 151.247.123.x, 204.3.206.x | US-Hosting, Betreiber nicht geprüft | A | Nutzung der Application Passwords (Spam-Posting, Credential-Tests) |
| Gekaperte Cloud- und Privatserver (DigitalOcean, OVH, Oracle Cloud, Contabo, webgo, Korea Telecom, Viettel, Chinanet, Indonesien, Brasilien) | Botnet | C | SSH-Wörterbuch, von einer Stelle aus gesteuert |
B.9 Zusammenhänge und die Frage nach dem Anstieg
Beim Abgleich der Server fallen mehrere Dinge auf. Die Netze 45.148.10.0/24, 62.60.130.0/24 und 91.92.0.0/16 tauchen jeweils auf zwei oder drei Servern auf, jeweils mit anderer Methode: SSH auf C, Batch-Scans auf A, SQL-Injection auf B, Brute-Force auf A. Hinter den Kampagnen stehen offenbar wenige Betreiber mit mehreren Werkzeugen.
Die Phasen ähneln sich. Auf eine Aufklärung (Zertifikatslisten, Author-Enumeration) folgt der Test gestohlener oder erratener Zugangsdaten (Basic Auth, XML-RPC), dann ein Exploit-Versuch (SQL-Injection, Upload-Tests), danach der Zugang für später (Application Passwords, Backdoor, Rogue-Admins) und schließlich die Verwertung (Spam, Affiliate, Wallet-Suche).
Zum Anstieg: Die SSH-Welle auf Server C vom 26. September lässt sich erklären. Sie hat einen scharfen Beginn, synchrone Versuche mit demselben Namen und ein eigenes Wörterbuch, das nach Krypto-Werten und Cloud-Konten fragt. Das spricht für eine einzige Steuerstelle. Der Anstieg der Secrets-Bans auf Server A (473 bis zum 29. September, 1.979 bis zum 7. Oktober, also eine Vervierfachung in acht Tagen) hat dagegen keinen einzelnen Auslöser, den ich nennen könnte. Wahrscheinlich laufen mehrere Scanner-Kampagnen parallel und neue Wortlisten kommen dazu.
Teil C: Detailanalyse, wie sie auf dem Shop-Server lief
Dieser Teil zeigt eine Analyse von Anfang bis Ende am Beispiel von Server B (Shop) und Server B2 (Dev). Er ist als Vorlage gedacht, nicht als Ereignisbericht.
C.1 Anlass
Am 8. Oktober fielen dem Shop-Betreiber Kontaktformular-Einsendungen mit dem Absender pentest@example.com und seltsame Kommentare auf. Die Aufgabe: herausfinden, ob das zusammengehört, was die Beteiligten getan haben und ob jemand eingebrochen ist.
C.2 Vorgehen
- Eine gebündelte, nur lesende SSH-Verbindung pro Server. So bleibt der Eingriff klein und die Auswertung reproduzierbar.
- Access-Log, Error-Log, auth.log und fail2ban-Status zeitlich auf Sekunden ausrichten und nach der ersten Auffälligkeit rückwärts suchen.
- Die Kampagne über User-Agents, Zeitfenster und IP-Muster abgrenzen, bevor man sie bewertet.
- Mit der Datenbank abgleichen: neue Konten, Bestellungen, Kommentare, Formulareinsendungen. Bei Passwort-Resets den Zeitstempel in
user_activation_keymit den Zugriffslogs auf drei Sekunden genau zuordnen. - Die Infrastruktur über Whois, die ASN-Abfrage von Team Cymru, öffentliche Missbrauchsdatenbanken und die offizielle Tor-Exit-Liste bestimmen. Dorthin gehen nur Angreifer-IPs.
- Vor jeder Sperre die Fehlalarme messen: Treffen die Filter eigene Admin-IPs, bekannte Crawler, Zahlungsanbieter oder echte Käufer?
- Beweise sichern (Logs, Datenbankzeilen, Prüfsummen), dann erst aufräumen, mit Sicherung und Rückweg.
- Am nächsten Morgen noch einmal von vorn auswerten. Erst dann sieht man, ob die Maßnahmen wirken und wohin der Angreifer ausweicht.
C.3 Identifikation der Kampagne
Die Kampagne begann am 8. Oktober um 00:01 Uhr Ortszeit und lief noch um 15:09 Uhr. Die User-Agents nannten sich NumaPentest/1.0 und <Shopname>Pentest/1.0 (der Name des Shops steckte also im User-Agent), dazu kamen curl/8.5.0, die Standardversion von Ubuntu 24.04, und ein generisches Mozilla/5.0. Rund 5.300 Requests kamen von etwa 511 wechselnden IPs, meist mit einem oder zwei Requests je Adresse. Einzelne IP-Sperren greifen bei so etwas kaum, ein rotierender Proxy-Pool sorgt für Nachschub. Warum eine gesperrte Adresse den Angreifer selten aufhält, steht in meinem Artikel Warum IP-Adressen sperren dir nichts bringt.
C.4 Ablauf in Phasen
| Zeit (Ortszeit) | Aktion | Ergebnis |
|---|---|---|
| 00:01 bis 00:09 | Fingerprinting, ?author=1, wp-json/users, SSRF über oEmbed (127.0.0.1, 169.254.169.254) | Blockiert (403, 400, 401) |
| 00:43 bis 00:56 | Plugin-Inventar per readme.txt, Suche nach .env, .git, wp-config.*, *.sql | .env, .git und .bak mit 444 gekappt, der Rest 404 |
| 00:56 bis 01:12 | Brute-Force auf admin-ajax.php-Actions, etwa 150 POSTs | Einige öffentliche Actions antworten mit 200 |
| 00:59 bis 01:02 | Upload-Test an ein Produkt-Add-on-Plugin, etwa 140 Versuche | Alle 400, Upload-Ordner sauber |
| 01:14 bis 01:27 und 10:32 | Einsendungen an ein Kontaktformular | Vom Spam-Filter erkannt |
| 09:55 bis 12:45 | Vier Kundenkonten (pentest…, pentest2…, rce<Ziffern>) | Nur Rolle Kunde |
| 11:15 und 12:45 | Zwei Bestellungen über je 10,99 Euro | Auf „wartend“ gesetzt |
| 11:13 bis 15:09 | Einsendungen an drei Formulare | Teils nicht als Spam erkannt |
| 12:20 bis 13:30 | Passwort-Reset-Läufe | Folgen später geklärt (C.10) |
| 14:11 bis 14:13 | Zwei Kommentare und eine Bewertung per REST (201) | Ausstehend, nicht veröffentlicht |
Die Kommentare trugen Autorennamen wie „Test“ und „Anna“ und stammten alle aus 104.245.240.x. Die Mailadresse des dritten Kommentars (ein Wegwerfdienst) verband ihn mit dem Konto rce….
C.5 Der behauptete Pentest
In den Bestellhinweisen stand sinngemäß „authorized pentest, cancel if processed“. Das ist eine unbelegte Selbstauskunft und richtet sich an Mitarbeitende, die die Bestellung öffnen. Der Betreiber bestätigte, dass kein Test beauftragt war. Behandelt wurde die Kampagne daher als Angriff. Die Befunde in Reihenfolge der Dringlichkeit:
- Rechnungs- und Buchhaltungs-Archive waren per direkter URL abrufbar. Der
.htaccess-Schutz greift unter nginx nicht, geschützt waren die Ordner nur durch zufällige Namen. Die Angreifer riefen sie nicht ab. xmlrpc.phpwar offen, Brute-Force auf echte Admin-Namen lief, auch aus den Kampagnen-Netzen. Erfolgreiche Logins gab es nicht.- Die Kundenregistrierung war offen und ungebremst: vier Konten und zwei Bestellungen in wenigen Stunden.
- Versionspreisgabe über
readme.html,license.txtund diereadme.txtder Plugins, eine direkt abrufbare Cache-Konfigurationsdatei und ein Plugin, das Eingaben einer öffentlichen AJAX-Action nicht validiert.
C.6 Erste Gegenmaßnahmen und eine Nebenwirkung
Zuerst kamen die Beweise in ein Verzeichnis auf dem Server, mit Prüfsummen. Dann die Aufräumarbeiten: Die zwei Bestellungen wurden storniert, drei Kommentare gelöscht, später vier Konten, nachdem ich jedes auf Login-Muster, Rolle und Anlagedatum geprüft hatte. In nginx wurden readme.html, license.txt und Plugin-Readmes zu 404, xmlrpc.php zu 403, dazu der Autoren-Pfad, die Benutzer-Sitemap, wp-config*, PHP in uploads und die Cache-Konfiguration. Rechnungen, Gutscheine und Logs sind nicht mehr direkt abrufbar, die Buchhaltungsexporte nur noch für feste IPs freigegeben.
Dann die IP-Bereiche der Kampagne, für alle Ports, auf Netzebene. Ein Skript prüfte vorher, dass keine Admin-IP und nicht meine SSH-Quelle in den Bereichen liegt. Auf Server B wurden in den ersten 20 Sekunden schon 40 Pakete verworfen. Die Kampagne lief also noch.
Eine Nebenwirkung ist erwähnenswert. Beim Übertragen der fail2ban-Konfiguration hat fail2ban-client reload auf Server B die Aktionen der Jails verloren. Für etwa fünf Minuten wurden keine Sperren durchgesetzt, bevor ich es bemerkte. Ein Neustart des Dienstes hat es behoben. Seitdem prüfe ich nach jedem Reload mit fail2ban-client get <jail> actions, ob alle Jails ihre Aktion haben, und starte lieber neu.
C.7 Der nächste Morgen: die Kampagne weicht aus
Die zweite Auswertung am 9. Oktober zeigt, was gewirkt hat und was nicht. Die Firewall-Sperre verwarf über Nacht etwa 9.400 Pakete aus den gesperrten Netzen, nginx sah null Zugriffe von dort. Die Härtung hielt: 942 XML-RPC-Versuche an einem Tag, alle mit 403, die Archive blieben zu. Kern, Prüfsummen und Dateien blieben sauber.
Der Angreifer war allerdings ausgewichen. Zwischen 18 und 22 Uhr kamen 84 IPs mit 744 Requests aus anderen Präfixen desselben Anbieters, mit demselben curl/8.5.0. Danach wurde es ruhig, und in der Nacht kam Tor ins Spiel (C.10). Auf Server B2 testete dasselbe Werkzeugmuster (ein Benutzername mit dem Wort pentestshell) über 37.114.63.5 den Upload des Produkt-Add-on-Plugins. Alle Versuche liefen auf 400.
C.8 Die Dev-Instanz und das Basic-Auth-Passwort
Auf Server B2 sind zwei Tor-IPs (192.42.116.110 und 192.121.44.26) mit dem gültigen Basic-Auth-Benutzer dev durch die Authentifizierung gekommen und haben 54 Mal POST /wp-login.php versucht. Alle blieben ohne Erfolg (Status 200 statt 302). Weitere Versuche liefen mit admin, test und root. Die Instanz enthielt aber eine Kopie der Shop-Daten.
Dass der Angreifer den Basic-Auth-Benutzer und das Passwort kannte, heißt: Das Passwort war kompromittiert, vermutlich aus einer alten Weitergabe oder einer Konfigurationsdatei. Gelernt: Basic Auth schützt nicht gegen jemanden, der den Benutzernamen kennt, und ein geteiltes Passwort für ein ganzes Team hält nie lange. Neues Zufallspasswort, nur auf dem Server abgelegt (nicht im Chat), beide Tor-IPs dauerhaft gesperrt.
C.9 Verdacht auf Kontoübernahme
Auf Server B sah ein Kundenkonto übernommen aus. Seit dem Vorabend gab es 16 erfolgreiche Logins von 193.24.123.94, etwa alle 30 Minuten, mit Aufrufen von /mein-konto/, /wp-admin/profile.php und xmlrpc.php. Ein Bot-Muster. Es gab keine Datenänderung (last_update von 2022), aber Adresse und Bestellhistorie waren einsehbar. Maßnahmen: alle Sessions beenden, das Passwort durch einen verworfenen Zufallswert ersetzen, die Bot-IP dauerhaft sperren. Den Kunden informieren muss der Verantwortliche. Ob eine Meldepflicht nach DSGVO besteht, ist seine Bewertung und nicht die der Technik.
C.10 Reset-Stürme über Tor
In der Nacht zum 9. Oktober wurden 31 Passwort-Resets ausgelöst, normal sind fünf bis dreizehn pro Tag. Sie liefen zwischen 00:01 und 04:22 Uhr über Tor-Exits und trafen unter anderem drei Admin-Konten. Die Zuordnung zu den IPs war auf drei Sekunden genau möglich, weil user_activation_key den Zeitpunkt in der Datenbank trägt und die Zugriffslogs denselben Moment zeigen. Zwei Resets kamen von derselben Tor-IP im Abstand von 23 Sekunden, ein dritter von einer anderen über das WooCommerce-Formular mit curl/8.5.0.
Zwei Fragen waren wichtig. Konnte jemand die Reset-Links abfangen? Nein: siteurl und home stehen fest in Datenbank und wp-config, ein gefälschter Host oder X-Forwarded-Host taucht in keinem Link auf, Reset-Poisoning ist damit ausgeschlossen. Und hat jemand einen Reset abgeschlossen? Nein, nur der Angreifer selbst am Vorabend für sein eigenes Konto. Die Schlüssel der drei Admin-Konten waren unbenutzt.
Die Werkzeugfamilie war dieselbe. Eine Tor-IP aus 109.70.100.x hatte am Vorabend Upload-Tests (shellphp) gemacht, 185.220.101.129 zählte Zahlungsseiten durch (/kasse/order-pay/915354 und die folgenden Nummern). Die neuen Angreifer-Konten tragen die Namen pents…, <shop>reset… und hostinj… mit einer Wegwerf-Domain. Das heißt: Hier wurde der Reset-Ablauf und eine Host-Header-Injection getestet. Von 27 IPs, die seit dem Vortag einen Reset ausgelöst hatten, waren 15 über die Netzsperre schon erfasst (ihre Resets liefen vorher), eine gebannt und elf nicht geblockt. Diese elf waren alle Tor-Exits.
C.11 Zweite Runde: Blocklisten, Tor, Rate-Limit
Eine einzelne Tor-IP zu sperren bringt nichts, weil die Exits wechseln. Server B2 hatte schon Blocklisten, aktualisiert alle sechs Stunden: FireHOL level1 und level2 sowie blocklist.de. Von 400 getesteten Tor-Exits deckten sie nur 5 bis 29 ab. Ich habe die offizielle Exit-Liste des Tor Project ergänzt (1.219 Einträge). Die Liste wird nur übernommen, wenn sie mindestens 200 Einträge hat, ein kaputter Abruf leert sie also nicht. Eine Allowlist hat Vorrang vor allen Sperren und enthält die eigenen IPs sowie die offiziellen Crawler-Bereiche von Bing und Google. Dasselbe Skript und derselbe Sechs-Stunden-Takt laufen jetzt auch auf Server B.
Vor dem Scharfschalten habe ich Fehlalarme gemessen. Von 436 Käufer-IPs traf keine einzige level1 oder blocklist.de. Die wenigen Tor-Treffer waren Bots, die Bestellseiten abrufen. PayPal, Klarna und Stripe waren nicht betroffen. Von den heutigen Besucher-IPs stehen je nach Liste 3 bis 8 Prozent drin (level1 7,6, level2 3,9, blocklist.de 2,4, Tor 3,9 Prozent). Auffällig: blocklist.de führt echte Bingbot-Adressen. Ohne die Crawler-Allowlist wäre die Bing-Indexierung betroffen gewesen.
Dazu kommt ein Rate-Limit für Passwort-Reset-Anforderungen: ungefähr eine pro zehn Sekunden insgesamt, kurz sechs am Stück, danach 429. Im Test gingen sechs durch, dann kam 429, die Reset-Seite und der normale Login blieben unberührt, nach 25 Sekunden war wieder frei. Normalbetrieb (fünf bis dreizehn Resets pro Tag) wird nicht berührt, die Sweeps mit 30 bis 50 pro Stunde schon. Zuletzt habe ich die offenen Reset-Schlüssel gelöscht und die drei Angreifer-Konten entfernt.
Teil D: Gegenmaßnahmen
D.1 fail2ban: Jails und Einstellungen
Alle Sperren laufen über fail2ban. Die Tabelle zeigt die Jails, die auf mindestens einem der Server aktiv sind.
| Jail | Zweck | Schwelle | Sperre |
|---|---|---|---|
| sshd | SSH-Fehlversuche | 5 in 37 Minuten | wächst bei jedem Rückfall bis 4 Wochen |
| nginx-credential-harvest | Abgriffe nach .env, .git, .aws und ähnlichem | 1 Treffer in einer Stunde | dauerhaft |
| nginx-wp-dir-scan | 403 auf WordPress-Kernverzeichnisse | 8 in 60 Sekunden | dauerhaft |
| wordpress-sqli | POST an den REST-Batch-Endpunkt | 3 in 30 Sekunden | dauerhaft |
| nginx-botsearch, php-url-fopen | 404-Scans auf Skriptnamen, Missbrauch von allow_url_fopen | 2 bzw. 5 | Standard, wächst |
| wordpress-login, wordpress-xmlrpc, wordpress-user-enum, wordpress-scan | Eigene Filter gegen Brute-Force, XML-RPC, Enumeration und Plugin-Scans | zwischen 2 und 5 in 1 bis 5 Minuten | 24 Stunden, XML-RPC 7 Tage, wächst |
| wordpress-hard, -soft, -extra | Syslog-Einträge des Plugins wp-fail2ban | 3 in 3 Stunden | 24 Stunden, wächst |
| recidive | Wiederholungstäter | 3 Sperren in 24 Stunden | 7 Tage |
Die globalen Einstellungen, die den größten Unterschied gemacht haben:
# jail.local, Abschnitt [DEFAULT]
ignoreip = 127.0.0.1/8 ::1 <eigene feste IPs>
banaction = %(banaction_allports)s
findtime = 37m
maxretry = 5
bantime.increment = true
bantime.maxtime = 4w
# fail2ban.local
[Definition]
dbpurgeage = 200d
Dazu ein paar Erfahrungen, die nicht in den Handbüchern stehen.
Der Standardwert von dbpurgeage ist ein Tag. Auf Server A standen Sperren von bis zu 30 Tagen, die ein Neustart nach 24 Stunden vergessen hätte. Mit 200 Tagen überleben sie, und bantime.increment zählt frühere Sperren mit, statt jeden Rückfall als ersten zu behandeln.
Mit banaction_allports sperrt ein Web-Ban auch SSH. Wer von einer neuen, nicht eingetragenen IP dreimal das falsche Passwort tippt, sitzt draußen. Die eigenen festen IPs gehören deshalb in ignoreip. Eine nur vorübergehend genutzte IP (Hotel, Handy) trage ich nicht dauerhaft ein.
Die Filter gehören vor dem Scharfschalten gegen die aktuellen Logs getestet, mit fail2ban-regex und einer Auswertung der Treffer-IPs. Beim Batch-Endpunkt habe ich zusätzlich geprüft, ob legitime Editor-Sitzungen betroffen wären: Von 1.831 IPs hatte keine einzige gewhitelistete IP den Endpunkt je benutzt. Alle erfolgreichen Zugriffe stammten aus Scanner-Netzen. Bei Seiten mit vielen externen Redakteuren, die den Site-Editor nutzen, würde ich die Schwelle weicher setzen.
D.2 Die drei Filter im Wortlaut
Die Muster sind absichtlich eng gehalten. Sie sperren Dinge, die auf einer normalen Seite nie vorkommen.
# filter.d/nginx-credential-harvest.conf
[Definition]
failregex = ^<HOST> .*"(GET|POST) /[^"]*(\.(env|aws|git|azure|ssh|svn)|firebase-config\.json|google-services\.json|claude_desktop_config|_profiler/phpinfo|debug/default/view|localhost\.json|wp-config\.php\.bak)[^"]*" \d{3} .*$
ignoreregex =
# filter.d/nginx-wp-dir-scan.conf
[Definition]
failregex = ^<HOST> .* "GET /(wp-includes|wp-admin|wp-content/cache|wp-content/plugins|\.git|\.svn)/[^"]*" 403 .*$
ignoreregex =
# filter.d/wordpress-sqli.conf
[Definition]
failregex = ^<HOST> .*"POST /(wp-json/batch/v1|index\.php/wp-json/batch/v1|(\?|index\.php\?)rest_route=/batch/v1)[^"]*" \d{3} .*$
ignoreregex =
Der Filter für wp-dir-scan setzt voraus, dass nginx die Kernverzeichnisse mit 403 abschirmt. Wer nur 404 liefert, muss das Muster anpassen. Die erste Version des SQL-Filters hing an den SQL-Fehlern im Error-Log (FastCGI sent in stderr: "PHP message: WordPress database error.+SQL syntax), das griff erst nach dem Fehler. Die jetzige Version zählt schon den Request.
D.3 Netzebene: Sperrlisten und Blocklisten
Fünf Bausteine tragen die Netzebene. Eine Liste mit Netzbereichen und ein systemd-Dienst, der sie nach einem Neustart wieder einliest. Ein ipset-Satz pro Blockliste (FireHOL level1 und level2, blocklist.de, Tor-Exit-Liste), alle sechs Stunden aktualisiert. Eine Allowlist mit Vorrang, die eigene IPs und die offiziellen Bing- und Google-Crawler enthält. Eine Plausibilitätsprüfung, die Listen mit weniger als 200 Einträgen verwirft. Und eine Messung vor dem Scharfschalten, siehe C.11.
Warum IP-Sperren allein wenig bringen, habe ich an anderer Stelle beschrieben. Netzbereiche eines ganzen Anbieters zu sperren, trifft auch VPN-Nutzer. Ich habe bewusst zuerst nur die beobachteten Bereiche gesperrt und erst später das komplette AS213954 (21 Einträge), nachdem klar war, dass der Angreifer innerhalb des Anbieters ausweicht. Tor und die Listen decken nur IPv4 ab.
D.4 nginx-Härtung
Ein Regelwerk direkt am Webserver fängt die dümmsten Angriffe ab, bevor PHP sie überhaupt sieht. Das Prinzip steht in Die Web-Firewall für Arme. Hier die Regeln, die sich auf diesen Servern bewährt haben:
xmlrpc.phpliefert 403, wenn kein Dienst darauf angewiesen ist.readme.html,license.txtund die Readmes der Plugins liefern 404.- Der Autorenpfad
/author/und die Benutzer-Sitemap liefern 404, das schließt die Enumeration über den Login. wp-config*, PHP-Dateien inuploadsund/wp-includes/*.phpauf erster Ebene liefern 404.- Rechnungen, Buchhaltungsexporte und Logs sind nicht direkt abrufbar, sonst nur für feste IPs.
- Backend-Ports gehören geschlossen oder an
127.0.0.1gebunden, auch bei Docker, das ufw umgeht. - Ein Prefix-Location wie
location ^~ /verhindert, dass nginx die Regex-Sperren für.gitoder.envüberhaupt auswertet. Auf Server C stellte das ein Test von localhost nach. WP_DEBUG_LOGgehört außerhalb des Webroots. Scanner fragen nach/wp-content/debug.log.
D.5 WordPress-Härtung
- Application Passwords abschalten, wenn kein Dienst sie braucht. Sonst regelmäßig in der Tabelle usermeta nachsehen.
DISALLOW_FILE_EDITsetzen.wp core verify-checksumsundwp plugin verify-checksums --allregelmäßig laufen lassen. Premium-Plugins, die nicht auf wordpress.org liegen, bleiben ungeprüft und brauchen einen eigenen Hash-Vergleich.mu-plugins,advanced-cache.phpundobject-cache.phpgetrennt und streng überwachen. Beide ändern sich im Normalbetrieb kaum.- Ungenutzte Plugins löschen. Auf Server A waren sieben Plugins auf keinem einzigen Blog aktiv.
- Eindeutige Salts und eindeutige Datenbankpasswörter pro Installation.
- Registrierung bremsen, Wegwerf-Mailadressen sperren oder ein Captcha davor setzen, wenn Kundenkonten nötig sind.
- Ein PHP-FPM-Pool pro Vhost mit eigenem Benutzer. Die wichtigste strukturelle Maßnahme, die bei mir noch offen ist.
D.6 Logging, das sich später auswerten lässt
Das alte access.log im Standardformat blieb unverändert, weil die fail2ban-Filter daran hängen. Daneben läuft ein zweites Log im JSON-Format mit Host, Client-IP, einer Cloudflare-IP und X-Forwarded-For, Benutzername, Methode, URI, Status, Bytes, Laufzeit, Referer und User-Agent. Das Host-Feld hätte auf Server A bei der about.php-Frage in einer Minute geklärt, was ich in einem Nachmittag erarbeitet habe. Das Benutzername-Feld hat die Application Passwords verraten.
Die Aufbewahrung der nginx-Logs liegt jetzt bei 180 statt 52 Tagen, die von auth.log und syslog bei 26 Wochen statt 4. 180 Tage hätten den Einstieg vom 12. Juni noch abgedeckt. Weil IP-Adressen personenbezogene Daten sind, sollte man die Frist im Verarbeitungsverzeichnis und in der Datenschutzerklärung begründen.
Indikatoren (IOC)
Stand 9. Oktober 2026. Netze von Hostern oder Tor zu sperren, kann legitime Nutzer treffen. Jede Sperre braucht eine eigene Abwägung und einen Weg zurück.
Dateien und Marker
wp-content/mu-plugins/forge-librarian-tag.phpmit Plugin-Header „Ridge Migrator Live“ (erfundener Autor, Plugin-URL auf eine fremde Domain), zusätzlich als Pluginforge-librarian-tag/forge-librarian-tag.phpinactive_plugins- Versteckte Dateien und Ordner mit Hex-Suffix:
.sc_<8 Hex>/mitcore_<8 Hex>.phpundown_<8 Hex>.php,.kk_<8 Hex>,.gk_…,.gl_…,.gm_…,.gp_…,.gs_…,.gt_…,.gv_…,.bt_…,.rd_…,.sd_…,.swm_…, dazu eine.gk_inwp-admin - Marker im Code:
SC_CORE_BOOT_VER,SC_TH_BEGINundSC_TH_END(infunctions.phpvon Themes),SC_ADV_BEGIN(inadvanced-cache.php),SCOCV:(inobject-cache.php), Funktionen_sc_ul(,_sc_padf(,_sc_mkdir(. Versionsstring 4.5.3. - Kleine Stub-Dateien wie
f15ef53e.php(sechs Byte) undthb_*.bak - Eine Datei
about.phpim Backup-Ordner des Migrations-Plugins (wp-content/ai1wm-backups/) - Die Cache-Drop-ins
advanced-cache.phpundobject-cache.php, wenn sie nicht zu einem installierten Cache-Plugin gehören oder deutlich größer sind als das Original
Konten und Zugangsdaten
- Admin-Konten mit dem Muster
admin_,administrator_oderbackup_plus Hex-Zeichen und einer Mailadresse auf der Blog-Domain - Application Passwords mit den Namen
sentinel,WP Admin Bot,auto-bootstrap - Kundenkonten mit den Namen
pentest…,rce<Ziffern>,pents…,<shop>reset…,hostinj…, Wegwerf-Domains wie guerrillamail.com und maxxspace.com, Reply-Topentest@example.com
User-Agents und Payloads
NumaPentest/1.0,<Shopname>Pentest/1.0,curl/8.5.0in Verbindung mit rotierenden IPs,libredtail-http, ein gefälschter Firefox 148 unter Windows für das Spam-Posting,WordPressPostChecker/1.0- SQL:
wp2shell_probe,0x776f726470726573733078,UNION ALL SELECT 99999999,CONCAT(0x7c7c,HEX(CAST((SELECT 0x4f4b)AS CHAR)),0x7c7c),SLEEP(1.2) - Pfade:
/setSystemCommand,/hello.world?%ADd+allow_url_include%3d1+%ADd+auto_prepend_file%3dphp://input,/cgi-bin/%%32%65%%32%65/…/bin/sh,/..%c0%afproc/self/cmdline - Spam-Ziel: eine Wegwerf-Domain auf
.top, hierw3n7z2qt[.]top
IP-Adressen und Netze
| Rolle | Adressen |
|---|---|
| Nutzung der Application Passwords | 107.158.19.26 (12.06.), 107.158.93.143, 204.77.129.12, 204.77.129.175 (Juli), 151.247.123.36 (September), 204.3.206.87 (01.10., abgewiesen) |
| Logins mit Rogue-Admins | 82.40.119.184, 66.67.167.152 |
| wp-login-Brute-Force | 91.92.40.172 |
| SQL-Injection, RCE-Sonden | 152.233.20.43, 62.60.130.230, 89.19.46.146, 185.244.213.59, 209.222.101.129, 45.156.87.213 |
| Kontoübernahme-Bot | 193.24.123.94 |
| Kampagne vom 8. Oktober | AS213954 (157.22.x.x in fünf /22, 104.245.240.0/22, 172.83.252.0/22), 159.253.248.0/24, 91.92.241.0/24 |
| Tor-Exits (Beispiele) | 192.42.116.45, 185.220.101.129, 193.189.100.197, 109.70.100.13 |
| Dauerscanner | 45.148.10.0/24, 213.209.159.0/24, 195.178.110.0/24, 93.123.109.0/24, 62.60.130.0/24 |
Checkliste zum Mitnehmen
- Logs mindestens 180 Tage aufheben, mit Host-Feld und Benutzername. Ohne sie lässt sich ein Einstieg nicht rekonstruieren.
- Dateizeitstempel nie als Beweis nehmen. Epochen mit
date -d @…umrechnen und Zeitzonen (Ortszeit gegen UTC) ausdrücklich prüfen. - Prüfsummen sehen nur Dateien. Datenbank, Benutzermeta, Optionen und Cron-Einträge gehören in jede Bereinigung.
- Application Passwords auf jeder Installation auflisten und abschalten, wenn sie niemand braucht.
- Im Access-Log nach Zeilen mit Benutzername im dritten Feld suchen. Das sind Basic-Auth-Zugriffe.
- mu-plugins, advanced-cache.php und object-cache.php regelmäßig prüfen.
- Jeder Vhost bekommt einen eigenen PHP-Benutzer. Sonst gilt ein Einbruch für alle.
dbpurgeagehochsetzen,bantime.incrementeinschalten, eigene feste IPs whitelisten.- fail2ban nach einem Reload prüfen: Haben alle Jails ihre Aktion? Im Zweifel neu starten.
- Neue Filter erst gegen die echten Logs testen, einschließlich Fehlalarm-Messung für eigene Admins, Crawler und Zahlungsanbieter.
- Tor-Exits an Login, Reset, Registrierung und XML-RPC sperren. Die Exit-Liste täglich aktualisieren.
- Nach jeder Maßnahme am nächsten Morgen noch einmal auswerten. Der Angreifer reagiert, und das sieht man erst dann.
Grenzen dieser Auswertung
Der Einstieg auf Server A ist nicht belegt, weil die Logs von Juni fehlten. Ob die ersten Spuren vom 12. Juni wirklich der Anfang waren, weiß ich deshalb nicht.
Die Zuordnung der Netze zu Betreibern beruht auf Whois, ASN-Abfragen und öffentlichen Missbrauchsdatenbanken. Dass dieselben Netze auf mehreren Servern auftauchen, zeigt Zusammenhang, aber keine Identität der Täter. Aussagen wie „ein Betreiber“ sind Vermutungen. Das gilt auch für den Verdacht, dass ein Cache-Drop-in WordPress-Abfragen verfälscht hat. Er passt zu den Beobachtungen, mehr kann ich dazu nicht sagen.
Alle Zahlen stammen aus Logs, die nur eine Seite des Geschehens zeigen. 200 in einem Request heißt nur, dass der Server antwortet. Ein Treffer in einem Filter ist keine Ausnutzung, und kein Treffer ist kein Beweis, dass nichts passiert ist.
Zwischen dem 12. Juni und dem 8. September hat niemand auf Server A auf Benutzerlisten, Application Passwords oder die Plugin-Ordner geschaut, und als ich nach dem Einstieg suchte, waren die Logs dazu schon weg. Wie lange hebt ihr eure auf?