Il reset password è uno dei vettori più trascurati nella sicurezza di un sito WordPress. Mentre il brute force sul login è ampiamente noto e spesso protetto, la pagina wp-login.php?action=lostpassword viene quasi sempre ignorata, ed è proprio quello il target di molti bot automatizzati.

Segue un'analisi di un attacco reale, in cui si mostra come riconoscerlo dai log e come bloccarlo con configurazioni nginx (su Linux Ubuntu).

Come si presenta l'attacco

La prima traccia è solitamente una notifica email da WordPress: qualcuno ha richiesto il reset della password per un account amministratore. Guardando i log nginx appare evidente che non si tratta di un errore umano.

grep -i "lostpassword" /var/log/nginx/admin_sito_access.log | tail -30

Emerge una sequenza meccanica di richieste compresse in pochi secondi:

[14:36:07] GET  /wp-login.php?action=lostpassword        200
[14:36:10] POST /wp-login.php?action=lostpassword        302
[14:36:10] GET  /wp-login.php?checkemail=confirm         200
[14:36:12] GET  /wp-login.php?action=rp&key=xkQvn...&login=nome_utente  302
[14:36:13] GET  /wp-login.php?action=lostpassword&error=invalidkey  200
[14:36:14] GET  /wp-login.php?action=lostpassword        200
[14:36:15] POST /wp-login.php?action=lostpassword        200
[14:36:16] GET  /wp-login.php?action=lostpassword        200
[14:36:17] POST /wp-login.php?action=lostpassword        200

Tre pattern indicativi:

Ciclo continuo
GET e POST si alternano a cadenza di un secondo per minuti interi. Nessun umano farebbe così.

User-agent diverso ad ogni richiesta
Chrome su Windows, poi Firefox su Linux, poi Safari su macOS. I bot cambiano user agent per eludere i filtri basati su UA.

Tentativo immediato della chiave di reset
Il bot invia la POST, riceve il 302 di conferma email e nel giro di 2 secondi chiama l'URL di reset con una chiave. La chiave risulta invalida (error=invalidkey) ma il ciclo riparte senza fermarsi.

Contando le richieste si ha un'idea della scala del problema:

grep "lostpassword" /var/log/nginx/admin_sito_access.log | wc -l
grep "lostpassword" /var/log/nginx/admin_sito_access.log.1 | wc -l

Nell'attacco analizzato: 88 richieste nel giorno corrente, 45 il giorno precedente, alla stessa ora. Un cron job schedulato.

Come il bot enumera i nomi utente

WordPress, che non è proprio un bunker inespugnabile per impostazioni di default, espone i nomi utente in almeno tre punti che vengono spesso ignorati. Cambiare il nome utente admin infatti non basta se questi endpoint rimangono accessibili.

REST API

L'endpoint /wp-json/wp/v2/users restituisce la lista utenti senza autenticazione per default.

curl -s https://tuo-sito.com/wp-json/wp/v2/users | python3 -m json.tool | head -20

Risposta tipica:

[
  {
    "id": 1,
    "name": "Mario Rossi",
    "slug": "mario_admin",
    "link": "https://example.com/author/mario_admin/"
  }
]

Il campo slug corrisponde esattamente al login name usato da WordPress per l'autenticazione.

Redirect delle pagine autore

WordPress risponde a /?author=1 con un redirect 301 verso /author/mario_admin/, rivelando il login name dell'utente con ID 1. Iterando su ?author=1, ?author=2 e così via, un bot enumera tutti gli utenti registrati.

curl -sI "https://tuo-sito.com/?author=1" | grep Location

XML-RPC

Il file xmlrpc.php, abilitato di default, supporta il metodo wp.getUsersBlogs e consente di eseguire un numero illimitato di tentativi di autenticazione in una singola richiesta HTTP, rendendolo più pericoloso della pagina di login classica.

curl -s -o /dev/null -w "%{http_code}" https://tuo-sito.com/xmlrpc.php

200 significa accessibile, 403 significa già bloccato.

Contromisure nginx

Tutte le configurazioni che seguono agiscono prima che la richiesta raggiunga PHP. Questo è il punto più efficiente dove intervenire: un plugin WordPress gira dentro PHP, quindi la richiesta arriva comunque al processo. Il rate limiting a livello nginx blocca il bot prima ancora che venga allocata memoria per PHP.

Rate limiting su wp-login.php

In un file nel contesto http di nginx, ad esempio /etc/nginx/conf.d/wp-security.conf:

limit_req_zone $binary_remote_addr zone=wp_login:10m rate=5r/m;

Questa direttiva definisce una zona chiamata wp_login che traccia gli IP in 10MB di memoria condivisa, con un limite di 5 richieste al minuto per indirizzo IP.

Nel vhost del sito, una location dedicata per wp-login.php:

location = /wp-login.php {
    limit_req zone=wp_login burst=3 nodelay;
    limit_req_status 429;

    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
}

burst=3 consente un picco iniziale di 3 richieste ravvicinate prima che il limite entri in azione. nodelay fa sì che nginx risponda immediatamente con 429 invece di mettere le richieste in coda. Con questa configurazione, il ciclo del bot che esegue decine di POST al minuto viene interrotto dopo le prime richieste.

Blocco XML-RPC

location = /xmlrpc.php {
    deny all;
    return 403;
}

Se XML-RPC non viene usato attivamente (Jetpack, app mobile WordPress), bloccarlo rimuove un vettore d'attacco senza impatti funzionali.

Blocco enumerazione utenti via REST API

location ~ ^/wp-json/wp/v2/users {
    deny all;
    return 403;
}

Questa regola blocca solo l'endpoint della lista utenti, senza interferire con il resto dell'API REST.

Per bloccare anche il redirect ?author=N è necessario intervenire lato WordPress, perché nginx non filtra facilmente sui parametri GET. Un plugin come Stop User Enumeration o codice in functions.php gestisce questo caso.

Applicazione

sudo nginx -t
sudo nginx -s reload

Verifica rapida

Quattro comandi per capire in meno di un minuto la situazione di un sito WordPress.

Richieste su wp-login.php nelle ultime 24 ore:

grep "wp-login" /var/log/nginx/tuo_sito_access.log | wc -l

Verifica se la REST API espone la lista utenti:

curl -s https://tuo-sito.com/wp-json/wp/v2/users | python3 -m json.tool | head -20

Verifica se il redirect autore funziona:

curl -sI "https://tuo-sito.com/?author=1" | grep -i location

Verifica se xmlrpc.php risponde:

curl -s -o /dev/null -w "%{http_code}" https://tuo-sito.com/xmlrpc.php

Segnali d'allarme nei log

Segnale Significato
Decine di POST a wp-login.php in pochi minuti Bot attivo
User-agent diverso ad ogni richiesta Evasione filtri per UA
Stesso pattern ogni giorno alla stessa ora Job schedulato
action=rp&key=... subito dopo checkemail=confirm Bot con accesso alla casella email o riutilizzo chiavi precedenti

FAQ

Il reset password è pericoloso anche se la casella email è sicura?

Se l'email dell'admin non è compromessa, il reset in sé non basta per accedere. L'attacco rimane però un problema: inonda la casella di notifiche inutili, genera carico su PHP e conferma al bot che il nome utente è valido (WordPress risponde in modo diverso a username esistenti e inesistenti).

Un plugin di sicurezza tipo Wordfence è sufficiente?

Wordfence agisce a livello PHP, quindi la richiesta arriva comunque al processo. Per volumi elevati, il rate limiting nginx è più efficiente perché il blocco avviene prima che PHP venga avviato.

Il rate limiting non blocca anche gli utenti legittimi?

Con rate=5r/m burst=3, un utente che dimentica la password e riprova 2-3 volte non ha problemi. Il blocco scatta solo su volumi automatici.

Fail2ban può aiutare?

Sì, ma richiede che i log catturino il vero IP del client. Se il sito è dietro un reverse proxy o CDN, verificare che nginx sia configurato con set_real_ip_from e real_ip_header appropriati, altrimenti fail2ban vedrebbe sempre l'IP del proxy e non potrebbe bannare il vero attaccante.

Riferimenti:

  1. WordPress Security Hardening
  2. nginx ngx_http_limit_req_module
  3. OWASP Testing for Account Enumeration