Log di audit in WordPress: ricostruire chi ha fatto cosa, e quando

Registri di attività, syslog remoto, binlog di MySQL e auditd: come tenere log che sopravvivono all’attacco e ricostruire un incidente su WordPress.

Tre sorgenti, cioè WordPress, il web server e il database, alimentano una cronologia di eventi con orari, dalla richiesta REST alla creazione di un utente fino all’attivazione di un plugin evidenziata in rosso; la cronologia confluisce in un archivio syslog remoto chiuso da un lucchetto

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.

Schema a livelli delle fonti di log utili a ricostruire un incidente su WordPress, con per ciascuna cosa registra e chi può cancellarla. Plugin di registro attività nel database di WordPress: eventi applicativi con nome e utente, cancellabile da chiunque controlli l’utente PHP o un account amministratore. Log di accesso del web server: ogni richiesta HTTP con IP, ora e risposta, cancellabile solo da root o dall’utente del web server, ma spesso ruotato dopo pochi giorni. Binlog di MySQL: ogni modifica ai dati, riga per riga, gestito dall’utente mysql. auditd: scritture e modifiche di file con processo e utente di sistema, gestito da root. Syslog remoto: gli eventi applicativi inviati in tempo reale a un altro server, fuori dalla portata dell’attaccante. Una freccia indica che la resistenza alla cancellazione cresce dall’alto verso il basso.
Più una fonte è lontana dall’utente PHP del sito, più è affidabile quando il sito è compromesso.

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.

Schema dell’inoltro remoto degli eventi. Sul server web, il mu-plugin audit-syslog intercetta gli hook di WordPress, come wp_login, user_register, set_user_role, activated_plugin e wp_create_application_password, e chiama la funzione syslog di PHP con facility local5. Il demone rsyslog, che gira come root, scrive una copia locale in /var/log/wp-audit.log e inoltra via TCP, cifrato con TLS se attraversa Internet, con una coda su disco in caso di interruzione. Il server di raccolta, separato e con credenziali proprie, accetta solo nuove righe e conserva i registri per almeno sei mesi. Un confine tratteggiato indica che l’utente PHP del sito può agire solo a sinistra del demone syslog: ciò che è già partito non si può più modificare.
L’utente PHP del sito può solo aggiungere eventi: una volta consegnati al demone syslog, non può più modificarli né cancellarli.

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.

Cronologia di un incidente d’esempio ricostruita da cinque fonti, in ora UTC. 02:47:12, log di nginx: POST a una rotta REST di un plugin di importazione da un indirizzo IP di esempio, risposta 200. 02:47:13, binlog di MySQL: inserimento nelle tabelle wp_users e wp_usermeta di un utente con capacità di amministratore. 02:47:13, syslog remoto: evento utente_creato con ruolo administrator. 02:51:40, syslog remoto: login del nuovo utente dallo stesso IP. 02:52:06, auditd: file creati in wp-content/plugins scritti dal processo php-fpm. 02:52:09, syslog remoto: plugin_attivato. In parallelo, la riga del registro attività nel database mostra un vuoto per tutta la notte, perché le righe sono state cancellate. In fondo, la conclusione: ingresso tramite l’applicazione, non tramite credenziali SFTP.
Nessuna fonte, da sola, racconta tutto: la sequenza emerge dall’incrocio, a patto che almeno una fonte sia sopravvissuta all’attacco.

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.

IL PROSSIMO PASSO

Il tuo sito
è infetto?

Ogni ora conta: il malware continua a colpire visitatori e clienti, e Google, l’hosting e le blacklist non aspettano. Scegli il piano, paga online e condividi gli accessi: la bonifica parte subito, a prezzo fisso e con tempi garantiti.

Richiedi la bonifica

Ti porta ai piani: bonifica entro 6, 12 o 24 ore dalla ricezione degli accessi.

MANAGED SERVER S.R.L.info@rimozionemalware.com +39 02 5656 9681

Lunedì – venerdì · 09:30 – 19:30

Preferisci un modulo online?
Contattaci su managedserver.it ↗