— Artikel — № 043

043 —Security

wp-login.php brute force: het .htaccess-blok dat werkt

De bots beuken al drie dagen om de twee seconden op wp-login.php. Dit is het .htaccess-blok dat ze tegenhoudt zonder het dashboard om zeep te helpen.

Foto van boven op linnen: oud .htaccess-vel, logstrook, manilla BLOCK-tab, messing HTACCESS-plaatje, rood potlood, lakzegel.
Hero · gestileerd stilleven№ 043

Je SSH't in om uit te zoeken waarom de database-belasting op de WordPress-site van een klant piekt. tail -f /var/log/apache2/access.log, en daar staat het: POST /wp-login.php, POST /wp-login.php, POST /wp-login.php, om de twee à vier seconden, elke regel van een ander IP. Dit patroon heb je duizend keer gezien. Je gaat het nog duizend keer zien.

Deze post is het .htaccess-blok dat we in een legacy site plakken zodra we dat patroon zien, plus een korte uitleg waarom het dashboard blijft werken, en wat het bewust laat passeren.

Hoe het verkeer eruitziet

Een moderne brute force op WordPress is zelden één IP dat één endpoint platgooit. Het is gedistribueerde credential stuffing. Een paar honderd IP's, vaak residential proxies, die elk één of twee POST requests doen met een geraden wachtwoord. Ze schrapen usernames uit het author-archief (/author/admin/, /author/editor/), POSTen naar wp-login.php en xmlrpc.php, en blijven malen tot ze een 200 krijgen of tot jij ze stopt.

Controleer het patroon met één regel:

awk '$7 == "/wp-login.php" && $9 != 200 {print $1}' access.log \
  | sort | uniq -c | sort -rn | head -20

Je ziet bijna altijd hetzelfde beeld: de top twintig IP's hebben elk tussen de twee en twaalf gefaalde POSTs. Geen enkel IP komt boven een fail2ban-drempel van bijvoorbeeld tien in vijf minuten uit. Het is het totale volume dat je database-verbindingen smelt.

Het blok

Het doel is om het request te weigeren op het Apache-niveau, vóór PHP, vóór WordPress, vóór de databaseverbinding opengaat. De goedkoopst mogelijke afwijzing.

# /var/www/example.com/.htaccess
# Block POST to wp-login.php unless the referer is this site itself.

<IfModule mod_rewrite.c>
  RewriteEngine On

  RewriteCond %{REQUEST_METHOD} =POST
  RewriteCond %{REQUEST_URI}    ^/wp-login\.php$
  RewriteCond %{HTTP_REFERER}   !^https?://([^.]+\.)?example\.com/ [NC]
  RewriteRule ^ - [F,L]
</IfModule>

# Block xmlrpc.php entirely. If you actually use it (Jetpack, mobile
# app), delete this block and lock it to specific IPs instead.
<Files xmlrpc.php>
  Require all denied
</Files>

Vervang example\.com door je eigen domein, en laat de geëscapete punt staan. Het blok doet vier dingen:

  • Een POST naar wp-login.php zonder referer, of met een vreemde referer, krijgt direct een 403.
  • Een POST naar wp-login.php die van je eigen domein komt, gaat onaangeraakt door.
  • Een GET naar wp-login.php (het laden van het formulier) wordt nooit geraakt.
  • xmlrpc.php geeft voor iedereen een 403.

De reden dat het ongeveer negen op de tien brute force POSTs vangt, is alledaags. Volume-scanners nemen niet de moeite om per site een referer te spoofen. Ze sturen niets, of ze lekken hun eigen scan-origin. De handvol bots die specifiek tegen jouw site is geschreven, komt erdoor. Dat is prima. Dat is een ander probleem met een andere oplossing.

Waarom het dashboard blijft werken

Er zijn drie dingen die normaal gesproken stukgaan zodra je een agressieve Apache-regel op een WordPress-installatie zet. Geen daarvan gebeurt hier.

Het inlogformulier zelf. Een echte login is een GET /wp-login.php (het formulier laadt), daarna een POST /wp-login.php (de credentials worden verstuurd). De referer op de POST is https://example.com/wp-login.php. Onze conditie zegt 'weiger tenzij de referer-host ons domein is', dus een echte submit komt erdoor.

admin-ajax.php. De dashboard-heartbeat, de autosave van de block editor, en bijna elke AJAX-call van een plugin draaien op /wp-admin/admin-ajax.php. Dat pad matcht onze regel nooit. Onaangeraakt.

Wachtwoord resetten. Wachtwoord-vergeten verstuurt een POST naar /wp-login.php?action=lostpassword vanaf je eigen formulierpagina. Dezelfde referer. Werkt nog steeds.

Wat wel stukgaat zodra je het bestand opslaat:

  • Alles wat programmatisch naar wp-login.php POST zonder referer. ManageWP-achtige remote login, sommige uptime monitors, een handvol MU-plugins. Voeg hun IP toe boven de regel met een RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.42$, of zet ze over op application passwords.
  • XML-RPC clients (de WordPress mobile app, Jetpack, oude desktop-editors). Gebruik je hier iets van, blokkeer dan niet de hele xmlrpc.php. Beperk het tot de specifieke IP's die het nodig hebben.

Wat het blok niet vangt

Wees eerlijk over de scope. Deze regel stopt goedkope, luidruchtige credential stuffing zonder referer. Hij stopt niet:

  • Een bot die specifiek tegen jouw site is geschreven en een matchende referer meestuurt. Triviaal om te bouwen, zeldzaam bij volume-runs, want referer-spoofing helpt de eigen analytics van de operator op zijn eigen scan om zeep.
  • Username-enumeratie via /?author=N of de REST API op /wp-json/wp/v2/users. Sluit die los af. De OWASP WordPress-richtlijn heeft daar een kort stuk over.
  • Gecompromitteerde credentials uit een paste-dump. Als het wachtwoord meteen klopt, is de bot niet te onderscheiden van een echte gebruiker. Daar is two-factor het enige echte antwoord op.

Globaal in volgorde van moeite zijn de volgende stappen zodra de .htaccess draait: een redirect op /?author=, two-factor op elk account met manage_options, en een niet-standaard login-URL om de ruis terug te brengen. Dat laatste is geen security, maar het scheelt nog een ordegrootte aan log-volume.

De wijziging controleren

Zodra het blok live staat, laat dan een uur lang een tail -f op het access log staan en let op drie dingen:

  1. Brute force POSTs die direct een 403 teruggeven, zonder dat PHP wordt aangeroepen. Te controleren door te kijken of de response time op die regels onder de 5 ms blijft.
  2. Je eigen logins die een 302 teruggeven naar /wp-admin/.
  3. admin-ajax.php-verkeer dat qua patroon en volume onveranderd blijft.

Houden alle drie het een uur vol, laat het blok dan staan. Alles wat op deze regel stukgaat, gaat in de eerste tien minuten stuk.

Serverbestanden als code behandelen

Toen we Pier bouwden, kwamen we steeds in dezelfde situatie terecht: het bureau dat een oude WordPress-installatie heeft overgenomen, weet niet welke IP's de vorige freelancer op de allowlist had staan, heeft geen overzicht van welke AJAX endpoints stukgaan onder welke Apache-regel, en heeft geen makkelijke manier om terug te draaien als een paste op vrijdag om 18:40 het dashboard plat legt. Wat we uiteindelijk hebben gedaan, is elke .htaccess-edit behandelen zoals je een database-migratie behandelt: het bestand wordt in version history vastgelegd voordat de save doorgaat, en een undo zet de vorige versie terug met één toetsaanslag. Dezelfde workflow voor wp-config.php en elk ander serverbestand waar je anders zenuwachtig van wordt zodra je het via SFTP openzet.

Het kleinste wat je vandaag kunt doen, is de awk-oneliner hierboven loslaten op je eigen access log. Hebben de top twintig IP's die wp-login.php raken elk tussen de twee en twaalf hits, dan kijk je precies naar het verkeer waar dit blok voor geschreven is, en ben je er in vijf minuten mee klaar.

— Vragen —

Sluit dit mij buiten mijn eigen wp-admin?

Nee, zolang je browser bij de login-POST een same-origin referer meestuurt, en dat doet hij standaard. Strip een privacy-extensie hem alsnog, zet dan Referrer-Policy: same-origin in dezelfde .htaccess.

Vervangt dit fail2ban of een WAF?

Nee. Het vangt goedkoop, referer-loos volume op het Apache-niveau af. Voor gerichte bots en hergebruikte credentials heb je nog steeds two-factor en rate limiting op een hogere laag nodig.

Wat is het NGINX-equivalent?

Een location = /wp-login.php-blok met daarin een if ($http_referer !~* "^https?://([^.]+\.)?example\.com/") { return 403; }. Dezelfde logica, andere syntax.