Dopo una bonifica la domanda più frequente è sempre la stessa: come si fa a impedire che succeda di nuovo? Una parte della risposta sta nei permessi e nella proprietà dei file, di cui abbiamo parlato nella checklist di hardening dopo la bonifica. Esiste però uno strumento più drastico, poco usato sui siti WordPress e molto efficace se applicato con criterio: l’attributo immutabile dei filesystem Linux, che si imposta con chattr +i e rende un file intoccabile anche per il suo proprietario.
Un file immutabile non si può modificare, cancellare né rinominare, qualunque siano i suoi permessi. Per il codice di un sito che cambia solo durante gli aggiornamenti è esattamente il comportamento che serve; per le cartelle in cui WordPress deve scrivere è una trappola.
Vediamo cosa fa l’attributo, quali percorsi bloccare e quali lasciare scrivibili, come cambiano gli aggiornamenti e, soprattutto, cosa non protegge e quali alternative esistono.
Cos’è l’attributo immutabile
Oltre ai permessi classici (lettura, scrittura, esecuzione per proprietario, gruppo e altri), i filesystem Linux più diffusi, tra cui ext4 e XFS, gestiscono una serie di attributi aggiuntivi legati all’inode. Due interessano la sicurezza. Secondo la pagina di manuale di chattr, un file con l’attributo i non può essere modificato, cancellato o rinominato, non vi si possono creare collegamenti e non può essere aperto in scrittura; un file con l’attributo a può essere aperto in scrittura solo in modalità di aggiunta, cioè si può allungare ma non riscrivere né troncare.
Il punto decisivo è chi può togliere l’attributo: solo root o un processo con la capability CAP_LINUX_IMMUTABLE. I permessi Unix non c’entrano. Anche se il file appartiene all’utente PHP del sito e ha permessi 777, un processo PHP compromesso non può modificarlo, e non può nemmeno rimuovere l’attributo, perché non ha quella capability. Per lo stesso motivo il comando funziona solo come root:
# Rendere immutabile un file e verificarlo
chattr +i /var/www/esempio.it/htdocs/wp-config.php
lsattr /var/www/esempio.it/htdocs/wp-config.php
----i---------e------- /var/www/esempio.it/htdocs/wp-config.php
# Su una cartella: -d mostra gli attributi della cartella stessa, non del contenuto
lsattr -d /var/www/esempio.it/htdocs/wp-includes
# Il tentativo di modifica fallisce anche per il proprietario
sudo -u esempio sh -c 'echo test >> /var/www/esempio.it/htdocs/wp-config.php'
sh: 1: cannot create /var/www/esempio.it/htdocs/wp-config.php: Operation not permitted
# Togliere l'attributo
chattr -i /var/www/esempio.it/htdocs/wp-config.php
Su una cartella l’attributo ha un effetto diverso e altrettanto utile: impedisce di creare, cancellare o rinominare le voci al suo interno, ma non protegge il contenuto dei file che contiene. Per bloccare un albero intero bisogna quindi applicarlo sia alle cartelle, così che non vi compaiano file nuovi, sia ai singoli file, così che quelli esistenti non cambino.
L’attributo non funziona ovunque: sui filesystem di rete come NFS o CIFS non è disponibile, e nei container Docker la capability LINUX_IMMUTABLE non è concessa per impostazione predefinita. Conviene provarlo prima su un file di prova nel percorso reale del sito.
Cosa bloccare e cosa lasciare scrivibile
Un’installazione WordPress contiene due tipi di file. Il codice (core, plugin, temi, configurazione) cambia solo quando qualcuno aggiorna o installa qualcosa; i dati (upload, cache, file di lavoro dei plugin) cambiano durante il normale funzionamento. L’attributo immutabile va applicato al primo gruppo e mai al secondo.
Tra i candidati naturali al blocco ci sono le cartelle wp-admin e wp-includes e i file PHP nella radice, wp-content/plugins, wp-content/themes e wp-content/mu-plugins, che è il posto preferito per le backdoor che si caricano da sole, come spieghiamo nell’articolo sui mu-plugin e drop-in. Poi wp-config.php e i file di configurazione per directory: .htaccess su Apache e LiteSpeed, .user.ini dove PHP gira come FPM o CGI. Sono i file che il malware modifica per ottenere persistenza, per esempio con un auto_prepend_file. Vanno bloccate anche la cartella wp-content in sé e la radice del sito, così che nessuno vi crei file nuovi, come i drop-in object-cache.php o advanced-cache.php.
Devono restare scrivibili, invece, wp-content/uploads, altrimenti il caricamento dei media smette di funzionare; le cartelle di cache dei plugin di caching, di solito sotto wp-content/cache; wp-content/upgrade e wp-content/upgrade-temp-backup, che WordPress usa durante gli aggiornamenti; e le cartelle di lavoro che alcuni plugin creano sotto wp-content, come wflogs di Wordfence o le cartelle di log e di esportazione di plugin per backup, moduli ed e-commerce. L’elenco esatto dipende dal sito, e si ricava guardando dove sono cambiati file negli ultimi giorni.
# Cartelle con file modificati negli ultimi 7 giorni: sono quelle che il sito scrive
cd /var/www/esempio.it/htdocs
find . -type f -mtime -7 -printf '%h\n' | sort | uniq -c | sort -rn | head -20
Le cartelle scrivibili sono esattamente quelle da tenere d’occhio. Nella cartella degli upload non deve esistere alcun file PHP eseguibile, e la regola del web server che lo impedisce, descritta nell’articolo sulle webshell negli upload, è il complemento obbligato del blocco: senza di essa l’attaccante sposta semplicemente la sua webshell nell’unico posto che gli resta aperto.
Applicare il blocco senza sbagliare percorso
Il comando chattr -R +i sulla radice del sito è la scorciatoia che tutti provano per prima, ed è sbagliata: blocca anche upload e cache, e il sito si rompe in modi poco evidenti. Meglio uno script che escluda esplicitamente le cartelle scrivibili e che serva sia per bloccare sia per sbloccare, così che le due operazioni tocchino sempre gli stessi percorsi:
#!/bin/bash
# /usr/local/sbin/wp-blocca.sh — da eseguire come root
# Uso: wp-blocca.sh +i (blocca) | wp-blocca.sh -i (sblocca)
set -euo pipefail
SITO=/var/www/esempio.it/htdocs
AZIONE=${1:?specificare +i oppure -i}
# Cartelle che WordPress e i plugin devono poter scrivere
mkdir -p "$SITO/wp-content/upgrade"
find "$SITO" \
\( -path "$SITO/wp-content/uploads" \
-o -path "$SITO/wp-content/cache" \
-o -path "$SITO/wp-content/upgrade" \
-o -path "$SITO/wp-content/upgrade-temp-backup" \
-o -path "$SITO/wp-content/wflogs" \) -prune \
-o \( -type f -o -type d \) -print0 \
| xargs -0 chattr "$AZIONE"
Lo script lavora solo su file e cartelle regolari, perché chattr non si applica ai collegamenti simbolici, e crea in anticipo la cartella upgrade: con wp-content bloccata, WordPress non potrebbe più crearla da sé. Dopo averlo eseguito, un controllo rapido conferma che le eccezioni sono rimaste scrivibili:
# Nessun file bloccato negli upload (output atteso: vuoto)
lsattr -R /var/www/esempio.it/htdocs/wp-content/uploads 2>/dev/null | awk '$1 ~ /i/'
# Nessun file sbloccato nel codice (output atteso: vuoto)
lsattr -R /var/www/esempio.it/htdocs/wp-includes 2>/dev/null | awk 'NF==2 && $1 !~ /i/'
# Prova di scrittura come utente del sito
sudo -u esempio touch /var/www/esempio.it/htdocs/wp-content/uploads/.prova && echo "upload scrivibili"
sudo -u esempio touch /var/www/esempio.it/htdocs/wp-content/plugins/.prova || echo "plugin bloccati"
Un’avvertenza sull’ordine delle operazioni: la pagina di manuale ricorda che impostare l’attributo non toglie la possibilità di scrivere a chi ha già il file aperto. Se un processo malevolo è in esecuzione e tiene aperto un file, può continuare a scriverlo anche dopo il blocco. Per questo il blocco si applica a bonifica conclusa, dopo aver terminato i processi sospetti e riavviato PHP-FPM, come descritto negli articoli sui processi PHP residenti in memoria e sulla checklist di hardening.
Effetti sugli aggiornamenti e sul malware
Con il codice bloccato, WordPress non può più aggiornarsi da solo. Prima di scegliere il metodo di scrittura, il core prova a creare un file temporaneo in wp-content: se non ci riesce, non usa la scrittura diretta e nel pannello chiede le credenziali FTP, mentre gli aggiornamenti automatici in background vengono saltati. È il comportamento voluto, perché lo stesso meccanismo che impedisce l’aggiornamento impedisce a un plugin vulnerabile di riscrivere il core. Per evitare confusione conviene dichiararlo in wp-config.php, prima di bloccarlo, con define( 'DISALLOW_FILE_MODS', true );: spariscono i pulsanti di aggiornamento e installazione e l’editor di temi e plugin.
Il rovescio della medaglia è il rischio più serio di tutto lo schema: un sito bloccato che nessuno aggiorna diventa, nel giro di qualche settimana, un sito con plugin vulnerabili. Il blocco sposta la responsabilità degli aggiornamenti da WordPress a chi gestisce il server, e funziona solo se esiste un processo che li applica con regolarità, almeno una volta al giorno per le patch di sicurezza.
Sul malware l’effetto è netto. Molte infezioni passano da un processo PHP che scrive file: una webshell che si copia nel core, codice aggiunto in fondo a functions.php, un .htaccess riscritto per i redirect condizionali. Con i file immutabili queste scritture falliscono con «Operation not permitted»: l’exploit può ancora riuscire, ma non mette radici nel codice e lascia errori evidenti nel log di PHP. Anche gli attaccanti conoscono questo strumento: alcuni cryptominer, quando ottengono i privilegi di root, usano chattr +i sui propri file e sulle righe di cron per rendere più difficile la rimozione. Un «Operation not permitted» inatteso durante una bonifica va sempre verificato con lsattr.
Un flusso di aggiornamento scriptato
Il flusso che usiamo sui siti bloccati ha quattro tempi: sbloccare, aggiornare, verificare, bloccare di nuovo. Il punto delicato è la verifica, perché la finestra in cui i file sono scrivibili è anche la finestra in cui un attacco in corso potrebbe approfittarne, e un aggiornamento malevolo, come quelli descritti nell’articolo sugli attacchi alla filiera dei plugin, arriva proprio da lì. Lo script gira come root, esegue WP-CLI con l’utente del sito e riblocca i file in ogni caso, anche se un passaggio fallisce:
#!/bin/bash
# /usr/local/sbin/wp-aggiorna.sh — sblocca, aggiorna, verifica, riblocca
set -euo pipefail
SITO=/var/www/esempio.it/htdocs
UTENTE=esempio
TIMBRO=$(mktemp)
wp_sito() { sudo -u "$UTENTE" -- wp --path="$SITO" "$@"; }
/usr/local/sbin/wp-blocca.sh -i
trap '/usr/local/sbin/wp-blocca.sh +i; rm -f "$TIMBRO"' EXIT
wp_sito core update
wp_sito core update-db
wp_sito plugin update --all
wp_sito theme update --all
wp_sito language core update
# Verifica: i plugin premium non hanno checksum su wordpress.org
wp_sito core verify-checksums --include-root
wp_sito plugin verify-checksums --all --exclude=plugin-premium
# File PHP comparsi o modificati durante la finestra di sblocco
find "$SITO" -newer "$TIMBRO" -type f -name '*.php' -not -path '*/wp-content/uploads/*'
# crontab di root: ogni giorno alle 4:30, con l'output inviato per email da cron
MAILTO=sistemi@esempio.it
30 4 * * * /usr/local/sbin/wp-aggiorna.sh
Grazie a set -e, se un controllo dei checksum fallisce lo script si interrompe, il trap riblocca comunque i file e cron invia l’output con l’errore: il sito resta protetto e l’anomalia arriva a una persona, che deve capire se è un file modificato o un plugin da aggiungere alle esclusioni. L’elenco finale dei file PHP recenti è normale dopo un aggiornamento; diventa interessante quando contiene file fuori dalle cartelle dei plugin aggiornati. Dopo ogni esecuzione riuscita è il momento giusto per rigenerare la baseline di integrità descritta nell’articolo sul monitoraggio dell’integrità dei file.
Lo stesso vale per i ripristini: un ripristino da backup sopra una cartella bloccata fallisce con errori di permesso anche come root. Si sblocca, si ripristina, si verifica e si riblocca, con lo stesso script.
I limiti: root, database e cartelle scrivibili
L’attributo immutabile protegge dal processo PHP e da chiunque agisca con l’utente del sito, compreso chi ha rubato la password SFTP di quell’utente, uno scenario frequente che descriviamo nell’articolo sulle credenziali FTP rubate. Non protegge da root: chi ottiene i privilegi di amministratore del sistema toglie l’attributo con un comando. È una difesa contro l’escalation dal sito al codice, non contro la compromissione del server.
Non protegge nemmeno il database. Gli utenti amministratori, le opzioni, i contenuti degli articoli e i widget vivono in MySQL, e un attaccante che sfrutta un plugin vulnerabile può ancora creare un amministratore fantasma, iniettare JavaScript nei contenuti o salvare un payload serializzato in wp_options. Ci sono poi i trigger SQL che reinseriscono il codice, i cron job dell’utente, che stanno in /var/spool/cron e non nella cartella del sito, e i processi che girano in memoria senza toccare il disco. Infine le cartelle scrivibili: se l’esecuzione di PHP negli upload non è bloccata a livello di server, il sito resta vulnerabile esattamente come prima.
chattr +a per i log: aggiungere sì, cancellare no
L’attributo a è il cugino meno noto di i, e trova il suo uso naturale nei file di log. Un file in sola aggiunta accetta nuove righe ma non può essere troncato, riscritto, rinominato o cancellato da chi non ha la capability necessaria. Per un log di audit scritto dall’utente del sito, come quelli descritti nell’articolo sui log di audit di WordPress, significa che un attaccante può aggiungere righe false ma non cancellare quelle che raccontano il suo ingresso. Il prezzo è la rotazione: logrotate deve togliere l’attributo prima di rinominare il file e rimetterlo dopo averne creato uno nuovo.
# /etc/logrotate.d/wp-audit-esempio
/var/log/wp-audit/esempio.log {
daily
rotate 60
compress
delaycompress
missingok
create 0640 esempio adm
sharedscripts
prerotate
chattr -a /var/log/wp-audit/esempio.log
endscript
postrotate
chattr +a /var/log/wp-audit/esempio.log
endscript
}
Resta il limite di root, che può togliere l’attributo e riscrivere il log: i log che contano vanno anche inviati in tempo reale a un altro sistema, l’unico posto che un attaccante con pieni privilegi non può ritoccare.
Alternative: bind mount in sola lettura e utenti separati
Quando chattr non è disponibile, per esempio su NFS o in un container, o quando si preferisce un meccanismo più facile da gestire, ci sono altre strade che ottengono lo stesso risultato. La più elegante, sui sistemi con systemd, è limitare la vista del filesystem al solo servizio PHP-FPM: con le direttive ReadOnlyPaths e ReadWritePaths i processi PHP vedono il codice del sito in sola lettura, mentre un amministratore collegato in SSH e WP-CLI lanciato da terminale continuano a scrivere normalmente. Niente da sbloccare prima di un aggiornamento fatto da riga di comando.
# systemctl edit php8.3-fpm
[Service]
ReadOnlyPaths=/var/www
ReadWritePaths=/var/www/esempio.it/htdocs/wp-content/uploads /var/www/esempio.it/htdocs/wp-content/cache
# Applicare e verificare
systemctl daemon-reload && systemctl restart php8.3-fpm
systemctl show php8.3-fpm -p ReadOnlyPaths -p ReadWritePaths
La restrizione vale per tutti i pool gestiti da quel servizio, quindi le eccezioni di ogni sito vanno elencate insieme. In alternativa, un bind mount in sola lettura (mount -o bind,ro) espone il codice in un percorso non scrivibile da cui legge il web server, ma si gestisce peggio con backup e strumenti di deploy.
Infine la soluzione più semplice e portabile, che descriviamo nella checklist di hardening e nell’articolo sui permessi di wp-config.php: file di proprietà di un utente di deploy, diverso da quello con cui gira PHP, e scrittura concessa al pool solo dove serve. Non richiede root per gli aggiornamenti e funziona su qualunque filesystem. L’attributo immutabile si aggiunge sopra, come secondo livello: se un errore di configurazione rendesse scrivibile un file, l’attributo continuerebbe a proteggerlo.
Il sito in sola lettura dà il meglio su installazioni stabili, che cambiano plugin di rado e hanno qualcuno che gestisce il server; è meno adatto dove si provano plugin nuovi ogni settimana dal pannello. In ogni caso è un livello in più, non un sostituto della bonifica: bloccare un sito che contiene ancora una backdoor significa soltanto proteggere la backdoor.
Se il tuo sito continua a reinfettarsi, o vuoi applicare questo schema su un’installazione appena ripulita, scrivici: interveniamo a livello applicativo e di sistema, dai file ai cron, ai processi in memoria e ai trigger nel database, con una pulizia garantita in 6, 12 o 24 ore per ogni installazione WordPress, e lasciamo il sito con un flusso di aggiornamento che non richiede di sbloccare nulla a mano.