Entwicklung

WordPress absichern: Scanner-Anfragen mit Status 410 abfangen

~7 Min. Lesezeit von Ralf Siepker
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.

Balkendiagramm der Antwortgrößen: 76 Kilobyte für commercewerft.de, 53 für magelounge.de, 44 für mageconsult.de, dagegen 0,28 Kilobyte für eine Antwort mit HTTP-Status-Code 410

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.

wordpress sicherheit htaccess fail2ban apache

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.

09. August 2026 4 Min.