Quasi tutte le infezioni che bonifichiamo hanno una cosa in comune: a un certo punto un file è cambiato, o è comparso dove non doveva essere, e nessuno se n’è accorto per giorni o per mesi. Una webshell nella cartella degli upload, tre righe in fondo a functions.php, un file con un nome simile a quelli del core nella radice del sito. Il sito continua a funzionare e la modifica resta invisibile finché non produce un effetto: spam, redirect, una segnalazione di Google.
Il monitoraggio dell’integrità non impedisce l’attacco. Riduce il tempo tra la modifica di un file e il momento in cui qualcuno se ne accorge, ed è quel tempo che decide quanto sarà costosa la bonifica.
In questo articolo mettiamo insieme gli strumenti che usiamo, dal più semplice al più completo: la verifica dei checksum ufficiali con WP-CLI, un manifest di hash per i file che wordpress.org non conosce, la sorveglianza in tempo reale con inotify, un database di integrità con AIDE e i deploy basati su Git. Nessuno basta da solo; combinati, coprono quasi tutti i modi in cui un file può cambiare sotto il naso di chi gestisce il sito.
Cosa vuol dire integrità per un sito WordPress
Controllare l’integrità significa confrontare lo stato attuale dei file con uno stato di riferimento ritenuto sano, la baseline, e segnalare ogni differenza. Il problema, su WordPress, è che i file non sono tutti uguali. Il core e i plugin della directory ufficiale hanno una baseline pubblica: wordpress.org pubblica l’hash di ogni file per ogni versione. I plugin premium, i temi personalizzati e il codice scritto per il cliente non ce l’hanno, e la baseline va costruita da chi gestisce il sito. La cartella degli upload, infine, cambia per definizione ogni volta che qualcuno carica un’immagine, e le cartelle di cache cambiano a ogni visita.
Ci sono poi due modi di controllare: periodicamente, confrontando tutto con la baseline una volta al giorno o all’ora, e in tempo reale, facendosi avvisare dal kernel nel momento in cui un file viene creato o modificato. Il primo è completo ma lento a reagire; il secondo è immediato ma vede solo gli eventi che accadono mentre è attivo. Uno schema sensato li usa entrambi, e tiene la baseline dove il codice del sito non può modificarla.
I checksum ufficiali con WP-CLI, ogni notte
Il primo livello costa pochi minuti di configurazione. WP-CLI confronta i file del core e dei plugin della directory con gli hash pubblicati da wordpress.org per la versione installata, e segnala sia i file modificati sia quelli che nel core non dovrebbero esistere. Con --include-root il controllo si estende ai file nella radice del sito, dove il malware ama lasciare file dal nome plausibile. Messo in un cron dell’utente del sito, con un avviso solo quando c’è qualcosa da segnalare, diventa un controllo quotidiano che nessuno deve ricordarsi di fare:
#!/bin/bash
# /usr/local/bin/wp-verifica-checksum.sh — da eseguire con l'utente del sito, non come root
SITO=/var/www/esempio.it/public_html
DEST=sicurezza@esempio.it
OUT=$(mktemp)
{
wp --path="$SITO" core verify-checksums --include-root
# i plugin premium non hanno checksum su wordpress.org: si escludono per nome
wp --path="$SITO" plugin verify-checksums --all --exclude=plugin-premium-1,plugin-premium-2
} > "$OUT" 2>&1
if grep -qE '^(Warning|Error):' "$OUT"; then
mail -s "Checksum WordPress non validi su $(hostname)" "$DEST" < "$OUT"
fi
rm -f "$OUT"
# crontab -e dell'utente del sito: ogni notte alle 3:15
15 3 * * * /usr/local/bin/wp-verifica-checksum.sh
I messaggi da leggere con attenzione sono due. «File doesn’t verify against checksum» indica un file del core il cui contenuto non corrisponde all’originale: può essere una modifica fatta a mano da uno sviluppatore, che resta comunque una cattiva pratica, oppure codice iniettato. «File should not exist» indica un file che nella distribuzione ufficiale non c’è: un file dal nome plausibile nella radice o in wp-includes, come un wp-login-cache.php, che nessuna versione di WordPress contiene, è quasi sempre una backdoor. Nel dubbio, il confronto con il file originale della stessa versione chiarisce in pochi secondi.
Due limiti vanno tenuti presenti. Il primo è che la verifica gira con lo stesso PHP e lo stesso WordPress che si stanno controllando: un malware che intercetta WP-CLI, o un mu-plugin caricato anche da riga di comando, può in teoria falsarne il risultato, come spieghiamo parlando dei limiti degli scanner. Il secondo è che in un attacco alla filiera, come quelli descritti nell’articolo sugli aggiornamenti malevoli dei plugin, la versione ufficiale è quella compromessa, e i checksum corrispondono. Il controllo resta prezioso, ma non può essere l’unico.
Un manifest sha256 per tutto il resto
Per temi, plugin premium e codice personalizzato la baseline si costruisce da sé, con strumenti presenti su qualunque sistema Linux. Subito dopo una bonifica, un deploy o un aggiornamento verificato, si calcola l’hash di ogni file e si salva l’elenco fuori dalla cartella del sito, in un percorso che l’utente PHP non può scrivere:
cd /var/www/esempio.it/public_html
BASE=/root/baseline/esempio.it
# Baseline: tutti i file tranne cache e upload, in ordine stabile
find . \( -path ./wp-content/cache -o -path ./wp-content/uploads \) -prune -o -type f -print0 \
| LC_ALL=C sort -z | xargs -0 sha256sum > "$BASE.sha256"
# Controllo: file modificati o spariti rispetto alla baseline
sha256sum --check --quiet "$BASE.sha256"
# File nuovi, che sha256sum --check non può vedere
find . \( -path ./wp-content/cache -o -path ./wp-content/uploads \) -prune -o -type f -print \
| LC_ALL=C sort > /tmp/elenco-attuale
cut -c67- "$BASE.sha256" | LC_ALL=C sort > /tmp/elenco-baseline
comm -13 /tmp/elenco-baseline /tmp/elenco-attuale
L’ultimo passaggio è quello che molti dimenticano: sha256sum --check verifica solo i file elencati nel manifest, e un file PHP comparso dopo la baseline passerebbe inosservato. Il confronto degli elenchi con comm colma la lacuna. La cartella degli upload è esclusa perché cambia di continuo, ma non va ignorata: al suo interno non dovrebbe esistere alcun file eseguibile, e un semplice find wp-content/uploads -type f -iname '*.php*' in cron è il complemento naturale, insieme al blocco dell’esecuzione PHP descritto nell’articolo sulle webshell negli upload.
Il manifest vale quanto la sua protezione. Se l’utente del sito può riscriverlo, un attaccante che ha preso il controllo di quell’utente può anche rigenerarlo dopo aver aggiunto la sua backdoor, e il controllo successivo risulterà pulito. Per questo il file va tenuto in una cartella di root, o meglio copiato anche su un altro sistema, e il controllo va lanciato da un cron di root o da una macchina di monitoraggio che legge i file in sola lettura. Lo stesso principio vale per tutti gli strumenti che seguono.
inotify: accorgersi nel momento in cui un file compare
I controlli periodici dicono cosa è cambiato dall’ultima volta; inotify dice cosa sta cambiando adesso. È un sottosistema del kernel Linux che notifica gli eventi sui file, e inotifywait, del pacchetto inotify-tools, permette di usarlo da uno script. Per un sito WordPress l’evento che conta di più è la creazione di un file PHP dentro wp-content: in condizioni normali succede solo durante un aggiornamento o un’installazione, cioè in momenti noti.
#!/bin/bash
# /usr/local/bin/wp-sorveglia-php.sh — nuovi file eseguibili in wp-content
DIR=/var/www/esempio.it/public_html/wp-content
inotifywait -m -r -q -e create -e moved_to -e close_write \
--format '%T|%e|%w%f' --timefmt '%F %T' "$DIR" |
while IFS='|' read -r quando evento file; do
case "$file" in
"$DIR"/cache/*) continue ;;
*.php|*.php[0-9]|*.phtml|*.phar|*.inc) ;;
*) continue ;;
esac
logger -t wp-integrita "$evento $file"
printf '%s %s %s\n' "$quando" "$evento" "$file" \
| mail -s "File PHP in wp-content: $file" sicurezza@esempio.it
done
Lo script va eseguito come servizio di sistema, con un’unità systemd che lo riavvia se si ferma, e con un utente diverso da quello del sito: se il processo appartenesse all’utente PHP, una webshell potrebbe semplicemente terminarlo. Tre accorgimenti pratici. Su siti con molte cartelle il limite di osservazioni per utente del kernel può esaurirsi, e va alzato con sysctl fs.inotify.max_user_watches. Durante gli aggiornamenti lo script genera decine di avvisi legittimi, quindi conviene sospenderlo nelle finestre di manutenzione o raccogliere gli eventi in un riepilogo invece di inviare un’email per ciascuno. Infine, inotify vede solo le modifiche fatte attraverso il kernel locale: su un filesystem di rete condiviso tra più server, le modifiche fatte da un altro nodo non generano eventi.
inotify dice quale file è cambiato, ma non chi l’ha scritto. Quando serve anche questa informazione, per esempio per capire se il file è stato scritto dal pool PHP-FPM, da un processo lanciato da cron o da un accesso SFTP, il sottosistema di audit del kernel è lo strumento giusto. Una regola su wp-content registra per ogni scrittura il processo, il suo percorso e l’utente:
# Registrare scritture e cambi di attributi sotto wp-content (regola non persistente)
auditctl -w /var/www/esempio.it/public_html/wp-content/ -p wa -k wp-scrittura
# Leggere gli eventi con nomi di utenti e processi risolti
ausearch -k wp-scrittura -i --start today | grep -E 'name=|exe=|auid='
Su siti con molto traffico in scrittura, come cache su disco o upload frequenti, le regole di audit generano volumi importanti di log: conviene limitarle alle cartelle dove il codice non dovrebbe cambiare, come plugin, temi e mu-plugin, e renderle persistenti in /etc/audit/rules.d/ solo dopo averne misurato l’impatto.
AIDE: un database di integrità per la docroot
AIDE (Advanced Intrusion Detection Environment) fa in modo strutturato ciò che il manifest fa a mano: costruisce un database con hash, permessi, proprietario, dimensioni e date di ogni file selezionato, e a ogni controllo riporta file aggiunti, rimossi e modificati, attributo per attributo. Rispetto a sha256sum vede anche i cambi di permessi e di proprietario, che spesso accompagnano un’infezione. È nei repository di tutte le principali distribuzioni; la configurazione di sistema controlla i binari e le librerie, e per il sito conviene un file separato:
# /etc/aide/aide-wordpress.conf
# (con AIDE 0.16 usare "database=" al posto di "database_in=")
database_in=file:/var/lib/aide/wordpress.db
database_out=file:/var/lib/aide/wordpress.db.new
report_url=stdout
# permessi, inode, link, utente, gruppo, dimensione, mtime, ctime, hash
WP = p+i+n+u+g+s+m+c+sha256
/var/www/esempio.it/public_html WP
!/var/www/esempio.it/public_html/wp-content/cache
!/var/www/esempio.it/public_html/wp-content/uploads
!/var/www/esempio.it/public_html/wp-content/upgrade
# Prima baseline, subito dopo una bonifica o un deploy verificato
aide --config=/etc/aide/aide-wordpress.conf --init
mv /var/lib/aide/wordpress.db.new /var/lib/aide/wordpress.db
# Controllo: il codice di uscita è diverso da zero se ci sono differenze
aide --config=/etc/aide/aide-wordpress.conf --check
# Dopo un aggiornamento verificato: nuovo database con le differenze accettate
aide --config=/etc/aide/aide-wordpress.conf --update
mv /var/lib/aide/wordpress.db.new /var/lib/aide/wordpress.db
Il punto debole di AIDE, come di ogni strumento di questo tipo, è la protezione della baseline. Se il database e la configurazione stanno sullo stesso server e l’attaccante ottiene i privilegi di root, può aggiornarli e cancellare le proprie tracce. Per questo il database va copiato dopo ogni aggiornamento su un sistema esterno, o su un supporto in sola lettura, e il controllo più affidabile è quello che confronta il server con quella copia. Sui server che gestiamo è anche uno dei dati che confrontiamo durante l’analisi di un incidente.
Il rumore: cache, upload e aggiornamenti
Un sistema di integrità che manda dieci avvisi al giorno viene ignorato entro una settimana. La differenza tra uno strumento utile e uno fastidioso sta quasi tutta nelle esclusioni. Le cartelle da escludere dal confronto degli hash sono quelle che cambiano per funzionamento normale: wp-content/cache e le cartelle dei plugin di cache, wp-content/uploads, wp-content/upgrade usata durante gli aggiornamenti, e le cartelle di log o di file temporanei che alcuni plugin creano dentro wp-content. Escludere però non significa smettere di guardare: per queste cartelle il controllo diventa «nessun file eseguibile», come visto per gli upload.
Il secondo accorgimento è legare gli aggiornamenti alla baseline. Se il sito si aggiorna in automatico di notte, il controllo della mattina troverà sempre differenze. Conviene che lo stesso processo che aggiorna, uno script con WP-CLI o il sistema di deploy, verifichi i checksum subito dopo, rigeneri manifest e database AIDE e lo annoti nel registro. Un cambiamento con una spiegazione registrata non genera allarmi; uno senza, sì.
Git come baseline dei deploy
Sui siti sviluppati su misura il modo più pulito di avere una baseline è non modificare mai i file in produzione se non con un deploy. Il codice vive in un repository Git; il server riceve solo ciò che è stato approvato; le dipendenze sono fissate con Composer. A quel punto il controllo di integrità è un comando:
# Repository fuori dalla docroot, non scrivibile dall'utente PHP
git --git-dir=/srv/deploy/esempio.it.git --work-tree=/var/www/esempio.it/public_html \
status --porcelain --untracked-files=all
# Nel .gitignore del progetto: wp-content/uploads/, wp-content/cache/, wp-config.php
Un output vuoto significa che ogni file corrisponde all’ultimo deploy; qualunque riga è una modifica fatta fuori dal processo, e va spiegata. Tenere la cartella .git fuori dalla docroot ha due vantaggi: non viene esposta sul web per errore, e un attaccante che controlla l’utente PHP non può fare un commit delle proprie modifiche per nasconderle. Il limite è che funziona solo se il flusso viene rispettato: un solo intervento diretto sul server, anche in buona fede, rende la baseline inaffidabile.
Quando scatta l’allarme
Il valore di tutto questo si gioca nei minuti dopo un avviso. La prima domanda è se il cambiamento era atteso: un aggiornamento, un deploy, un intervento registrato. Se la risposta è sì, si verificano i checksum, si aggiorna la baseline e si chiude. Se è no, l’istinto è cancellare il file, ed è l’errore più comune. Prima va conservato, con i suoi metadati:
# Copia con permessi e date, fuori dalla docroot
cd /var/www/esempio.it/public_html
mkdir -p /root/incidente-$(date +%F)
cp -a --parents wp-content/uploads/2026/09/cache-img.php /root/incidente-$(date +%F)/
# Date di modifica e di cambio stato: la ctime non si può falsificare con touch
stat wp-content/uploads/2026/09/cache-img.php
# Cosa è successo in quel minuto, nei log di accesso
grep '12/Sep/2026:03:12' /var/log/nginx/access.log
La data di modifica (mtime) può essere alterata con touch, e il malware lo fa spesso per mimetizzarsi tra i file vecchi; la data di cambio di stato (ctime) no, ed è quella da incrociare con i log di accesso per trovare la richiesta che ha scritto il file e quindi il punto d’ingresso. Da lì si procede come in ogni incidente, secondo le prime due ore di risposta: contenere, cambiare le credenziali, cercare la persistenza, cioè i cron, i processi in memoria e il database, che nessuno degli strumenti visti controlla.
La baseline nuova si costruisce solo alla fine, a bonifica completata: rigenerarla prima significherebbe dichiarare sano un sito che ha ancora una backdoor. Se un avviso ti ha portato a un file che non sai spiegare, o se vuoi impostare questi controlli su un sito appena ripulito, scrivici: interveniamo a livello applicativo e di sistema, con una pulizia garantita in 6, 12 o 24 ore per ogni installazione WordPress, e lasciamo il sito con una baseline da cui ripartire.