WordPress absichern: Scanner-Anfragen mit Status 410 abfangen
Wer eine WordPress-Seite betreibt, kennt das Muster aus den Server-Logs:
Anfragen auf wp-config.php.bak, auf .env, auf vendor/phpunit, auf
shell.php. Automatisierte Scanner arbeiten Listen bekannter Schwachstellen
ab, rund um die Uhr, bei jeder Domain, unabhängig davon, wie klein oder
unbekannt sie ist.
Verhindern lässt sich das nicht. Was sich ändern lässt, ist die Antwort.
Das eigentliche Ärgernis ist die Antwortgröße
Fragt ein Scanner einen Pfad an, den es nicht gibt, liefert WordPress seine 404-Seite aus. Und die ist keine kleine Textzeile, sondern eine vollständig gerenderte Seite mit Theme, Stylesheets und Navigation. Auf meinen WordPress-Seiten liegen diese Fehlerseiten zwischen 44 und 76 Kilobyte.
Bei ein paar tausend Anfragen am Tag summiert sich das zu Datenmengen, die niemandem nützen. Schlimmer noch: Jede dieser Anfragen bedeutet PHP-Ausführung, Datenbankzugriffe und Rechenzeit für einen Besucher, der kein Besucher ist.
Die Lösung passt in wenige Zeilen
mod_rewrite kennt das Flag [G] für „Gone". Damit antwortet Apache mit
HTTP-Status-Code 410 (Gone) und einer minimalen Standardseite von rund
282 Byte, ohne dass PHP überhaupt anläuft. Statt 76 Kilobyte gehen 282 Byte
raus, das ist ein Faktor von bis zu 270.
Der Unterschied zu einem 404 ist dabei nicht nur die Größe: 404 heißt „nicht gefunden, vielleicht später wieder", 410 heißt „endgültig weg". Genau die Aussage will man Scannern gegenüber treffen.
Der Block gehört vor die WordPress-Regeln in der .htaccess:
<IfModule mod_rewrite.c>
RewriteEngine On
# Sensitive Dotfiles, greift an der Wurzel wie in Subpfaden,
# deckt auch URL-kodierte Punkte ab
RewriteCond %{THE_REQUEST} \s(/[^\s/]*)*/(\.|%2[eE])(env|env\.[a-z]+|git|aws|svn|htaccess|htpasswd)(/|%2[fF]|\s|$) [NC]
RewriteRule .* - [G,L]
# wp-config.php in allen Varianten (.bak, .old, .save, .txt)
RewriteCond %{THE_REQUEST} \s/wp-config[^\s]*\s [NC]
RewriteRule .* - [G,L]
# Installer-Pfade und Webshells
RewriteRule ^(installer|setup|install|configuration)\.php$ - [G,L,NC]
RewriteRule (^|/)(adminfuns|wso|shell|cmd|c99|r57|eval-stdin)\.php$ - [G,L,NC]
# Vendor- und PHPUnit-Vektoren
RewriteRule (^|/)vendor/ - [G,L,NC]
# Probes, die gar nicht zu WordPress gehören
RewriteRule ^_(rsc|next)(/|$) - [G,L,NC]
RewriteRule (^|/)(phpinfo|info|test)\.php$ - [G,L,NC]
</IfModule>
Die Reihenfolge ist entscheidend. Steht der Block hinter dem
WordPress-Abschnitt, hat dessen Catch-all die Anfrage längst an index.php
weitergereicht, und PHP läuft doch.
Dasselbe unter nginx
Ein guter Teil der WordPress-Installationen läuft nicht auf Apache. Unter nginx
gibt es keine .htaccess, die Regeln gehören in den Server-Block, und statt
[G] schreibt man return 410:
location ~* ^/(installer|setup|install|configuration)\.php$ { return 410; }
location ~* /(adminfuns|wso|shell|cmd|c99|r57|eval-stdin)\.php$ { return 410; }
location ~* ^/wp-config { return 410; }
location ~ /\.(env|git|aws|svn|htaccess|htpasswd) { return 410; }
location ~ /vendor/ { return 410; }
location ~ ^/_(rsc|next)/ { return 410; }
Zu beachten ist die Auswertungsreihenfolge: nginx nimmt bei regulären Ausdrücken
die erste passende location in der Reihenfolge der Datei, nicht die
spezifischste. Diese Blöcke gehören deshalb vor den allgemeinen
location ~ \.php$, der sonst alles an PHP-FPM durchreicht.
fail2ban macht daraus eine Sperre
Der HTTP-Status-Code 410 allein reduziert die Last. Interessanter wird es, wenn man ihn als Signal verwendet: Wer reihenweise 410er produziert, ist mit Sicherheit kein Besucher. Ein eigener Jail wertet genau diesen Statuscode im Zugriffsprotokoll aus und sperrt Adressen, die es zu bunt treiben.
Der Filter braucht nur eine Zeile. Er liest die Adresse am Zeilenanfang und
prüft, ob die Antwort ein 410er war, /etc/fail2ban/filter.d/apache-410.conf:
[Definition]
failregex = ^<HOST> .* "[A-Z]+ [^"]+ HTTP/[\d.]+" 410 \d+
ignoreregex =
Dazu der Jail, /etc/fail2ban/jail.d/apache-410.local:
[apache-410]
enabled = true
port = http,https
filter = apache-410
logpath = %(apache_access_log)s
maxretry = 5
findtime = 120
bantime = 7200
%(apache_access_log)s ist die Voreinstellung der Distribution. Wer mehrere
Seiten auf einer Maschine betreibt, kann dort auch ein Muster eintragen, das
alle Zugriffsprotokolle einsammelt, dann gilt eine Sperre für alle Seiten
gleichzeitig. Das ist wirksamer und hat einen Haken, siehe unten.
Fünf 410er in zwei Minuten, dann zwei Stunden Pause. Die Werte sind bewusst großzügig: Ein Scanner arbeitet seine Liste in Sekunden ab und überschreitet die Schwelle sofort, während ein Mensch, der auf ein paar tote Links klickt, gar nicht erst in die Nähe kommt.
Das ist der Punkt, an dem aus Rauschunterdrückung eine Abwehr wird. Die Scanner, die ohnehin nur Listen abarbeiten, verlieren das Ziel.
Zwei Fallstricke, die ich mir eingefangen habe
Der Block stammt aus der WordPress-Welt. Wer ihn in eine andere Anwendung
übernimmt, sollte vorher prüfen, welche der geblockten Pfade diese Anwendung
selbst braucht. Zwei Kollisionen sind typisch: Eine Regel auf ^api schießt in
einer modernen Anwendung die eigenen API-Routen ab, und eine Regel auf ^admin
erwischt jedes Admin-Panel, das dort wohnt. Beide Regeln sind in einem
WordPress sinnvoll, weil das seine Oberfläche unter /wp-admin und seine
Schnittstelle unter /wp-json hat.
Suchmaschinen-Crawler gehören freigestellt. Das ist kein Randfall, sondern im Aufbau angelegt: Der 410er ist genau die Antwort, die Googlebot sehen soll, denn sie nimmt die URL aus dem Index. Er ruft alte Pfade also völlig legitim ab, sammelt dabei fünf 410er in zwei Minuten und landet im Jail. Überwacht der Jail mehrere Seiten gemeinsam, crawlt er danach keine davon mehr.
Die Freistellung gehört deshalb von Anfang an dazu, in die ignoreip des
Jails, und will regelmäßig aufgefrischt werden, weil sich die Adressbereiche
ändern. Google veröffentlicht sie maschinenlesbar unter
developers.google.com/search/apis/ipranges/googlebot.json.
Ein Fallstrick dabei, der teuer werden kann: *.bc.googleusercontent.com ist
nicht Googlebot. Das sind gemietete Cloud-Server, die jeder buchen kann, und
in Sperrlisten stecken davon regelmäßig welche, die tatsächlich scannen. Wer
seine Whitelist über ein Namensmuster wie „enthält google" baut, schaltet
diese Scanner mit frei. Verlässlich geht es nur über die offiziell
veröffentlichten Adressbereiche.
Wirkt es? Nachmessen im Zugriffsprotokoll
Nach ein paar Tagen lohnt der Blick ins Log. Wichtig ist dabei, nur den
angefragten Pfad zu zählen und nicht die ganze Zeile: Sucht man den Begriff
mit grep im gesamten Datensatz, findet man ihn auch im Referrer und im
User-Agent, und die Zahlen fallen um ein Vielfaches zu hoch aus. Der folgende
Einzeiler nimmt sich nur das Request-Feld vor und zählt die Antwortcodes:
zcat -f access.log* | awk '{
for (i = 1; i <= NF; i++)
if ($i ~ /^"(GET|POST|HEAD)$/) {
pfad = $(i+1); status = $(i+3)
if (pfad ~ /wp-login|wp-admin|xmlrpc/) zaehler[status]++
break
}
} END { for (s in zaehler) print s, zaehler[s] }'
Interessant ist weniger die Gesamtzahl als die Verteilung. Solange dort viele
200 stehen, beantwortet die Seite die Anfragen noch. Wandern die Zahlen zu
410, greift der Block.
Nicht nur ein WordPress-Thema
Ein Blick auf die Regeln oben zeigt, dass die Hälfte davon mit WordPress nichts
zu tun hat. .env, vendor/, eval-stdin.php: Das sind Pfade aus der
Symfony- und Composer-Welt, und die Scanner probieren sie überall durch, weil
sie nicht wissen, was auf der Domain läuft.
Bei einem Shop wiegt genau das schwerer als bei einem Blog. Eine
.env-Datei, die ausgeliefert wird, enthält dort Datenbank-Zugangsdaten und
Schlüssel für Zahlungsanbieter, und vendor/ hat in der Vergangenheit mehr als
einmal einen Weg zur Codeausführung geboten. Der Block gehört deshalb genauso
vor eine Shopware- oder Laravel-Installation, nur mit einer wichtigen Anpassung:
Die Regeln auf ^api und ^admin müssen raus, weil moderne Anwendungen genau
diese Pfade selbst brauchen.
Für Shop-Projekte mache ich solche Arbeiten beruflich, das läuft unter com:werft. Hier auf der eigenen Seite ist es Hausputz, dort gehört es zur Wartung.
Was das nicht löst
Der Block macht die Seite leiser, nicht sicher. wp-login.php bleibt
erreichbar, und das muss es auch, sonst kommt man selbst nicht mehr hinein.
Genau dort liegt aber der eigentliche Angriffspfad: Jeder Anmeldeversuch
bekommt weiterhin ein funktionierendes Formular ausgeliefert.
Wer auch das noch zumachen will, hat zwei bewährte Möglichkeiten. Die erste ist eine vorgelagerte Abfrage, die den Server beantwortet, bevor WordPress überhaupt startet:
<Files "wp-login.php">
AuthType Basic
AuthName "Anmeldung"
AuthUserFile /pfad/zu/.htpasswd
Require valid-user
</Files>
Das kostet einen zusätzlichen Anmeldeschritt und ist unbequem, wenn mehrere
Leute an der Seite arbeiten. Die zweite Möglichkeit ist eine Beschränkung auf
bekannte Adressen (Require ip …), die aber nur taugt, wenn man eine feste
Adresse hat. Beides schützt nicht vor Lücken in Plugins, sondern nur vor dem
Durchprobieren von Passwörtern.
Was bleibt, sind die üblichen Pflichten: aktueller Kern, kurze Plugin-Liste, starke Passwörter, Zwei-Faktor-Anmeldung. Der Probe-Block nimmt einem davon nichts ab. Er räumt nur den Lärm weg, damit man im Log wieder sieht, was tatsächlich passiert.
Auch interessant
Zwei WordPress-Seiten an einem Nachmittag abgelöst
mageconsult.de und magelounge.de liefen seit Jahren auf WordPress, ohne dass dort noch etwas passierte. An einem Nachmittag im August habe ich beide durch statische Seiten ersetzt, mit Claude Code als Werkzeug. Was dabei schnell ging, was nicht, und warum die Code-Menge nicht der Engpass war.