WordPress-DDoS-Schutz: warum das Plugin zu spät kommt
Wenn ein Plugin die Anfrage sieht, ist sie bereits durch nginx gelaufen und hat einen PHP-FPM-Arbeitsprozess belegt. Die echte Obergrenze ist pm.max_children, und deshalb legt ein Layer-7-Angriff mit 2,4 Mbit/s eine WordPress-Seite lahm.
Wenn ein WordPress-Plugin eine Anfrage sieht, ist es für die Abwehr eines DDoS-Angriffs bereits zu spät. Die Anfrage hat zu diesem Zeitpunkt den Webserver passiert, einen PHP-FPM-Arbeitsprozess belegt und WordPress vollständig gestartet. Das Plugin entscheidet danach, ob es die Anfrage abweist, und diese Entscheidung kostet genau so viel wie eine echte Seitenauslieferung. Die harte Obergrenze eines WordPress-Servers heißt deshalb nicht Firewall-Regel, sondern pm.max_children, und dieser Wert ist auf jedem Server dreistellig oder kleiner.
Daraus folgt die Zahl, die den ganzen Artikel trägt: Ein Layer-7-Angriff mit einigen hundert Anfragen pro Sekunde auf /?s=zufallswort legt eine WordPress-Seite lahm, während die Leitung zu 99 Prozent leer bleibt. 500 Anfragen pro Sekunde mit je rund 600 Byte sind 2,4 Mbit/s. Auf einem Anschluss mit 1 Gbit/s ist das ein Vierhundertstel der verfügbaren Bandbreite. Genau deshalb gehen Betreiber offline, obwohl ein DDoS-Plugin aktiv ist und der Traffic-Graph im Kundenbereich unauffällig aussieht.
Dieser Beitrag zeigt, wie ein Layer-7-Angriff auf WordPress wirklich abläuft, wie Sie ihn von Bruteforce auf wp-login.php und von einem echten Besucheransturm unterscheiden, welche Endpunkte einer WordPress-Installation teuer sind, wie Sie den Angriff an der PHP-FPM-Statusseite und am Zugriffsprotokoll festmachen und welche Konfiguration auf dem Server tatsächlich wirkt. Danach folgt der ehrliche Teil: wo diese Maßnahmen aufhören, was ein vorgeschalteter Dienst wie Cloudflare oder Sucuri leistet und auf welchen fünf Wegen Ihre Ursprungs-IP trotzdem bekannt wird. Wenn Ihre Seite gerade steht, springen Sie zum Abschnitt über den laufenden Angriff und messen Sie zuerst, statt zu schrauben.
Warum ein WordPress-Plugin einen DDoS-Angriff nicht aufhalten kann
Ein Sicherheits-Plugin ist PHP-Code. PHP-Code läuft in einem PHP-Prozess. Ein PHP-Prozess wird von PHP-FPM bereitgestellt, und PHP-FPM hält davon eine feste, begrenzte Zahl vor. Diese Kette ist nicht umgehbar, und sie erklärt das ganze Problem in drei Sätzen.
Der Weg einer Anfrage, und an welcher Stelle das Plugin sitzt
Eine Anfrage an eine WordPress-Seite durchläuft sieben Stationen. Jede kostet etwas, und die Kosten steigen mit jeder Station um etwa eine Zehnerpotenz.
- Der Kernel nimmt das TCP-Paket an. Kosten: Mikrosekunden. Begrenzt durch die Paketrate der Netzwerkkarte und die Größe der Accept-Warteschlange.
- nginx nimmt die Verbindung an und verhandelt TLS. Kosten: eine asymmetrische Rechenoperation je neuer Sitzung, danach nur noch symmetrische Verschlüsselung. Begrenzt durch
worker_connectionsund die CPU. - nginx prüft seine eigenen Regeln.
limit_req,limit_conn,deny, ein Treffer im Seitencache. Kosten: einige Mikrosekunden. Eine hier abgewiesene Anfrage kostet praktisch nichts. - nginx gibt die Anfrage an PHP-FPM weiter. Ab hier ist ein Arbeitsprozess belegt, und zwar für die gesamte Dauer der Anfrage.
- PHP startet. Bei aktiviertem OPcache entfällt das Kompilieren, das Aufbauen der Laufzeitumgebung bleibt.
- WordPress bootet.
wp-load.php,wp-settings.php, die Datenbankverbindung, die Options-Tabelle, alle aktiven Plugins, das Theme. Auf einer gewöhnlichen Installation sind das 40 bis 120 Millisekunden, bevor die erste eigene Zeile Code läuft. - Das Sicherheits-Plugin bewertet die Anfrage und verwirft sie gegebenenfalls.
Station 7 ist die erste, an der ein Plugin überhaupt etwas tun kann, und sie liegt hinter der teuersten Station. Eine Anfrage, die ein Plugin abweist, hat denselben Arbeitsprozess belegt wie eine Anfrage, die eine Seite ausliefert. Für den Angreifer ist der Unterschied bedeutungslos, denn sein Ziel ist nicht der Inhalt der Antwort, sondern der belegte Arbeitsprozess.
Es gibt eine Ausnahme, und sie gehört zur Vollständigkeit dazu: Wordfence kann sich im erweiterten Schutzmodus über die PHP-Direktive auto_prepend_file vor WordPress schalten. Sein Regelwerk läuft dann bei Station 5 statt bei Station 7, also vor dem Bootvorgang. Das spart die 40 bis 120 Millisekunden für WordPress selbst und ist ein echter Unterschied gegenüber einem gewöhnlichen Plugin. Der Arbeitsprozess ist trotzdem belegt, weil PHP bereits läuft. Der Deckel bleibt also derselbe, er sitzt nur etwas höher.
Die eigentliche Obergrenze: pm.max_children
pm.max_children ist die maximale Anzahl gleichzeitig laufender PHP-Arbeitsprozesse eines FPM-Pools. Jeder Prozess bearbeitet genau eine Anfrage. Der maximale Durchsatz eines WordPress-Servers in Anfragen pro Sekunde ist damit eine Division, keine Meinung:
Anfragen pro Sekunde = pm.max_children / mittlere Antwortzeit in Sekunden
Der Wert von pm.max_children ergibt sich wiederum aus dem Arbeitsspeicher, weil jeder Arbeitsprozess eine WordPress-Installation im Speicher hält. Bei 80 Megabyte je Prozess und 4 Gigabyte nutzbarem Arbeitsspeicher sind 40 Prozesse das Maximum, bevor der Server zu swappen beginnt. Die folgende Tabelle rechnet das für typische Konstellationen durch.
| Arbeitsspeicher für PHP | pm.max_children bei 80 MB je Prozess | Mittlere Antwortzeit | Maximale Anfragen pro Sekunde | Bandbreite des Angriffs bei 600 Byte je Anfrage |
|---|---|---|---|---|
| 1 GB | 12 | 150 ms (Cache-Treffer in PHP) | 80 | 0,38 Mbit/s |
| 2 GB | 25 | 250 ms (gewöhnliche Seite) | 100 | 0,48 Mbit/s |
| 4 GB | 50 | 250 ms | 200 | 0,96 Mbit/s |
| 4 GB | 50 | 900 ms (Suche ohne Cache) | 55 | 0,26 Mbit/s |
| 8 GB | 100 | 250 ms | 400 | 1,92 Mbit/s |
| 8 GB | 100 | 1.200 ms (WooCommerce-Kasse) | 83 | 0,40 Mbit/s |
| 16 GB | 200 | 250 ms | 800 | 3,84 Mbit/s |
Die letzte Spalte ist die eigentliche Nachricht. Selbst ein gut ausgestatteter Server mit 16 Gigabyte Arbeitsspeicher ist bei 3,84 Mbit/s Angriffsverkehr am Ende seiner PHP-Kapazität. Das ist weniger, als ein einzelner Haushaltsanschluss aufbringt, und es ist 0,38 Prozent eines Anschlusses mit 1 Gbit/s. Kein Traffic-Alarm dieser Welt schlägt bei diesem Wert an. Wie Sie pm.max_children für Ihren Server sauber berechnen, statt zu raten, steht in PHP-FPM pm.max_children richtig berechnen.
Ein zweiter Effekt verschärft das Bild. Sobald alle Arbeitsprozesse belegt sind, stellt PHP-FPM weitere Anfragen in die Listen-Warteschlange. Die Antwortzeit steigt für alle, also auch für echte Besucher, und mit der Antwortzeit steigt die Belegdauer je Prozess. Der Durchsatz sinkt damit genau in dem Moment, in dem er gebraucht wird. Wer dagegen pm.max_children einfach hochdreht, bekommt nicht mehr Durchsatz, sondern den OOM-Killer, der sich üblicherweise den MySQL-Prozess holt.
Warum ein Plugin trotzdem sinnvoll ist
Die Aussage dieses Abschnitts ist nicht, dass Sicherheits-Plugins nutzlos wären. Sie lösen eine andere Aufgabe, und zwar gut: Erkennung bekannter Schwachstellen in Themes und Plugins, Prüfung auf veränderte Dateien, Blockieren einzelner bösartiger Muster, Zwei-Faktor-Anmeldung, Benachrichtigung bei Anmeldungen. Das ist Anwendungssicherheit, und dafür ist die Anwendungsebene der richtige Ort. Ein DDoS-Angriff ist dagegen ein Kapazitätsproblem, und Kapazitätsprobleme löst man immer an der Stelle, an der die Kapazität endlich ist. Die Grundlagen dazu stehen in Was ist ein DDoS-Angriff.
Wie ein Layer-7-Angriff auf WordPress aussieht
Ein Layer-7-Angriff ist ein Angriff auf der Anwendungsebene: Er besteht aus vollständig gültigen HTTP-Anfragen über vollständig aufgebaute TCP-Verbindungen. Es gibt in den Paketen selbst nichts Verdächtiges. Verdächtig ist nur die Menge und die Auswahl der Ziele.
Der typische Ablauf gegen WordPress hat vier Merkmale. Erstens werden Pfade gewählt, die nicht aus einem Cache beantwortet werden können, also vor allem Anfragen mit wechselnden Parametern. Zweitens wird die Antwort oft gar nicht gelesen, sondern die Verbindung nach dem Absenden geschlossen, was im Protokoll als Statuscode 499 erscheint. Drittens kommen die Anfragen aus einigen hundert bis einigen zehntausend verschiedenen Adressen, häufig aus Wohnanschlüssen, weil deren Adressen nicht pauschal gesperrt werden können. Viertens tragen sie plausible Kopfzeilen: ein aktueller Browser-Kennzeichner, passende Accept-Language-Werte, manchmal sogar ein gültiger Referrer.
Der Unterschied zu Bruteforce auf wp-login.php
Bruteforce auf wp-login.php verfolgt ein anderes Ziel und verhält sich deshalb anders. Der Angreifer will hinein, nicht überlasten. Er bleibt deswegen bewusst unter der Schwelle, die eine Sperre auslöst, und er verteilt seine Versuche über tausende Seiten gleichzeitig. Wordfence blockiert nach eigener Angabe über 6,4 Milliarden solcher Anmeldeversuche pro Monat über sein gesamtes Netz. Das ist der Grundrauschpegel des Internets, kein Angriff auf Sie persönlich.
Technisch erkennen Sie Bruteforce an einer klaren Signatur: POST /wp-login.php, Statuscode 200 (WordPress antwortet auf einen fehlgeschlagenen Anmeldeversuch mit 200 und einer Fehlermeldung im HTML, nicht mit 401), leerer oder auf die Anmeldeseite selbst zeigender Referrer, und ein Wechsel entweder der Benutzernamen oder der Passwörter bei gleichbleibendem Rest.
Die Kostenseite ist dabei weniger eindeutig, als oft angenommen wird. Seit WordPress 6.8 (April 2025) werden Passwörter mit bcrypt statt mit dem alten, MD5-basierten phpass-Verfahren geprüft. bcrypt ist absichtlich rechenintensiv, eine Prüfung kostet in der Standardeinstellung mehrere zehn Millisekunden CPU-Zeit. Ein Anmeldeversuch gegen einen existierenden Benutzernamen ist damit deutlich teurer geworden. Ein Versuch gegen einen nicht existierenden Benutzernamen kostet dagegen fast nichts, weil WordPress bereits an der Benutzerabfrage abbricht und gar nicht erst hasht. Bruteforce gegen den Benutzernamen admin ist also gleichzeitig ein Lastangriff, Bruteforce mit Wörterbuchnamen nicht.
Der Unterschied zu einem echten Besucheransturm
Ein echter Ansturm nach einer Erwähnung in einem großen Portal, einem Newsletter oder einem viralen Beitrag sieht auf den ersten Blick identisch aus: die Anfragen steigen, die Antwortzeit steigt, die Seite wird langsam. Sechs Merkmale trennen die beiden Fälle zuverlässig.
| Merkmal | Layer-7-Angriff | Bruteforce auf wp-login.php | Echter Besucheransturm |
|---|---|---|---|
| Anstiegsform | innerhalb von Sekunden auf Vollgas, danach konstant | tagelang gleichbleibend niedrig | Anstieg über Minuten, Abklingen über Stunden |
| Verhältnis HTML zu Bildern und Skripten | fast nur HTML, kaum statische Dateien | nur die Anmeldeseite | je Seitenaufruf 20 bis 80 statische Dateien |
| Referrer | leer oder immer derselbe Wert | leer oder die Anmeldeseite selbst | viele verschiedene, echte Herkunftsseiten |
| Cache-Status im Protokoll | durchgehend MISS oder BYPASS | BYPASS (Anmeldeseite ist nie cachebar) | überwiegend HIT nach den ersten Sekunden |
| Statuscodes | auffälliger Anteil 499, dazu 502 und 504 | fast ausschließlich 200 | 200 und 304, kaum 499 |
| Angefragte Pfade | wenige Pfade mit vielen verschiedenen Parametern | genau ein Pfad | breite Streuung über den echten Seitenbestand |
Das aussagekräftigste Einzelmerkmal ist das Verhältnis von HTML-Anfragen zu Anfragen nach statischen Dateien. Ein echter Browser lädt zu jeder HTML-Seite die Stylesheets, Schriften, Skripte und Bilder nach, in der Regel 20 bis 80 Einzelanfragen. Ein Angriffswerkzeug lädt nur das HTML, weil nur das HTML einen PHP-Prozess kostet. Kippt dieses Verhältnis innerhalb weniger Sekunden von 1 zu 40 auf 1 zu 1, haben Sie Ihre Antwort. Die vollständige Messanleitung für den allgemeinen Fall steht in DDoS-Angriff erkennen.
Die teuren Endpunkte einer WordPress-Installation
Nicht jede WordPress-URL kostet gleich viel. Ein Angreifer, der die Installation kennt, wählt gezielt die Pfade, die weder vom Seitencache noch vom Objektcache bedient werden können. Diese Pfade sind bei jeder WordPress-Installation dieselben, weil sie zum Kern gehören.
Die Suche: /?s=zufallswort
Die WordPress-Suche ist der teuerste Endpunkt einer Standardinstallation, und zwar aus zwei Gründen gleichzeitig. Erstens erzeugt jeder Suchbegriff einen neuen Cache-Schlüssel. Ein Angreifer, der bei jeder Anfrage eine andere Zeichenfolge anhängt, umgeht damit jeden Seitencache und jeden Objektcache, ohne eine einzige Regel zu verletzen. Zweitens setzt WordPress die Suche als LIKE-Abfrage mit führendem Platzhalter um, sinngemäß:
SELECT wp_posts.ID FROM wp_posts
WHERE ((wp_posts.post_title LIKE '%suchwort%')
OR (wp_posts.post_excerpt LIKE '%suchwort%')
OR (wp_posts.post_content LIKE '%suchwort%'))
AND wp_posts.post_status = 'publish'
ORDER BY wp_posts.post_date DESC
Ein LIKE mit führendem Prozentzeichen kann keinen Index benutzen. MySQL liest die Tabelle wp_posts vollständig und vergleicht in jedem Datensatz drei Textspalten, darunter post_content, die auf einer gewachsenen Seite mehrere Megabyte umfassen kann. Auf einer Installation mit 5.000 Beiträgen dauert eine solche Abfrage regelmäßig 300 bis 900 Millisekunden. Zehn gleichzeitige Suchanfragen genügen, um die Datenbank zum Flaschenhals zu machen, und die Datenbank bremst dann auch alle Seiten aus, die mit der Suche nichts zu tun haben.
admin-ajax.php und die Heartbeat-Schnittstelle
/wp-admin/admin-ajax.php ist der zentrale AJAX-Verteiler von WordPress und aus zwei Gründen ein beliebtes Ziel. Er liegt zwar unter /wp-admin/, ist aber für nicht angemeldete Besucher erreichbar, weil Themes und Plugins ihre Aktionen über wp_ajax_nopriv_* registrieren. Und er ist über nocache_headers() ausdrücklich von jeder Zwischenspeicherung ausgenommen, denn seine Antworten sollen aktuell sein.
Dazu kommt die Heartbeat-Schnittstelle, die im Beitragseditor alle 15 Sekunden und auf den übrigen Verwaltungsseiten alle 60 Sekunden eine Anfrage an admin-ajax.php sendet. Sie ist deshalb schon im Normalbetrieb der meistgefragte dynamische Pfad einer aktiven Redaktion. Ein Angreifer braucht keine gültige Aktion: Ein Aufruf ohne oder mit unbekanntem action-Parameter bootet WordPress trotzdem vollständig und antwortet mit einer Null und dem Statuscode 400. Der Arbeitsprozess war die ganze Zeit belegt.
xmlrpc.php: Verstärker und Rechenlast in einem
xmlrpc.php ist seit WordPress 3.5 ohne Schalter in der Oberfläche dauerhaft aktiv. Zwei Methoden machen die Datei relevant.
pingback.ping weist Ihren Server an, eine beliebige fremde URL abzurufen und darin nach einem Rückverweis zu suchen. Damit wird Ihre Installation zum Werkzeug gegen Dritte. Im März 2014 dokumentierte Sucuri einen Layer-7-Angriff, der über 162.000 unversehrte, regulär betriebene WordPress-Seiten gefahren wurde: Jede dieser Seiten hielt sich an die Spezifikation und rief brav die ihr genannte Adresse ab. Für den Betreiber einer solchen Seite bedeutet das ausgehenden Verkehr, belegte Arbeitsprozesse und im ungünstigen Fall einen Eintrag auf einer Sperrliste.
system.multicall bündelt beliebig viele Einzelaufrufe in einer HTTP-Anfrage. In Verbindung mit wp.getUsersBlogs lassen sich damit hunderte Passwortversuche in einem einzigen POST unterbringen. Jede Zählung, die auf HTTP-Anfragen je Adresse beruht, sieht davon genau eine Anfrage. Das ist der Grund, warum Sperrlisten für wp-login.php allein nichts nützen, solange xmlrpc.php offen steht.
Ein verbreiteter Irrtum gehört hierher: Der Filter xmlrpc_enabled schaltet ausschließlich die Methoden ab, die eine Anmeldung verlangen. pingback.ping verlangt keine Anmeldung und läuft danach unverändert weiter. Wer den Pingback wirklich schließen will, muss entweder die Methode entfernen oder die Datei auf Webserverebene zumachen.
wp-cron.php: der Zeitplaner, den jeder auslösen kann
WordPress bringt keinen echten Zeitplaner mit. Stattdessen prüft es am Ende eines Seitenaufrufs, ob eine geplante Aufgabe fällig ist, und stößt dafür per Schleifenanfrage einen zweiten Aufruf von wp-cron.php an. Eine einfache Sperre über WP_CRON_LOCK_TIMEOUT verhindert, dass das öfter als einmal in 60 Sekunden geschieht.
Zwei Eigenschaften machen daraus ein Angriffsziel. Die Datei ist öffentlich erreichbar, jeder kann sie aufrufen. Und jeder Aufruf bootet WordPress vollständig, prüft die Aufgabenliste und führt fällige Aufgaben aus, darunter potenziell Update-Prüfungen, Feed-Abrufe und Mailversand. Ein Angreifer, der wp-cron.php parallel aufruft, erzeugt mit wenig Aufwand viel Arbeit und provoziert zusätzlich ausgehende Verbindungen Ihres Servers.
WooCommerce: Warenkorb, Kasse und Fragmente
Ein Shop verschiebt die Grenze deutlich nach unten, weil WooCommerce große Teile der Seite bewusst von der Zwischenspeicherung ausnimmt. Vier Punkte sind dabei entscheidend.
?wc-ajax=get_refreshed_fragmentswird bei bestehendem Warenkorb-Keks auf jeder einzelnen Seite nachgeladen, damit der Minikorb aktuell bleibt. Diese Anfrage ist nie cachebar und kostet einen vollen WordPress-Start.- Die Sitzungs-Kekse
woocommerce_items_in_cartundwp_woocommerce_session_*schalten den Seitencache für den betreffenden Besucher ab. Ein Angreifer, der diese Kekse einfach mitschickt, hebt Ihren Cache für sich selbst auf, ohne dass irgendetwas ungültig aussieht. ?add-to-cart=123erzeugt eine Sitzung und damit einen Schreibvorgang in die Datenbank. Schreibende Anfragen sind die unangenehmste Sorte, weil sie sich nicht auf Leseknoten verteilen lassen.- Produktfilter mit mehreren Parametern erzeugen eine praktisch unbegrenzte Menge an Cache-Schlüsseln. Jede Kombination ist ein neuer MISS.
Die Endpunkte im Überblick
| Pfad | Was der Server dafür tut | Aus dem Cache bedienbar | Wirksame Maßnahme |
|---|---|---|---|
/?s=begriff |
voller WordPress-Start, Tabellendurchlauf in MySQL | nein, jeder Begriff ist ein neuer Schlüssel | eigene Ratenbegrenzung, 1 bis 2 Anfragen pro Minute je Adresse |
/wp-admin/admin-ajax.php |
voller WordPress-Start, auch ohne gültige Aktion | nein, ausdrücklich ausgenommen | Ratenbegrenzung, Aktionen des Frontends namentlich zulassen |
/xmlrpc.php |
voller Start, Pingback ruft fremde URLs ab | nein | schließen, oder nur Pingback entfernen |
/wp-cron.php |
voller Start plus fällige Aufgaben plus ausgehende Verbindungen | nein | abschalten und als echten Cron führen |
/wp-login.php |
voller Start, bei bekanntem Benutzernamen eine bcrypt-Prüfung | nein | IP-Einschränkung oder Webserver-Authentifizierung davor |
/wp-json/wp/v2/... |
voller Start, Datenbankabfragen je Ressource | selten, meist ausgenommen | Ratenbegrenzung, Benutzerliste sperren |
/?wc-ajax=get_refreshed_fragments |
voller Start plus Sitzungszugriff | nein | Ratenbegrenzung, Fragmente im Theme abschalten |
/warenkorb/, /kasse/, /mein-konto/ |
voller Start, Sitzung, Schreibvorgänge | nein, durch Kekse ausgenommen | Ratenbegrenzung, gleichzeitige Verbindungen begrenzen |
/beitrag/ ohne Parameter |
bei Cache-Treffer nur ein Dateizugriff durch nginx | ja | Vollseiten-Cache, dadurch praktisch unbegrenzt |
Woran Sie den Angriff festmachen
Bevor Sie etwas ändern, brauchen Sie drei Zahlen: wie viele Anfragen pro Sekunde ankommen, auf welche Pfade sie gehen und aus wie vielen verschiedenen Adressen sie kommen. Alles Weitere folgt daraus.
Die PHP-FPM-Statusseite
Die Statusseite von PHP-FPM ist das genaueste Messgerät für diesen Fall, weil sie genau die Ressource zeigt, die zu Ende geht. Sie wird in der Pool-Konfiguration aktiviert, üblicherweise /etc/php/8.3/fpm/pool.d/www.conf:
pm.status_path = /fpm-status
request_slowlog_timeout = 5s
slowlog = /var/log/php8.3-fpm-slow.log
Dazu ein nginx-Block, der ausschließlich lokal erreichbar ist:
location = /fpm-status {
access_log off;
allow 127.0.0.1;
deny all;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
systemctl reload php8.3-fpm nginx
watch -n 1 "curl -s http://127.0.0.1/fpm-status"
Vier Werte entscheiden. listen queue zeigt die Anfragen, die gerade auf einen freien Arbeitsprozess warten. Im Normalbetrieb steht dort dauerhaft 0. Jeder andauernde Wert darüber bedeutet, dass Anfragen nicht langsam bearbeitet werden, sondern überhaupt nicht. max listen queue ist der Höchststand seit dem Start und bleibt stehen, auch wenn der Angriff vorbei ist. max children reached zählt, wie oft der Pool an seine Obergrenze gestoßen ist; ein steigender Wert ist der Beweis, dass pm.max_children der begrenzende Faktor ist. slow requests zählt Anfragen jenseits von request_slowlog_timeout, und die zugehörige Datei enthält den vollständigen Aufrufstapel, also die genaue Stelle, an der PHP hängt.
Mit curl -s "http://127.0.0.1/fpm-status?full" sehen Sie zusätzlich je Prozess, welche URL er gerade bearbeitet. Wenn dort dreißig Prozesse gleichzeitig /index.php?s=... mit verschiedenen Begriffen stehen haben, ist die Diagnose abgeschlossen.
Das Zugriffsprotokoll auswerten
Erweitern Sie zunächst das Protokollformat, denn das Standardformat verschweigt die beiden wichtigsten Werte: die Antwortzeit und den Cache-Status.
log_format kh '$remote_addr $status $request_time $upstream_response_time '
'$upstream_cache_status "$request" "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log kh;
Die folgenden Auswertungen laufen ohne zusätzliche Werkzeuge. Sie beziehen sich auf das nginx-Standardformat combined, bei dem Feld 1 die Adresse, Feld 4 der Zeitstempel, Feld 7 die URL und Feld 9 der Statuscode ist.
tail -n 200000 /var/log/nginx/access.log | awk '{print substr($4,2,20)}' | uniq -c | sort -rn | head -20
tail -n 200000 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
tail -n 200000 /var/log/nginx/access.log | awk '{print $1, substr($4,2,20)}' | sort | uniq -c | sort -rn | head -20
tail -n 200000 /var/log/nginx/access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -20
tail -n 200000 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c | sort -rn
tail -n 200000 /var/log/nginx/access.log | awk '{print $1}' | sort -u | wc -l
Die dritte Zeile ist die wichtigste: Sie zählt Anfragen je Adresse und Sekunde. Steht dort ein zweistelliger Wert, haben Sie einen einzelnen Fluter und sperren ihn. Stehen dort dreitausend Zeilen mit jeweils 1 oder 2, haben Sie einen verteilten Angriff, und jede Sperrliste ist der falsche Weg. Die letzte Zeile liefert die Zahl der beteiligten Adressen und entscheidet damit über die gesamte Strategie.
Für das Verhältnis von HTML zu statischen Dateien genügt ein Einzeiler:
tail -n 200000 /var/log/nginx/access.log \
| awk '{ if ($7 ~ /\.(css|js|png|jpg|jpeg|webp|svg|woff2?|ico)(\?|$)/) s++; else d++ } END { print "statisch:", s, " dynamisch:", d }'
Statuscode 499, 502 und 504 richtig lesen
499 ist kein Standardcode, sondern eine Eigenheit von nginx und bedeutet: Der Client hat die Verbindung geschlossen, bevor nginx antworten konnte. Zwei völlig verschiedene Ursachen erzeugen ihn. Ein Angriffswerkzeug, das die Antwort nicht braucht und sofort auflegt, und ein echter Besucher, dem die Seite zu langsam war und der abgebrochen hat. Ein hoher Anteil 499 allein ist deshalb kein Beweis. Beweiskraft entsteht aus der Kombination: viele 499 bei gleichzeitig hohem $request_time und sehr vielen verschiedenen Quelladressen.
502 heißt bei dieser Konstellation fast immer, dass nginx keine Verbindung zum FPM-Socket mehr aufbauen konnte, weil dessen Warteschlange voll ist. Im Fehlerprotokoll steht dann typischerweise connect() to unix:/run/php/php8.3-fpm.sock failed (11: Resource temporarily unavailable) while connecting to upstream. Die Gegenmaßnahme ist nicht ein höherer listen.backlog, denn eine längere Warteschlange verlängert nur die Wartezeit. Die vollständige Ursachenliste steht in nginx 502 Bad Gateway beheben.
504 bedeutet, dass ein Arbeitsprozess die Anfrage angenommen hat und länger als fastcgi_read_timeout gebraucht hat. Bei einem Angriff auf die Suche ist das der Regelfall, weil die Datenbank der Flaschenhals ist. Details dazu in nginx 504 Gateway Timeout beheben.
Das nginx-Fehlerprotokoll
Drei Zeilen im Fehlerprotokoll sind bei diesem Angriffsbild eindeutig:
[error] 1123#1123: *8421 limiting requests, excess: 5.020 by zone "wp_expensive", client: 203.0.113.7, server: example.com, request: "GET /?s=qwertz HTTP/2.0"
[error] 1123#1123: *8422 connect() to unix:/run/php/php8.3-fpm.sock failed (11: Resource temporarily unavailable) while connecting to upstream
[alert] 1123#1123: 768 worker_connections are not enough
Die erste Zeile ist eine gute Nachricht: Die Ratenbegrenzung greift und nennt Ihnen die Zone, die Adresse und die angefragte URL. Genau auf diese Zeile setzt auch der mitgelieferte fail2ban-Filter auf. Die zweite Zeile bedeutet, dass PHP-FPM keine Verbindungen mehr annimmt. Die dritte Zeile bedeutet, dass nginx selbst am Limit ist, was bei einem reinen Layer-7-Angriff auf WordPress selten vorkommt und meist auf sehr viele offen gehaltene Verbindungen hindeutet.
Was auf dem Server wirklich hilft
Alle folgenden Angaben gelten für Debian 12, Debian 13, Ubuntu 22.04 LTS und Ubuntu 24.04 LTS mit nginx und PHP-FPM und sind für root geschrieben. Die Reihenfolge ist nach Wirkung sortiert, nicht nach Aufwand. Prüfen Sie jede Änderung mit nginx -t, bevor Sie neu laden.
1. Vollseiten-Cache, damit die Flut auf Dateien trifft
Die mit Abstand wirksamste Einzelmaßnahme ist ein Vollseiten-Cache im Webserver. Ein Cache-Treffer wird von nginx aus einer Datei beantwortet, ohne dass PHP überhaupt gefragt wird. Damit verschiebt sich die Obergrenze von pm.max_children (in der Tabelle oben zwischen 12 und 200) auf worker_connections, das bei vier Prozessen mit je 16.384 Verbindungen bei 65.536 liegt. Der Unterschied liegt bei drei Zehnerpotenzen.
Zuerst die Zone und die Regeln, wann nicht zwischengespeichert wird. Das gehört in den http-Block, also nach /etc/nginx/conf.d/wp-cache.conf:
fastcgi_cache_path /var/cache/nginx/wp levels=1:2 keys_zone=WORDPRESS:100m inactive=60m max_size=2g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
map $http_cookie $kh_skip_cookie {
default 0;
"~*wordpress_logged_in_" 1;
"~*wp-postpass_" 1;
"~*comment_author_" 1;
"~*woocommerce_items_in_cart" 1;
"~*wp_woocommerce_session_" 1;
}
map $request_uri $kh_skip_uri {
default 0;
"~*^/wp-admin/" 1;
"~*^/wp-login\.php" 1;
"~*^/wp-json/" 1;
"~*^/(warenkorb|kasse|mein-konto)/" 1;
"~*[?&](s|add-to-cart|wc-ajax)=" 1;
}
Danach der PHP-Block im Server-Abschnitt:
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 301 302 10m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_bypass $kh_skip_cookie $kh_skip_uri;
fastcgi_no_cache $kh_skip_cookie $kh_skip_uri;
fastcgi_cache_lock on;
fastcgi_cache_lock_timeout 5s;
fastcgi_cache_use_stale updating error timeout invalid_header http_500 http_502 http_503 http_504;
fastcgi_cache_background_update on;
add_header X-Cache $upstream_cache_status always;
}
Vier Direktiven sind hier die eigentliche Abwehr, und genau sie fehlen in den meisten Anleitungen.
fastcgi_cache_lock onsorgt dafür, dass bei 500 gleichzeitigen Anfragen auf dieselbe noch nicht zwischengespeicherte Seite nur eine einzige an PHP weitergereicht wird. Ohne diese Zeile gehen alle 500 durch, und genau dieser Ansturm auf einen kalten Cache ist das, was ein Angriff auslöst.fastcgi_cache_use_stale updatingzusammen mitfastcgi_cache_background_update onliefert während der Erneuerung eines Eintrags allen übrigen Besuchern weiterhin die alte Fassung aus der Datei.fastcgi_cache_use_stale ... http_500 http_502 http_503 http_504hält die Seite auch dann erreichbar, wenn PHP-FPM bereits nicht mehr antwortet. Für den Besucher ist eine zehn Minuten alte Seite immer noch eine Seite.add_header X-Cache $upstream_cache_status alwaysmacht die Wirkung messbar. Ohne diese Zeile wissen Sie nicht, ob der Cache greift.
Eine Falle sollten Sie kennen, weil sie in vielen kursierenden Konfigurationen steckt: fastcgi_cache_bypass $arg_nocache; oder $http_pragma in derselben Zeile. Beides lässt sich von außen setzen. Wer ?nocache=1 anhängt oder die Kopfzeile Pragma: no-cache sendet, umgeht damit Ihren gesamten Cache, und zwar völlig regelkonform. Die Bedingung für einen Cache-Umgehung darf ausschließlich aus Werten bestehen, die der Besucher nicht frei wählen kann.
Prüfen Sie danach mit zwei Aufrufen, ob es wirkt:
mkdir -p /var/cache/nginx/wp && chown www-data:www-data /var/cache/nginx/wp
nginx -t && systemctl reload nginx
curl -sI https://example.com/ | grep -i x-cache
curl -sI https://example.com/ | grep -i x-cache
Der erste Aufruf meldet MISS, der zweite HIT. Meldet der zweite ebenfalls MISS, greift eine Ihrer Ausnahmen, und die häufigste Ursache ist ein Keks, den ein Plugin bereits beim ersten Aufruf setzt.
2. Ratenbegrenzung je Quelladresse mit limit_req_zone
limit_req_zone begrenzt Anfragen je Schlüssel nach dem Leaky-Bucket-Verfahren. Als Schlüssel dient $binary_remote_addr, die verkürzte binäre Fassung der Quelladresse. Ein Zustand belegt auf einem 64-Bit-System 128 Byte, eine Zone mit 10 Megabyte fasst damit rund 80.000 verschiedene Adressen. Für limit_conn sind es 64 Byte je Zustand und damit rund 160.000 Adressen bei 10 Megabyte. Diese Zahlen stehen so in der nginx-Dokumentation und sind der Grund, warum eine zu klein gewählte Zone bei einem verteilten Angriff als Erstes überläuft.
limit_req_zone $binary_remote_addr zone=wp_general:20m rate=10r/s;
limit_req_zone $binary_remote_addr zone=wp_login:10m rate=20r/m;
limit_req_zone $kh_expensive_key zone=wp_expensive:20m rate=2r/m;
limit_conn_zone $binary_remote_addr zone=wp_conn:10m;
limit_req_status 429;
limit_conn_status 429;
limit_req_log_level warn;
Die dritte Zone nutzt einen berechneten Schlüssel, und das ist der Kunstgriff, mit dem Sie ausschließlich die teuren Anfragen begrenzen, ohne den Rest der Seite anzufassen. Die nginx-Dokumentation hält fest: Anfragen mit leerem Schlüssel werden nicht gezählt. Damit lässt sich eine Zone bauen, die nur greift, wenn ein bestimmter Parameter vorkommt:
map $args $kh_expensive {
default 0;
"~*(^|&)s=" 1;
"~*(^|&)wc-ajax=" 1;
"~*(^|&)add-to-cart=" 1;
}
map $kh_expensive $kh_expensive_key {
0 "";
1 $binary_remote_addr;
}
Eine gewöhnliche Seitenanfrage erhält damit den leeren Schlüssel und läuft an der Zone vorbei. Eine Suchanfrage erhält die Adresse als Schlüssel und darf zweimal pro Minute je Adresse passieren. Angewandt wird das im Server-Abschnitt:
location / {
limit_req zone=wp_general burst=20 nodelay;
limit_req zone=wp_expensive burst=3 nodelay;
limit_conn wp_conn 20;
try_files $uri $uri/ /index.php?$args;
}
location = /wp-login.php {
limit_req zone=wp_login burst=5 nodelay;
limit_conn wp_conn 5;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
nodelay ist hier bewusst gesetzt. Ohne diesen Schalter verzögert nginx überzählige Anfragen innerhalb des Burst-Fensters, statt sie abzuweisen. Verzögern heißt: Die Verbindung bleibt offen und belegt weiterhin eine nginx-Verbindung. Gegen einen Angriff ist das die falsche Richtung. Der Standardstatus ist übrigens 503; 429 ist die genauere Antwort und teilt Suchmaschinen nicht mit, der Server sei defekt.
Wählen Sie die Werte nicht aus dem Bauch. Messen Sie eine Woche lang, wie viele Anfragen pro Sekunde eine einzelne echte Adresse tatsächlich erzeugt, und setzen Sie das Limit auf das Drei- bis Fünffache. Zu eng gesetzte Limits werfen echte Besucher heraus, und zwar zuerst die aktivsten.
3. Die Falle hinter einem vorgeschalteten Dienst: die echte Adresse
Wenn vor Ihrem Server ein Reverse-Proxy, ein CDN oder ein Loadbalancer steht, sieht nginx nicht die Adresse des Besuchers, sondern die des Proxys. Jede Ratenbegrenzung je $binary_remote_addr drosselt dann den Proxy und damit alle Besucher gleichzeitig, während der Angreifer ungebremst durchläuft. Die Korrektur läuft über das Modul realip:
set_real_ip_from 203.0.113.0/24;
set_real_ip_from 198.51.100.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Entscheidend ist, dass unter set_real_ip_from ausschließlich Netze stehen, denen Sie wirklich vertrauen. Steht dort versehentlich 0.0.0.0/0, kann jeder Absender seine eigene Kopfzeile X-Forwarded-For setzen und damit sowohl die Ratenbegrenzung als auch jede fail2ban-Sperre umgehen. Dieselbe Änderung brauchen Sie außerdem, damit WordPress und Ihre Protokolle überhaupt die richtige Adresse sehen. Wie ein sauberer Vorschaltserver aufgebaut wird, steht in nginx als Reverse Proxy einrichten.
4. xmlrpc.php schließen, ohne Jetpack zu zerlegen
Der günstigste Fall ist, die Datei gar nicht erst an PHP zu geben:
location = /xmlrpc.php {
access_log off;
log_not_found off;
return 444;
}
return 444 ist ein nginx-eigener Sonderwert: Die Verbindung wird ohne jede Antwort geschlossen. Das ist die billigste mögliche Reaktion und erzeugt im Gegensatz zu 403 keine Antwortdaten. Beachten Sie dabei eine nginx-Eigenheit: return wird in der Rewrite-Phase ausgewertet und damit vor allow und deny, die in der Access-Phase laufen. Wer beides in denselben Block schreibt, bekommt immer das return. Eine Ausnahme für einzelne Adressen braucht deshalb eine Fallunterscheidung über eine map, nicht ein zusätzliches allow.
Wenn Sie Jetpack, die WordPress-App oder ein Sicherungs-Plugin einsetzen, das XML-RPC braucht, entfernen Sie stattdessen nur den Pingback. Das gehört in ein kleines Plugin unter wp-content/mu-plugins/, nicht in die functions.php des Themes, sonst ist es nach dem nächsten Theme-Wechsel weg:
<?php
add_filter('xmlrpc_methods', function (array $methods): array {
unset(
$methods['pingback.ping'],
$methods['pingback.extensions.getPingbacks']
);
return $methods;
});
add_filter('xmlrpc_enabled', '__return_false');
Der zweite Filter allein genügt ausdrücklich nicht, weil er nur die anmeldepflichtigen Methoden abschaltet. Erst die Kombination schließt beide Wege.
5. wp-cron abschalten und als echten Zeitplan führen
Zwei Schritte, die zusammengehören. Zuerst in der wp-config.php, oberhalb der Zeile mit dem Hinweis, dass ab hier nichts mehr bearbeitet werden soll:
define('DISABLE_WP_CRON', true);
Danach ein echter Eintrag in der Systemzeitsteuerung, der PHP direkt aufruft und damit weder den Webserver noch eine Netzverbindung braucht:
*/5 * * * * www-data /usr/bin/php8.3 /var/www/example.com/wp-cron.php >/dev/null 2>&1
Und erst jetzt darf der HTTP-Weg zu:
location = /wp-cron.php {
access_log off;
return 444;
}
Die Reihenfolge ist wichtig. Wer den HTTP-Weg schließt, ohne vorher den echten Zeitplan einzurichten, hat innerhalb weniger Tage nicht veröffentlichte geplante Beiträge, ausbleibende Sicherungen und nicht versandte Bestellbestätigungen. Prüfen Sie nach der Umstellung einmal mit wp cron event list --path=/var/www/example.com, ob die Liste abgearbeitet wird.
6. wp-login.php und wp-admin eng führen
Der stärkste Hebel ist auch hier, PHP gar nicht erst zu starten. Wenn Sie von einer festen Adresse aus arbeiten, ist die Sache einfach:
location = /wp-login.php {
allow 203.0.113.10;
deny all;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
Ohne feste Adresse setzen Sie eine Authentifizierung des Webservers davor. Eine abgewiesene Anfrage kostet dann einen Vergleich im Webserver statt eines vollständigen WordPress-Starts mit bcrypt-Prüfung:
apt-get install -y apache2-utils
htpasswd -c /etc/nginx/.htpasswd redaktion
location = /wp-login.php {
auth_basic "Redaktion";
auth_basic_user_file /etc/nginx/.htpasswd;
limit_req zone=wp_login burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ^~ /wp-admin/ {
auth_basic "Redaktion";
auth_basic_user_file /etc/nginx/.htpasswd;
location = /wp-admin/admin-ajax.php {
auth_basic off;
limit_req zone=wp_general burst=10 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
Der verschachtelte Block für admin-ajax.php ist keine Feinheit, sondern Pflicht. Wer /wp-admin/ vollständig sperrt, nimmt damit auch den AJAX-Verteiler aus dem Verkehr, und danach funktionieren Kontaktformulare, Filter, Warenkörbe und unendliches Nachladen im Frontend nicht mehr. Diese Fehlersuche kostet erfahrungsgemäß mehr Zeit als die gesamte übrige Einrichtung.
7. fail2ban auf das Zugriffsprotokoll
fail2ban liest Protokolldateien und setzt Sperren. Es ist damit langsamer als eine nginx-Regel, aber es wirkt länger und auf Paketebene, also bevor nginx überhaupt eine Verbindung annimmt. Der mitgelieferte Filter nginx-limit-req wertet genau die oben gezeigte Zeile aus dem Fehlerprotokoll aus und braucht nur ein Gefängnis in /etc/fail2ban/jail.d/wordpress.conf:
[nginx-limit-req]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
findtime = 300
maxretry = 20
bantime = 3600
banaction = nftables-multiport
Für die teuren Pfade lohnt ein eigener Filter, der direkt im Zugriffsprotokoll ansetzt. Nach /etc/fail2ban/filter.d/kh-wp-flood.conf:
[Definition]
failregex = ^<HOST> .* "(GET|POST|HEAD) /(\?s=|xmlrpc\.php|wp-cron\.php|wp-login\.php)
ignoreregex =
datepattern = \[%%d/%%b/%%Y:%%H:%%M:%%S %%z\]
[kh-wp-flood]
enabled = true
port = http,https
filter = kh-wp-flood
logpath = /var/log/nginx/access.log
findtime = 60
maxretry = 60
bantime = 7200
banaction = nftables-multiport
fail2ban-client reload
fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/kh-wp-flood.conf
fail2ban-client status kh-wp-flood
Zwei ehrliche Einschränkungen gehören dazu. Erstens: banaction gehört auf nftables-multiport oder eine ipset-basierte Variante. Die Voreinstellung mit einer eigenen iptables-Regel je Sperre erzeugt bei 5.000 gesperrten Adressen eine Kette mit 5.000 Einträgen, die für jedes eingehende Paket linear durchlaufen wird. Die Sperrliste wird dann selbst zum Lastproblem. Zweitens: Bei einem verteilten Angriff aus zehntausenden Adressen, von denen jede nur ein bis zwei Anfragen pro Sekunde sendet, greift keine Schwelle, die echte Besucher verschont. fail2ban ist in diesem Fall das falsche Werkzeug, nicht ein falsch eingestelltes. Die Grundeinrichtung mit allen Gefängnissen steht in fail2ban einrichten, das passende Regelwerk davor in UFW-Firewall einrichten.
8. PHP-FPM und die Datenbank passend dimensionieren
Ein sauber eingestellter Pool bricht nicht später, aber kontrollierter. Drei Werte sind dabei relevant:
pm = dynamic
pm.max_children = 50
pm.start_servers = 12
pm.min_spare_servers = 8
pm.max_spare_servers = 20
pm.max_requests = 500
request_terminate_timeout = 30s
listen.backlog = 511
request_terminate_timeout ist hier die eigentliche Schutzfunktion: Sie beendet Anfragen, die länger als 30 Sekunden laufen, und gibt den Arbeitsprozess frei. Ohne diesen Wert bleiben bei einem Angriff auf die Suche Prozesse minutenlang in einer MySQL-Abfrage stehen, und der Pool leert sich nie wieder. Setzen Sie den Wert höher als Ihre längste legitime Anfrage, etwa einen Import, und niedriger als fastcgi_read_timeout in nginx.
Auf der Datenbankseite lohnt derselbe Gedanke. max_connections sollte zu pm.max_children passen, sonst bekommen Sie statt einer Warteschlange eine Fehlerseite. Ein Objektcache über Redis senkt die Kosten je Anfrage deutlich und hebt damit die Obergrenze. Gegen den Angriff auf /?s=zufallswort hilft er allerdings nicht, weil jeder Begriff ein neuer Schlüssel ist. Schlimmer noch: Die nutzlosen Einträge verdrängen die nützlichen, und nach dem Angriff ist der Cache leer. Setzen Sie deshalb maxmemory-policy allkeys-lru und geben Sie dem Objektcache eine eigene Redis-Datenbank, damit Sitzungen und Warenkörbe nicht mit verdrängt werden.
Wo diese Maßnahmen aufhören
Alles bisher Beschriebene läuft auf Ihrem Server, also am Ende der Leitung. Eine nginx-Regel entscheidet über ein Paket, das bereits über das Kabel gelaufen ist. Sie können es verwerfen, aber nicht ungesendet machen. Ist die Leitung davor voll oder die Paketrate höher, als die Netzwerkkarte und der Kernel verarbeiten können, kommen die Pakete Ihrer Besucher gar nicht mehr an.
| Anschluss | Nutzdaten pro Sekunde | Pakete pro Sekunde bei 64 Byte | HTTP-Anfragen pro Sekunde bei 600 Byte |
|---|---|---|---|
| 100 Mbit/s | 12,5 Megabyte | 148.809 | 20.833 |
| 1 Gbit/s | 125 Megabyte | 1.488.095 | 208.333 |
| 10 Gbit/s | 1.250 Megabyte | 14.880.952 | 2.083.333 |
Vergleichen Sie die letzte Spalte mit der Tabelle aus dem ersten Abschnitt. Ein Anschluss mit 1 Gbit/s trägt rechnerisch 208.333 HTTP-Anfragen pro Sekunde, ein WordPress-Server verkraftet zwischen 80 und 800. Der Abstand beträgt Faktor 260 bis 2.600. Für einen Layer-7-Angriff ist die Leitung also nie die Grenze, und genau das ist der Grund, warum der Traffic-Graph unauffällig bleibt.
Daraus folgt die klare Trennung: Läuft Ihre Leitung voll, haben Sie keinen Layer-7-Angriff mehr, sondern einen volumetrischen. Ab diesem Punkt spielt keine einzige Einstellung auf Ihrem Server mehr eine Rolle, auch nicht der beste Cache, weil die Anfrage den Server nicht erreicht. Dasselbe gilt für die Paketrate: Ein gewöhnlicher Serverkernel verarbeitet einige hunderttausend Pakete pro Sekunde, bevor er anfängt zu verwerfen, und verwirft dann ohne Ansehen des Absenders. Was in diesem Fall zu tun ist, steht in Schwerer DDoS-Angriff: was tun.
Eine dritte Grenze wird selten genannt und trifft HTTPS-Seiten besonders. Jede neue TLS-Sitzung kostet eine asymmetrische Rechenoperation. Ein Angriff, der laufend neue Verbindungen aufbaut, statt eine bestehende wiederzuverwenden, erzeugt damit CPU-Last, bevor auch nur eine HTTP-Anfrage gelesen wurde. Ein gemeinsamer Sitzungsspeicher mildert das, weil eine wiederaufgenommene Sitzung ohne den teuren Teil auskommt:
ssl_session_cache shared:SSL:20m;
ssl_session_timeout 1h;
ssl_session_tickets on;
Ein Megabyte dieses Speichers fasst rund 4.000 Sitzungen, 20 Megabyte also etwa 80.000. Gegen einen Angreifer, der bewusst jedes Mal neu verhandelt, hilft das nicht, gegen Ihre echten Besucher unter Last sehr wohl. Wie Sie die Zertifikate dafür kostenfrei und automatisch erneuert bekommen, steht in Kostenloses SSL-Zertifikat mit Let's Encrypt.
WAF und CDN: was sie leisten und wo die Lücke sitzt
Ein vorgeschalteter Dienst wie Cloudflare oder Sucuri arbeitet nach einem anderen Prinzip als alles bisher Beschriebene, und dieses Prinzip funktioniert. Der Dienst nimmt die Verbindung des Besuchers selbst an, beendet dort TLS, prüft die Anfrage und baut erst danach eine eigene Verbindung zu Ihrem Server auf. Eine Anfrage, die dort abgewiesen wird, erreicht Ihren Server nie und kostet Sie keinen Arbeitsprozess. Für Layer-7-Angriffe auf HTTP und HTTPS ist das eine saubere Lösung, und sie bringt zusätzlich Zwischenspeicherung an vielen Standorten mit.
Die Lücke ist ebenso klar: Das alles wirkt nur, solange der Angriff durch den Dienst läuft. Ihr Server hat weiterhin eine eigene, öffentlich erreichbare IP-Adresse. Kennt der Angreifer sie, schickt er seinen Verkehr direkt dorthin und der gesamte vorgeschaltete Aufbau ist ohne Wirkung. Diese Adresse geheim zu halten ist deutlich schwerer, als es klingt.
Auf welchen Wegen die Ursprungs-IP bekannt wird
- Die DNS-Historie. Mehrere Dienste archivieren DNS-Einträge über Jahre. Der A-Eintrag, der vor der Umstellung auf den Proxy direkt auf Ihren Server zeigte, steht dort weiterhin. Eine Umstellung löscht keine Vergangenheit.
- Mail-Kopfzeilen Ihres eigenen Servers. Jede Nachricht, die WordPress verschickt, trägt in den
Received-Zeilen die Adresse des absendenden Systems. Das betrifft die Bestätigung aus dem Kontaktformular, die Nachricht zum Zurücksetzen des Passworts, die Bestellbestätigung eines Shops und jede Benachrichtigung über einen neuen Kommentar. Ein Angreifer braucht dafür nur ein Formular auszufüllen und die Kopfzeilen zu lesen. - Ausgehende Verbindungen Ihrer Installation. WordPress ruft von sich aus fremde Adressen auf: Update-Prüfungen, eingebettete Vorschauen über oEmbed, Webhooks, und beim Pingback sogar jede URL, die ihm jemand nennt. Wer Ihrem Server eine Adresse unterschiebt, die er selbst kontrolliert, liest die Quelladresse anschließend im eigenen Protokoll ab. Das ist der zweite, praktische Grund,
pingback.pingzu entfernen undwp-cron.phpnicht über HTTP laufen zu lassen. - Zertifikats-Transparenzlogs. Jedes öffentlich vertrauenswürdige Zertifikat wird in öffentliche, nur anfügbare Protokolle eingetragen (das Verfahren ist in RFC 6962 beschrieben). Wer dort nach Ihrer Domain sucht, bekommt jede jemals ausgestellte Bescheinigung samt aller enthaltenen Namen, darunter Testsysteme und interne Bezeichnungen. Liefert Ihr Ursprungsserver auf Port 443 dasselbe Zertifikat aus, weist er sich damit selbst aus, sobald jemand die in Frage kommenden Adressbereiche absucht.
- Nicht geschützte Subdomains.
mail.,webmail.,cpanel.,ftp.,dev.,staging.unddirekt.zeigen in vielen Setups auf dieselbe Maschine und laufen nicht über den Proxy. Ein MX-Eintrag kann über einen HTTP-Proxy grundsätzlich nicht laufen, er nennt also immer eine echte Adresse.
Dazu kommen Kleinigkeiten mit derselben Wirkung: eine vergessene phpinfo.php, eine Fehlerseite, die den Servernamen ausgibt, ein altes Sicherungsarchiv im Web-Verzeichnis oder ein Antwortkopf, der die interne Adresse nennt.
Die Ursprungs-IP wieder zumachen, und was dann noch offen bleibt
Wenn Sie einen vorgeschalteten Dienst nutzen, sollte Ihr Server nur noch von diesem Dienst erreichbar sein. Auf Paketebene mit nftables:
table inet filter {
set proxy_v4 {
type ipv4_addr
flags interval
elements = { 203.0.113.0/24, 198.51.100.0/24 }
}
chain input {
type filter hook input priority filter; policy drop;
iif lo accept
ct state established,related accept
ct state invalid counter drop
tcp dport { 80, 443 } ip saddr @proxy_v4 accept
tcp dport 22 ip saddr 203.0.113.10 accept
counter drop
}
}
Tragen Sie unter proxy_v4 ausschließlich die vom Dienst veröffentlichten Netze ein und Ihre eigene Adresse für SSH, bevor Sie das Regelwerk laden, sonst sperren Sie sich selbst aus. Der vollständige Regelsatz für den laufenden Betrieb, einschließlich SYN-Cookies und angepasster conntrack-Grenzen, steht in Server vor DDoS-Angriffen schützen.
Ergänzend sorgt ein Standard-Servereintrag in nginx dafür, dass eine direkte Verbindung mit unbekanntem Namen gar keine Antwort bekommt:
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_reject_handshake on;
}
Jetzt der ehrliche Teil, den kaum eine Anleitung nennt: Damit ist der HTTP-Weg zu, nicht der Netzweg. Die Pakete des Angreifers erreichen weiterhin Ihre Leitung und Ihre Netzwerkkarte, sie werden nur nicht mehr beantwortet. Bei einem Layer-7-Angriff reicht das aus, weil dessen Volumen klein ist. Bei einem volumetrischen Angriff auf dieselbe Adresse ändert es gar nichts: Die Leitung ist voll, bevor Ihre Regel greift. Filterung muss deshalb vor der Leitung sitzen, nicht dahinter, und das kann nur der Betreiber des Netzes leisten.
Wer löst welche Angriffsart
| Angriffsart | Maßnahmen auf dem Server | Vorgeschalteter Dienst | Filterung im Netz davor |
|---|---|---|---|
HTTP-Flut auf /?s= mit 500 Anfragen pro Sekunde |
Cache und Ratenbegrenzung lösen das vollständig | löst das vollständig, solange der Verkehr darüber läuft | sieht gültige Verbindungen, greift nur bei auffälligen Mustern |
Bruteforce auf wp-login.php |
Authentifizierung davor und fail2ban lösen das | löst das über eigene Regeln | nicht die richtige Ebene |
| Verbindungsflut, zehntausende offene TCP-Sitzungen | limit_conn und kurze Zeitgrenzen mildern, lösen aber nicht |
löst das, weil die Verbindung dort endet | löst das auf Paketebene |
| SYN-Flut mit gefälschten Absendern | SYN-Cookies halten den Dienst am Leben | schützt nur die Adresse des Dienstes, nicht Ihre | löst das vollständig |
| UDP-Flut oder Verstärkungsangriff mit 100 Gbit/s | keine Wirkung, die Leitung ist vorher voll | keine Wirkung, wenn direkt auf die Ursprungs-IP geschossen wird | löst das als Einziges |
| Paketrate jenseits von einer Million pro Sekunde | keine Wirkung, der Kernel verwirft blind | keine Wirkung bei direktem Beschuss | löst das als Einziges |
Die Tabelle zeigt, warum die Frage "Plugin oder CDN oder Serverfilter" falsch gestellt ist. Die drei Ebenen lösen unterschiedliche Angriffe, und in den ersten beiden Zeilen ist die Serverkonfiguration sogar die wirksamste und günstigste Antwort. In den letzten drei Zeilen gibt es genau eine Spalte, in der etwas anderes als "keine Wirkung" steht.
Was KernelHost dagegen stellt
Der Dauerschutz, der in jedem Serverpaket enthalten ist
Der DDoS-Schutz von KernelHost ist zweistufig und dauerhaft aktiv, ohne Bestellung, ohne Schalter und ohne Aufpreis:
- Stufe 1: 17 Tbps Mitigationskapazität im globalen Scrubbing-Netzwerk. Volumetrische Angriffe werden nah an ihrer Quelle bereinigt, bevor sie das Rechenzentrum erreichen.
- Stufe 2: Arbor-Echtzeitfilterung mit 3,2 Tbps in Frankfurt am Main. Unmittelbar vor dem Server werden protokollspezifische Muster erkannt und verworfen, Paket für Paket.
Zwei Eigenschaften sind für einen WordPress-Betreiber entscheidend. Der Schutz ist ab der Bereitstellung des Servers aktiv, es gibt also keine Minuten am Anfang eines Angriffs, in denen die Seite weg ist. Und es wird kein Nullrouting eingesetzt: Ihre IP-Adresse bleibt im Netz erreichbar, verworfen werden ausschließlich die schädlichen Pakete. Das ist der Unterschied, der darüber entscheidet, ob ein Angriff fünf Minuten Störung bedeutet oder vier Stunden Nichterreichbarkeit.
Advanced DDoS Protection für dauerhaft beschossene Projekte
Manche Projekte werden nicht gelegentlich getroffen, sondern gezielt und über Wochen, oft zur immer gleichen Uhrzeit und mit wechselnden Mustern. Dafür gibt es die Advanced DDoS Protection ab 50,00 € im Monat, PrePaid, ohne Mindestlaufzeit und ohne Einrichtungsgebühr. Der Unterschied liegt nicht in mehr Kapazität, sondern in der Kontrolle:
- Dedizierte Schutz-IP aus dem Frankfurter Kern, auf die Ihr Server im eigenen Netz umgestellt wird. Auf Ihrer Seite ist kein Umbau nötig.
- Selbst verwaltbare Schutzregeln je Port und Protokoll im Kundenbereich. Für eine WordPress-Seite heißt das getrennte Regeln für 80 und 443 gegenüber allem anderen, etwa SSH, Datenbank oder Mail.
- Änderungen greifen in Echtzeit. Sie können während eines laufenden Angriffs nachjustieren, statt auf ein Wartungsfenster zu warten.
- Eigene Profile für HTTP, HTTPS, TLS und WordPress sowie für beliebige eigene Dienste auf freien TCP- und UDP-Ports.
Die Advanced DDoS Protection richtet sich an KernelHost-Kunden und setzt einen Server bei KernelHost voraus. Läuft Ihr Projekt derzeit woanders und steht dort regelmäßig unter Beschuss, ist der Umzug hierher der Weg, nicht eine Fernbetreuung Ihrer bisherigen Adresse. Wie ein WordPress-Umzug ohne Ausfall abläuft, mit abgesenkter TTL, Delta-Abgleich und Test über die hosts-Datei, steht Schritt für Schritt in WordPress auf einen neuen Server umziehen.
Die beiden Stufen im Vergleich
| Merkmal | Inkludierter DDoS-Dauerschutz | Advanced DDoS Protection |
|---|---|---|
| Preis | in jedem Serverpaket enthalten, ohne Aufpreis | ab 50,00 € im Monat, PrePaid |
| Filterkapazität | 17 Tbps globales Scrubbing plus Arbor-Echtzeitfilterung mit 3,2 Tbps in Frankfurt am Main | dieselbe zweistufige Filterung |
| Aktiv ab | Bereitstellung des Servers | Bereitstellung der Schutz-IP |
| IP-Adresse | die IP-Adresse Ihres Servers | zusätzliche dedizierte Schutz-IP |
| Regelwerk | automatische Profile, keine Konfiguration nötig | eigene Regeln je Port und Protokoll im Kundenbereich |
| Änderungen | laufen automatisch mit | greifen in Echtzeit, auch während eines Angriffs |
| Nullrouting | nein | nein |
| Laufzeit | an das Serverpaket gebunden | PrePaid, keine Mindestlaufzeit, keine Kündigungsfrist, keine Einrichtungsgebühr |
Und die Abgrenzung, die zu diesem Artikel gehört: Eine Filterung im Netz arbeitet auf Paketen, Verbindungen und Protokollmustern. Sie kann nicht wissen, dass /?s=zufallswort auf Ihrer Installation teurer ist als /impressum/, denn beides sind gültige HTTPS-Anfragen in einer verschlüsselten Verbindung. Die Arbeitsteilung ist deshalb eindeutig: Volumen, Paketrate, Verbindungsfluten und Protokollauffälligkeiten löst die Filterung davor, die Kosten je einzelner gültiger Anfrage lösen der Vollseiten-Cache und die Ratenbegrenzung auf Ihrem Server. Beides zusammen ergibt einen Schutz, jedes für sich hat eine offene Flanke.
Was im laufenden Angriff zu tun ist, in dieser Reihenfolge
Die Reihenfolge ist wichtiger als die einzelnen Schritte, weil die teuersten Fehler in den ersten fünf Minuten passieren.
- Messen, nicht schrauben. Sichern Sie zuerst die Ausgabe von
curl -s "http://127.0.0.1/fpm-status?full", die letzten 200.000 Zeilen des Zugriffsprotokolls und die letzten 200 Zeilen des Fehlerprotokolls in Dateien. Nach dem Angriff sind diese Werte weg. - Nicht neu starten. Ein Neustart löscht alle Zähler und jeden Beweis, und die Last ist nach wenigen Sekunden zurück.
- Die Ebene bestimmen. Ist die Leitung voll oder die Paketrate hoch, ist es kein Layer-7-Angriff, und auf dem Server hilft nichts mehr. Ist die Leitung leer und
listen queuehoch, sind Sie hier richtig. - Die Adressenzahl bestimmen. Eine Handvoll Adressen sperren Sie sofort. Zehntausend Adressen sperren Sie nicht, sondern begrenzen die Rate.
- Den Vollseiten-Cache einschalten oder erzwingen. Das ist die Maßnahme mit dem größten Hebel je Minute Aufwand. Prüfen Sie anschließend mit
X-Cache, dass sie greift. - Die teuren Pfade zudrehen. Ratenbegrenzung auf
/?s=,admin-ajax.phpundwc-ajax,xmlrpc.phpundwp-cron.phpaufreturn 444. Diese vier Änderungen wirken sofort nachnginx -s reloadund brauchen keinen Neustart von PHP. - Die Anmeldung schließen. Authentifizierung des Webservers vor
wp-login.phpund/wp-admin/, mit der Ausnahme füradmin-ajax.php. - Erst jetzt an PHP-FPM drehen.
request_terminate_timeoutsetzen, damit hängende Prozesse freigegeben werden.pm.max_childrennur erhöhen, wenn der freie Arbeitsspeicher das wirklich hergibt. - Die eigene Adresse nicht vorschnell wechseln. Ein Wechsel wirkt nur, wenn zugleich alle fünf Lecks aus dem Abschnitt zur Ursprungs-IP geschlossen sind. Ein einziger alter DNS-Eintrag macht ihn wirkungslos.
- Den Anbieter mit Zahlen einbeziehen. Ein Ticket mit Zeitpunkt, Zielport, Anfragen pro Sekunde, Zahl der Quelladressen und den drei meistgefragten Pfaden ist um Größenordnungen schneller bearbeitet als die Meldung, die Seite sei langsam.
- Danach dokumentieren. Beginn, Dauer, Muster, Zielpfade. Wiederkehrende Angriffe zur selben Uhrzeit sind das Kriterium dafür, ob ein Projekt eine dedizierte Schutz-IP braucht.
Häufige Fehler und was stattdessen hilft
pm.max_childrenverdoppeln, weil der Pool voll ist. Ohne zusätzlichen Arbeitsspeicher endet das im OOM-Killer, der sich meist MySQL holt. Danach ist die Seite nicht langsam, sondern kaputt. Rechnen Sie den Wert aus dem freien Speicher, nicht aus dem Wunsch.- Während des Angriffs ein Sicherheits-Plugin installieren. Die Installation selbst braucht einen Arbeitsprozess, den es gerade nicht gibt, und das Plugin prüft anschließend jede Anfrage in PHP. Die Maßnahme wirkt erst nach dem Angriff.
- Den Cache über einen Parameter umgehbar machen.
fastcgi_cache_bypass $arg_nocacheoder$http_pragmaist eine offene Tür. Die Bedingung darf nur aus Werten bestehen, die der Besucher nicht setzen kann. - Ratenbegrenzung hinter einem Proxy ohne
set_real_ip_from. Sie drosseln dann den Proxy und damit alle echten Besucher, während der Angreifer durchläuft. - Ein Caching-Plugin und
fastcgi_cachegleichzeitig, ohne sie aufeinander abzustimmen. Das Ergebnis sind widersprüchliche Kopfzeilen und ein Cache, der nach dem Bearbeiten eines Beitrags nicht geleert wird. Entscheiden Sie sich für eine Instanz, die den Cache verwaltet. /wp-admin/komplett sperren. Ohne die Ausnahme füradmin-ajax.phpgehen Kontaktformulare, Filter und Warenkörbe im Frontend kaputt, meist unbemerkt.wp-cron.phpsperren, ohne einen echten Zeitplan einzurichten. Geplante Beiträge, Sicherungen und Bestellbestätigungen bleiben dann stumm liegen.- Sperrlisten von Hand pflegen. Bei einem verteilten Angriff wächst keine Liste schnell genug, und jeder Eintrag kostet zusätzlich Speicher und Suchzeit auf einem ohnehin überlasteten System.
- Die IP-Adresse wechseln und die Lecks offen lassen. DNS-Historie, Mail-Kopfzeilen und Transparenzlogs führen den Angreifer binnen Minuten auf die neue Adresse.
Kurz zusammengefasst
- Ein WordPress-Plugin sieht eine Anfrage erst, nachdem sie den Webserver passiert, einen PHP-FPM-Arbeitsprozess belegt und WordPress gestartet hat. Eine abgewiesene Anfrage kostet damit genauso viel wie eine ausgelieferte Seite.
- Die harte Obergrenze heißt
pm.max_children. Der maximale Durchsatz istpm.max_childrengeteilt durch die mittlere Antwortzeit, also je nach Server zwischen 80 und 800 Anfragen pro Sekunde. - 500 Anfragen pro Sekunde mit je 600 Byte sind 2,4 Mbit/s. Ein Anschluss mit 1 Gbit/s trägt rechnerisch 208.333 HTTP-Anfragen pro Sekunde. Deshalb bleibt der Traffic-Graph bei einem Layer-7-Angriff unauffällig.
- Die teuersten Endpunkte sind immer dieselben:
/?s=(nicht cachebar, Tabellendurchlauf in MySQL),admin-ajax.php,xmlrpc.php,wp-cron.php, die REST-Schnittstelle und bei WooCommerce Warenkorb, Kasse und Fragmente. - Gemessen wird an vier Werten der PHP-FPM-Statusseite (
listen queue,max listen queue,max children reached,slow requests) und an Anfragen je Adresse und Sekunde im Zugriffsprotokoll. Statuscode 499 allein beweist nichts, 499 zusammen mit hoher Antwortzeit und vielen Quelladressen schon. - Wirksam auf dem Server sind in dieser Reihenfolge: Vollseiten-Cache mit
fastcgi_cache_lockundfastcgi_cache_use_stale,limit_req_zonemit eigener Zone für teure Parameter,limit_conn_zone,xmlrpc.phpundwp-cron.phpaufreturn 444, Authentifizierung vor der Anmeldung, fail2ban mit nftables-Sperren. - Cloudflare, Sucuri und Wordfence sind legitime Lösungen mit einem anderen Ansatz. Die vorgeschalteten Dienste wirken, solange der Verkehr durch sie läuft; sie helfen nicht, wenn der Angriff direkt auf die Ursprungs-IP geht. Diese Adresse wird über DNS-Historie, Mail-Kopfzeilen, ausgehende Verbindungen, Zertifikats-Transparenzlogs und nicht geschützte Subdomains regelmäßig bekannt.
- Oberhalb der Leitungs- und Paketratengrenze entscheidet ausschließlich die Filterung im Netz vor dem Server. Bei KernelHost ist sie zweistufig, in jedem Serverpaket ohne Aufpreis enthalten und ab Bereitstellung aktiv, ohne Nullrouting: 17 Tbps Mitigationskapazität im globalen Scrubbing-Netzwerk plus Arbor-Echtzeitfilterung mit 3,2 Tbps in Frankfurt am Main.
- Die Advanced DDoS Protection ergänzt das ab 50,00 € im Monat um eine dedizierte Schutz-IP und selbst verwaltbare Regeln je Port und Protokoll, PrePaid, ohne Mindestlaufzeit und ohne Einrichtungsgebühr.
Läuft Ihr WordPress bereits bei KernelHost, ist die Filterung aktiv, ohne dass Sie etwas tun müssen. Der Vollseiten-Cache und die Ratenbegrenzung aus diesem Beitrag bleiben trotzdem Ihre Aufgabe, weil sie eine andere Angriffsklasse abdecken. Bemerken Sie Auffälligkeiten, eröffnen Sie ein Support-Ticket mit den fünf Angaben aus Schritt 10. Bei einem laufenden Angriff erreichen Sie uns zusätzlich über den WhatsApp-Notfallchat unter +43 650 8209883.
Häufige Fragen
Schützt ein WordPress-Plugin vor DDoS-Angriffen?
Warum geht meine Seite offline, obwohl der Traffic-Graph unauffällig ist?
Wie viele Anfragen pro Sekunde hält ein WordPress-Server aus?
Welche WordPress-URLs sind bei einem Angriff die teuersten?
Woran erkenne ich einen Layer-7-Angriff auf WordPress?
Was bedeutet Statuscode 499 im nginx-Protokoll?
Soll ich xmlrpc.php sperren?
Hilft ein Vollseiten-Cache gegen einen DDoS-Angriff?
Reicht Cloudflare oder Sucuri als DDoS-Schutz für WordPress?
Wie findet ein Angreifer die Ursprungs-IP hinter einem CDN?
Wie stelle ich wp-cron richtig um?
Was stellt KernelHost gegen Angriffe auf WordPress-Seiten?
2026 KernelHost GmbH. Alle Rechte vorbehalten. Diese Anleitung ist urheberrechtlich geschützt. Eine Veröffentlichung auf anderen Webseiten, auch auszugsweise oder in bearbeiteter Form, ist ohne unsere schriftliche Zustimmung nicht gestattet. Zitate mit Quellenangabe und Link sind ausdrücklich willkommen.

