La prima domanda che ci viene fatta durante una bonifica è quasi sempre la stessa: «come sono entrati, e quando?». La risposta dipende da una cosa sola, cioè da quanto il sito registrava prima dell’incidente. Troppo spesso la risposta onesta è: poco. Un log di accesso del web server ruotato dopo sette giorni, nessuna traccia dei login, nessuna traccia dei plugin installati o degli utenti creati. E, quando un registro esisteva, stava nello stesso database a cui l’attaccante aveva accesso.
Un log di audit vale quanto la sua distanza dall’attaccante. Se chi compromette il sito può anche cancellare il registro, il registro racconta solo quello che l’attaccante ha deciso di lasciare.
In questo articolo vediamo cosa WordPress registra da solo e cosa no, cosa aggiungono i plugin di registro attività, come portare gli eventi fuori dal sito con syslog, come incrociarli con i log del web server, di MySQL e di auditd, quanto conservarli nel rispetto del GDPR e, alla fine, come si ricostruisce concretamente una sequenza di eventi.
Cosa WordPress non registra
WordPress, per impostazione predefinita, non tiene alcun registro delle attività. Non registra i login riusciti né quelli falliti, non registra chi ha installato o attivato un plugin, chi ha creato un utente o cambiato un ruolo, chi ha modificato un’opzione. Alcune tracce indirette esistono: le revisioni degli articoli con il loro autore, la data di registrazione degli utenti, le sessioni attive salvate nei metadati utente. Ma sono dati pensati per il funzionamento del sito, non per l’indagine, e un attaccante con accesso al database li modifica come vuole.
Fuori da WordPress, di solito, c’è il log di accesso del web server, che registra ogni richiesta HTTP ma non sa chi fosse l’utente né cosa sia successo dentro l’applicazione, e il log degli errori di PHP. Il primo è prezioso, e abbiamo spiegato come leggerlo nell’articolo sui log di accesso di Apache, NGINX e LiteSpeed; il suo limite è che una POST a /wp-admin/admin-ajax.php può voler dire mille cose diverse. Servono eventi applicativi, con un nome: «utente creato», «plugin attivato», «ruolo cambiato».
Un’avvertenza sul log degli errori: attivare WP_DEBUG_LOG senza indicare un percorso fa scrivere gli errori in wp-content/debug.log, dentro la cartella pubblica, dove chiunque può scaricarlo insieme ai percorsi del server e, a volte, a dati degli utenti. Se serve, va indirizzato a un file fuori dalla docroot; ne parliamo nell’articolo sui file esposti per errore.
WP Activity Log e Simple History
I due plugin più diffusi per il registro attività coprono bene questo livello. WP Activity Log, sviluppato da Melapress, registra login, modifiche a contenuti, utenti, plugin, temi e impostazioni; secondo la pagina del plugin, la versione gratuita registra gli stessi eventi di quella a pagamento, mentre le notifiche, i report e la possibilità di salvare i log in un database esterno o di inoltrarli a servizi come syslog, Papertrail o AWS CloudWatch sono nelle edizioni premium. Simple History ha un approccio più leggero e leggibile, un’interfaccia a cronologia, esportazione in CSV e JSON, comandi WP-CLI e, nella versione premium, l’inoltro degli eventi a file locali, server syslog, database esterni e altri servizi.
# Simple History da riga di comando: gli eventi di una notte, in CSV
wp simple-history event list --date_from="2026-10-14 00:00:00" --date_to="2026-10-14 08:00:00" \
--count=500 --format=csv > eventi-notte.csv
Entrambi fanno bene il loro lavoro principale, e su un sito senza alcun registro installarne uno è già un grande passo avanti. Hanno però un limite comune, che non dipende dalla qualità del codice: nella configurazione predefinita salvano gli eventi nel database di WordPress, e mantengono il registro per un periodo limitato, che nel caso di Simple History è di trenta giorni se non si cambia l’impostazione.
Il registro nello stesso database dell’attaccante
Pensiamo a cosa può fare chi ha preso il controllo di un account amministratore o, peggio, ha caricato una webshell che gira con l’utente PHP del sito. Può disattivare il plugin di registro, e l’evento «plugin disattivato» sarà l’ultimo che il plugin scrive. Può cancellare le righe delle proprie azioni direttamente dalla tabella, con le credenziali lette da wp-config.php. Può svuotare tutto. Nelle indagini ci capita di trovare registri perfettamente integri fino a una certa sera e poi vuoti, oppure registri in cui mancano esattamente le ore che interessano.
La regola che ne deriva è semplice: ogni evento deve lasciare il server nel momento in cui accade, verso una destinazione su cui l’utente del sito non ha permessi di scrittura né di cancellazione. Il registro locale resta utile per la consultazione quotidiana; quello remoto è la fonte su cui si fa affidamento quando le cose vanno male.
Portare gli eventi fuori dal sito con syslog
Se non si vuole dipendere da una funzione premium, gli eventi più importanti si possono inviare a syslog con un mu-plugin di poche righe. PHP scrive al demone syslog locale, che a sua volta li inoltra a un server di raccolta; l’utente PHP non ha alcun modo di modificare ciò che è già partito. Gli eventi da registrare sono pochi e ad alto valore: accessi, utenti e ruoli, plugin, aggiornamenti, password applicative e le opzioni che un attaccante tocca più spesso.
<?php
// wp-content/mu-plugins/audit-syslog.php
function rmw_audit( $evento, $dati = array() ) {
$utente = wp_get_current_user();
$riga = wp_json_encode( array_merge( array(
'sito' => wp_parse_url( home_url(), PHP_URL_HOST ),
'evento' => $evento,
'utente' => $utente->exists() ? $utente->user_login : '-',
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
), $dati ) );
openlog( 'wp-audit', LOG_PID, LOG_LOCAL5 );
syslog( LOG_NOTICE, $riga );
closelog();
}
add_action( 'wp_login', fn( $login ) => rmw_audit( 'login', array( 'login' => $login ) ) );
add_action( 'wp_login_failed', fn( $login ) => rmw_audit( 'login_fallito', array( 'login' => $login ) ) );
add_action( 'user_register', fn( $id ) => rmw_audit( 'utente_creato', array( 'id' => $id, 'ruoli' => get_userdata( $id )->roles ) ) );
add_action( 'set_user_role', fn( $id, $ruolo, $vecchi ) => rmw_audit( 'ruolo_cambiato', array( 'id' => $id, 'nuovo' => $ruolo, 'vecchi' => $vecchi ) ), 10, 3 );
add_action( 'activated_plugin', fn( $plugin ) => rmw_audit( 'plugin_attivato', array( 'plugin' => $plugin ) ) );
add_action( 'deactivated_plugin', fn( $plugin ) => rmw_audit( 'plugin_disattivato', array( 'plugin' => $plugin ) ) );
add_action( 'upgrader_process_complete', fn( $u, $extra ) => rmw_audit( 'installazione_aggiornamento', $extra ), 10, 2 );
add_action( 'wp_create_application_password', fn( $id, $item ) => rmw_audit( 'password_applicativa_creata', array( 'id' => $id, 'nome' => $item['name'] ) ), 10, 2 );
add_action( 'updated_option', function ( $opzione ) {
if ( in_array( $opzione, array( 'siteurl', 'home', 'admin_email', 'users_can_register', 'default_role', 'active_plugins' ), true ) ) {
rmw_audit( 'opzione_modificata', array( 'opzione' => $opzione ) );
}
} );
# /etc/rsyslog.d/60-wp-audit.conf — copia locale e inoltro con coda su disco
local5.* /var/log/wp-audit.log
local5.* action(type="omfwd" target="log.esempio.it" port="514" protocol="tcp"
queue.type="LinkedList" queue.filename="wp_audit_fwd"
action.resumeRetryCount="-1")
Il volume va considerato: durante un attacco a forza bruta, wp_login_failed può generare migliaia di righe in pochi minuti, e conviene che il server di raccolta sappia gestirle o che la regola di inoltro le campioni. La coda su disco fa sì che, se il server di raccolta non è raggiungibile, gli eventi vengano conservati e inviati appena possibile. Se il traffico attraversa una rete pubblica va cifrato, con il driver TLS che il modulo omfwd di rsyslog supporta. Dietro un proxy o una CDN, REMOTE_ADDR contiene l’indirizzo del proxy: in quel caso va letto l’IP reale dall’intestazione impostata dal proxy, fidandosi solo se la richiesta arriva davvero da lì. Un attaccante che ottiene l’accesso ai file può cancellare il mu-plugin, certo; ma gli eventi già inviati restano, e un sito che smette improvvisamente di produrre eventi è a sua volta un segnale da monitorare.
Il server di raccolta, dal canto suo, deve essere davvero separato: un’altra macchina o un servizio esterno, con credenziali che non compaiono su nessuno dei server web, che accetta solo l’aggiunta di nuove righe e applica la rotazione secondo il periodo di conservazione deciso. Se il web server può collegarsi via SSH al server dei log, un attaccante che ha ottenuto l’accesso come root può fare lo stesso, e la separazione esiste solo sulla carta.
Correlare con il web server e con MySQL
Un evento applicativo dice cosa è successo; il log di accesso dice da dove è arrivata la richiesta; il database dice cosa è stato scritto. Il primo requisito per metterli insieme è banale e spesso trascurato: gli orologi. Il log di nginx riporta l’ora locale con il fuso, syslog di solito l’ora locale, WordPress salva le date sia in ora locale sia in UTC, MySQL dipende dalla configurazione. Prima di costruire una sequenza conviene convertire tutto in UTC e verificare che il server sia sincronizzato via NTP.
Per il database esistono due registri molto diversi. Il general query log registra ogni istruzione ricevuta, comprese le letture: utile per un’indagine mirata e breve, troppo pesante da tenere acceso in produzione. Il binary log, invece, registra solo le modifiche ai dati ed è pensato per la replica e il ripristino: nella documentazione di MySQL 8.4 risulta attivo per impostazione predefinita, mentre su MariaDB va abilitato esplicitamente. Quando c’è, permette di vedere l’istante esatto in cui è comparso un utente o è cambiata un’opzione, anche se poi l’attaccante ha cancellato le prove dal sito:
# Modifiche alle tabelle degli utenti in una finestra di 30 minuti
mysqlbinlog --base64-output=DECODE-ROWS -vv \
--start-datetime="2026-10-14 02:40:00" --stop-datetime="2026-10-14 03:10:00" \
/var/lib/mysql/binlog.000123 | grep -A15 -E 'INSERT INTO `[^`]*`\.`wp_(users|usermeta)`'
Il binlog è gestito dall’utente mysql e non è accessibile all’utente PHP del sito, che può al massimo inviare istruzioni SQL: anche un DELETE fatto per coprire le tracce finisce, a sua volta, nel binlog. Va solo ricordato che viene ruotato ed eliminato dopo un periodo configurabile, e che conviene copiarlo subito quando si apre un’indagine, insieme agli altri registri, come descritto nell’articolo sulle prime due ore dopo un attacco.
auditd: chi ha scritto quel file
L’ultimo livello è il sistema operativo. Il sottosistema di audit di Linux, con il demone auditd, può registrare ogni scrittura o cambio di attributi su file e cartelle scelti, insieme al processo che l’ha eseguita, all’utente di sistema e all’utente che aveva effettuato l’accesso in origine. È la differenza tra sapere che functions.php è cambiato e sapere se lo ha scritto PHP-FPM, cioè il sito stesso, oppure una sessione SFTP con le credenziali di un collaboratore. Il campo auid, l’utente di audit, resta quello del login originale anche dopo un sudo o un cambio di utente, ed è spesso l’informazione decisiva:
# /etc/audit/rules.d/wordpress.rules — solo le cartelle dove il codice non dovrebbe cambiare
-w /var/www/esempio.it/public_html/wp-config.php -p wa -k wp-config
-w /var/www/esempio.it/public_html/wp-content/plugins -p wa -k wp-codice
-w /var/www/esempio.it/public_html/wp-content/themes -p wa -k wp-codice
-w /var/www/esempio.it/public_html/wp-content/mu-plugins -p wa -k wp-codice
# Caricare le regole e interrogare il registro
augenrules --load
ausearch -k wp-codice -i --start today | grep -E 'name=|exe=|auid='
Le regole, descritte nel manuale di auditctl, vanno limitate alle cartelle in cui il codice non dovrebbe cambiare se non con un aggiornamento: sorvegliare upload e cache produrrebbe un volume di eventi inutile. Il registro è scritto da root, fuori dalla portata dell’utente del sito, e si integra bene con il monitoraggio dell’integrità dei file: l’integrità dice che un file è cambiato, auditd dice chi l’ha cambiato. Anche /var/log/audit può essere inoltrato al server di raccolta.
Quanto conservare, e il GDPR
I log contengono dati personali, come indirizzi IP, nomi utente e a volte email, e vanno trattati come tali. Il GDPR, al considerando 49, riconosce la sicurezza delle reti e dei sistemi come legittimo interesse del titolare, nei limiti di quanto strettamente necessario e proporzionato. In pratica: registrare ciò che serve alla sicurezza e non di più, limitare l’accesso ai registri a chi deve consultarli, definire e documentare un periodo di conservazione, cancellare alla scadenza.
In Italia c’è un riferimento specifico: il provvedimento del Garante sugli amministratori di sistema del 27 novembre 2008 chiede di registrare gli accessi logici degli amministratori ai sistemi che trattano dati personali, con registrazioni complete, inalterabili e verificabili, conservate per un congruo periodo non inferiore a sei mesi. Per un sito con area riservata, un e-commerce o un gestionale, sei mesi sono quindi un minimo ragionevole anche per il registro degli accessi amministrativi di WordPress, e il server di raccolta remoto è il modo più semplice di garantirne l’inalterabilità. Le implicazioni di un incidente sul piano della protezione dei dati sono l’argomento dell’articolo sul sito hackerato come data breach.
Un esempio di ricostruzione
Vediamo come le fonti si completano a vicenda, con un caso costruito sul modello di quelli che incontriamo. Il cliente segnala un plugin sconosciuto, comparso nella bacheca. Il registro attività nel database non mostra nulla per quella notte: le righe sono state cancellate. Il syslog remoto, invece, riporta alle 02:47 UTC un evento utente_creato con ruolo amministratore, alle 02:51 un login di quell’utente e alle 02:52 l’attivazione del plugin, tutti dallo stesso indirizzo IP.
Con l’IP e l’orario, il log di nginx mostra alle 02:47 una POST a una rotta REST di un plugin di importazione, seguita un minuto dopo dalla creazione dell’utente nel binlog. auditd conferma che i file del nuovo plugin sono stati scritti da PHP-FPM, non via SFTP: l’ingresso è l’applicazione, non le credenziali di un collaboratore. Resta da verificare la rotta REST coinvolta, con il metodo descritto nell’articolo sull’API REST e gli endpoint esposti, e da cercare tra i bollettini una vulnerabilità nota del plugin di importazione.
Senza il syslog remoto e il binlog, in questo caso, avremmo avuto solo un plugin sconosciuto e un registro vuoto. Con quelle fonti, in meno di un’ora si passa dal sintomo al punto d’ingresso, ed è il punto d’ingresso che decide se la bonifica reggerà. Se ti trovi davanti a un sito compromesso e a registri incompleti, possiamo aiutarti a ricostruire la sequenza con quello che resta, dai log del web server alla correlazione assistita da modelli AI eseguiti in locale: scrivici. La bonifica, a livello applicativo e di sistema, è garantita in 6, 12 o 24 ore per ogni installazione WordPress.