Un’agenzia gestisce una ventina di siti WordPress dei propri clienti su un unico account di hosting. Uno di questi, un vecchio sito vetrina che nessuno aggiorna da anni, viene compromesso attraverso un plugin abbandonato. Il sito viene ripulito con cura, ma una settimana dopo il malware compare su tre siti diversi, poi su altri cinque. Ogni volta che se ne sistema uno, un altro si ammala. Non è sfortuna e non è un attacco nuovo ogni giorno: è una contaminazione incrociata, e nasce da come sono organizzati l’account e il server.
Su un account condiviso il perimetro dell’infezione non è il sito: è tutto ciò che l’utente di sistema del sito può leggere e scrivere.
In questo articolo vediamo perché un’infezione salta da un sito all’altro, quali configurazioni la rendono possibile (utente di sistema unico, pool PHP condivisi, permessi troppo larghi, installazioni dimenticate) e come si pianifica una pulizia che non lasci la porta aperta dal sito accanto.
Un utente di sistema, molti siti
Su Linux ogni processo gira con l’identità di un utente, e i permessi sui file dicono cosa quell’utente può fare. Quando PHP esegue WordPress, lo fa con un utente preciso: può essere www-data o apache nelle configurazioni più semplici, oppure l’utente dell’account di hosting quando il server usa PHP-FPM, suEXEC o LiteSpeed con un processo per account. Qualunque codice PHP venga eseguito da quel sito, compreso quello di una webshell, ha esattamente gli stessi diritti.
Se dieci siti appartengono allo stesso utente, una webshell caricata in uno qualsiasi di essi può leggere e modificare i file di tutti e dieci. Non serve nessuna vulnerabilità ulteriore, nessuna scalata di privilegi: per il sistema operativo sono semplicemente file dello stesso proprietario. Molti kit di malware per WordPress lo sanno bene e, appena eseguiti, cercano tutti i file wp-config.php raggiungibili a partire dalla cartella home, leggono le credenziali del database di ciascun sito e iniettano codice nei file index.php, nei temi e nei plugin di ogni installazione trovata.
Lo stesso vale, in scala più grande, per i server configurati male in cui tutti i siti di tutti i clienti girano come www-data: lì il perimetro non è l’account, è l’intera macchina. È una configurazione che si incontra ancora su VPS installati a mano, dove Apache con mod_php o un solo pool PHP-FPM servono decine di virtual host.
Come sono fatti gli account di cPanel e Plesk
Nei pannelli di hosting più diffusi il confine tra utenti di sistema non coincide necessariamente con il confine tra siti, ed è bene sapere dove passa.
In cPanel ogni account corrisponde a un utente di sistema con la propria home, di solito /home/nomeutente/. Il dominio principale sta in public_html, ma i domini aggiuntivi (gli addon domain) e i sottodomini creati dallo stesso account stanno anch’essi sotto quella home, spesso in sottocartelle di public_html stessa, e girano tutti con lo stesso utente. Un account cPanel con quindici addon domain è, dal punto di vista della sicurezza, un unico sito con quindici ingressi.
In Plesk l’unità di isolamento è la subscription: ogni sottoscrizione ha un proprio utente di sistema, mentre i domini e i sottodomini aggiunti dentro la stessa sottoscrizione condividono quell’utente. Separare i siti in sottoscrizioni distinte è quindi il modo per ottenere utenti diversi. Su entrambi i pannelli, poi, contano le scelte del server: con PHP-FPM ogni account ha di norma un pool proprio, mentre handler più vecchi o configurazioni personalizzate possono far girare tutto con l’utente del server web.
Il primo passo di ogni analisi su hosting condiviso è quindi disegnare la mappa: quali siti esistono, sotto quale utente, con quale processo PHP. Un comando utile per vedere con quali utenti girano i processi PHP-FPM:
# Processi PHP-FPM raggruppati per utente e per pool
ps -eo user:20,args | grep '[p]hp-fpm: pool' | sort | uniq -c
# Pool definiti nella configurazione (percorso tipico su Debian e Ubuntu)
grep -H -E '^\[|^user|^listen ' /etc/php/*/fpm/pool.d/*.conf
Le installazioni dimenticate
Nelle contaminazioni incrociate che analizziamo, il punto d’ingresso è molto spesso un sito che il proprietario non ricordava di avere. Le forme sono sempre le stesse: una copia di staging creata anni fa in public_html/staging o public_html/test, la versione precedente del sito rinominata in old o vecchio dopo un rifacimento, un’installazione di prova lasciata a metà, un archivio .zip del sito con dentro un wp-config.php leggibile, un dominio scaduto il cui WordPress è ancora sul disco.
Queste copie hanno tre caratteristiche che le rendono ideali per un attacco: nessuno le aggiorna, contengono spesso plugin nel frattempo diventati vulnerabili e, se sono raggiungibili dal web anche solo tramite il percorso del dominio principale, possono essere scansionate come qualsiasi altro sito. Una volta dentro, l’attaccante si trova nella stessa home dei siti curati e aggiornati.
Per fare l’inventario non ci si può fidare dell’elenco dei domini nel pannello: bisogna cercare le installazioni sul disco. I comandi seguenti trovano tutti i WordPress presenti in una home, gli archivi che potrebbero contenere credenziali e la versione del core di ciascuna installazione:
# Tutte le installazioni WordPress sotto la home dell'account
find /home/cliente -name wp-config.php -not -path '*/node_modules/*' 2>/dev/null
# Versione del core di ciascuna
find /home/cliente -path '*/wp-includes/version.php' -exec grep -H '^\$wp_version' {} \;
# Archivi e dump dimenticati nelle cartelle pubbliche
find /home/cliente -type f \( -name '*.zip' -o -name '*.tar.gz' -o -name '*.sql' -o -name '*.sql.gz' \) \
-path '*public_html*' -printf '%TY-%Tm-%Td %10s %p\n' | sort
Permessi, proprietari e open_basedir
Quando i siti appartengono a utenti diversi, la separazione regge solo se i permessi la rispettano. Una home con permessi 755 permette a tutti gli utenti del server di elencarne il contenuto; un wp-config.php con permessi 644 è leggibile da chiunque possa raggiungerlo, e con esso le credenziali del database. Le regole di base sono semplici: home con 711 o 750 (secondo come il server web accede ai file), cartelle a 755, file a 644 e wp-config.php a 600 o 640, sempre con il proprietario corretto. Ne abbiamo parlato nel dettaglio nell’articolo sull’hardening di wp-config e dei permessi.
Un file con permessi 777 o una cartella scrivibile da tutti annulla qualsiasi separazione tra utenti. Trovarli è immediato:
# File e cartelle scrivibili da chiunque
find /home -xdev \( -type f -o -type d \) -perm -0002 -not -type l 2>/dev/null
# File dentro una home che non appartengono al suo utente
find /home/cliente -not -user cliente -printf '%u %p\n' 2>/dev/null | head -50
# wp-config.php leggibili dal resto del mondo
find /home -name wp-config.php -perm -0004 2>/dev/null
Poi c’è open_basedir, la direttiva di PHP che limita i percorsi a cui le funzioni di file di PHP possono accedere. Impostata per ogni sito sulla sua cartella, impedisce a uno script di aprire con fopen() o include i file di un altro sito. È utile, ma va capito il suo limite: vale solo per le funzioni di PHP. Un comando lanciato con exec(), system() o proc_open() è un processo esterno e ignora completamente open_basedir. Da sola non è quindi un confine di sicurezza: è una rete in più, che funziona davvero solo se affiancata da utenti separati e, dove possibile, da disable_functions sulle funzioni di esecuzione.
Un ultimo dettaglio spesso trascurato sono le cartelle condivise: /tmp e la cartella delle sessioni PHP. Se tutti i siti scrivono lì, un sito compromesso può leggere i file di sessione degli altri o lasciarvi payload da includere. Ogni pool dovrebbe avere upload_tmp_dir e session.save_path propri, dentro la home del sito.
Un pool PHP-FPM per ogni sito
Sui server che gestiamo direttamente, o su un VPS, la soluzione strutturale è dare a ogni sito un utente di sistema e un pool PHP-FPM dedicati. Il server web (NGINX, Apache con mod_proxy_fcgi o LiteSpeed) passa le richieste PHP al socket del pool giusto, e ogni pool gira con il proprio utente e le proprie restrizioni. Un esempio di pool per un sito:
; /etc/php/8.3/fpm/pool.d/sito1.conf
[sito1]
user = sito1
group = sito1
listen = /run/php/php8.3-fpm-sito1.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 30s
php_admin_value[open_basedir] = /home/sito1/:/usr/share/php/
php_admin_value[upload_tmp_dir] = /home/sito1/tmp
php_admin_value[session.save_path] = /home/sito1/sessions
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
php_admin_flag[allow_url_include] = off
Le direttive impostate con php_admin_value non possono essere cambiate dal sito tramite ini_set() o un file .user.ini, ed è questo che le rende affidabili. L’elenco di disable_functions va adattato: alcuni plugin di backup o di ottimizzazione delle immagini usano funzioni di esecuzione e vanno provati prima in staging. Sul lato NGINX, il blocco del virtual host punta al socket del pool:
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm-sito1.sock;
}
Con Apache, oltre al pool, conviene sostituire Options FollowSymLinks con SymLinksIfOwnerMatch: un attacco classico degli ambienti condivisi consiste nel creare, dal sito compromesso, un collegamento simbolico al wp-config.php di un altro utente e farlo servire dal server web come testo. Su hosting condivisi commerciali questi problemi sono spesso risolti da soluzioni come CloudLinux con CageFS, che dà a ogni utente un file system virtualizzato; vale comunque la pena di verificare cosa offre il proprio provider, invece di darlo per scontato.
Perché pulire un sito alla volta non funziona
Torniamo all’agenzia dell’inizio. Il problema non era la qualità della pulizia sui singoli siti, ma l’ordine delle operazioni. Mentre si ripuliva il primo sito, gli altri, ancora infetti, avevano accesso in scrittura agli stessi file: bastava una richiesta a una delle loro backdoor, o un’attività pianificata, per reinfettare quello appena sistemato. Ogni intervento parziale era destinato a durare poche ore.
Ci sono poi elementi che appartengono all’utente e non al sito, e che quindi nessuna pulizia del singolo WordPress tocca: il crontab dell’utente di sistema, i processi PHP rimasti in memoria anche dopo la cancellazione del file che li aveva avviati, le chiavi SSH in ~/.ssh/authorized_keys, i file .bashrc o .profile modificati, i file .htaccess e .user.ini nelle cartelle superiori, che valgono per tutti i siti sottostanti. Un auto_prepend_file impostato in un .user.ini nella radice di public_html, per esempio, fa eseguire un file PHP prima di ogni script di ogni sito sotto quella cartella.
# Direttive PHP e .htaccess che valgono per più siti
find /home/cliente -maxdepth 3 \( -name '.user.ini' -o -name 'php.ini' -o -name '.htaccess' \) \
-exec grep -H -i -E 'auto_prepend_file|auto_append_file|php_value' {} \;
# File PHP modificati di recente in tutta la home, non in un solo sito
find /home/cliente -name '*.php' -newermt '2026-08-01' -printf '%TY-%Tm-%Td %TH:%TM %p\n' | sort
# Integrità del core di ogni installazione trovata
for cfg in $(find /home/cliente -name wp-config.php); do
d=$(dirname "$cfg"); echo "== $d"
sudo -u cliente wp --path="$d" core verify-checksums --quiet || true
done
Anche i database meritano attenzione: se tutti i siti usano lo stesso utente MySQL, chi ha letto le credenziali di un sito può scrivere nei database degli altri, per esempio creando amministratori fantasma o inserendo payload nelle opzioni. Ogni sito dovrebbe avere il proprio utente del database con privilegi limitati al proprio schema.
Un piano di isolamento in sei passaggi
Una bonifica di un account condiviso va trattata come un intervento unico su tutto il perimetro dell’utente, non come una serie di interventi sui singoli siti. Lo schema che seguiamo è questo:
- Inventario. Tutte le installazioni sul disco, tutti i database, tutti gli utenti di sistema e i pool coinvolti, i domini che puntano all’account. Le copie che non servono più si archiviano fuori dalla cartella pubblica e poi si eliminano.
- Congelamento. Si fermano i punti di persistenza comuni: crontab dell’utente, processi in esecuzione, chiavi SSH non riconosciute. Dove possibile si mette l’account in manutenzione, così nessuna backdoor può essere richiamata durante il lavoro.
- Pulizia simultanea. Tutti i siti dello stesso utente vengono puliti nella stessa finestra di intervento: core e plugin sostituiti con copie originali, file estranei rimossi, database controllati, prima di riaprire qualsiasi sito.
- Separazione. Un utente di sistema, un pool PHP-FPM e un utente del database per ogni sito, con
open_basedir, cartelle temporanee e sessioni proprie; su cPanel e Plesk, account o sottoscrizioni distinti. - Credenziali. Si cambiano tutte le password dopo la pulizia, non prima: database, amministratori WordPress, FTP e SFTP, pannello di hosting; si rigenerano i salt di ogni
wp-config.php. - Monitoraggio. Controllo dell’integrità dei file e dei log per le settimane successive, perché una reinfezione, se arriva, arriva nei primi giorni.
L’ordine conta. Cambiare le password prima di aver rimosso le backdoor è inutile, perché l’attaccante non ne ha bisogno per rientrare; separare gli utenti prima di pulire significa distribuire file infetti in account nuovi; pulire senza aver fermato il cron significa lavorare mentre qualcuno rimette tutto com’era.
Quando l’hosting non si può cambiare
Non sempre si ha il controllo del server. Su un hosting condiviso economico l’utente è uno solo e non si possono creare pool; in quel caso bisogna lavorare sulla superficie d’attacco: ridurre al minimo i siti nello stesso account, eliminare ogni installazione non indispensabile, bloccare l’esecuzione di PHP nelle cartelle di caricamento, tenere aggiornato tutto ciò che resta e togliere i plugin abbandonati. Se i siti sono di clienti diversi, la scelta più sensata è spostarli in account separati: il costo di un account in più è quasi sempre inferiore a quello di una reinfezione a catena.
Quando interveniamo su un’infezione, la prima domanda che ci facciamo è proprio questa: quanti siti condividono lo stesso utente? Lavoriamo a livello di sistema, non solo di applicazione, quindi controlliamo crontab, processi, permessi e configurazione PHP dell’intero account, oltre ai file di WordPress. La pulizia garantita in 6, 12 o 24 ore si intende per ogni installazione WordPress, e su un account condiviso comprende l’analisi degli elementi comuni che potrebbero reinfettarla. Se gestisci più siti sullo stesso hosting e uno di questi è stato compromesso, contattaci prima di iniziare a ripulirli uno per uno.