Una rete WordPress Multisite è comoda da amministrare: un solo core, un solo insieme di plugin e temi, un solo pannello da cui gestire decine o centinaia di siti. È la scelta tipica di università, gruppi editoriali, catene di negozi e reti di agenti. La stessa architettura che semplifica la gestione, però, cambia radicalmente il modo in cui si propaga un’infezione e il modo in cui la si rimuove.
Su un’installazione singola il perimetro è chiaro. Su una rete, il sottosito che mostra il redirect può non essere quello da cui è entrato l’attacco, la backdoor può stare in un plugin attivato su tutta la rete o in una tabella che nessuno guarda, e spegnere tutto per pulire significa fermare il lavoro di decine di redazioni o punti vendita. In questo articolo vediamo come è fatta una rete dal punto di vista di chi deve bonificarla, come censirla con WP-CLI, come contenere un sito compromesso senza bloccare gli altri e in che ordine procedere.
Come è fatta una rete multisite
Tutti i siti di una rete condividono la stessa cartella di WordPress sul disco: stesso wp-config.php, stesso core, stessa cartella wp-content/plugins e wp-content/themes. Ciò che li distingue sta quasi tutto nel database, che ha tre gruppi di tabelle.
- Tabelle di rete:
wp_sitedescrive la rete,wp_sitemetane contiene le impostazioni (tra cui l’elenco dei super amministratori e dei plugin attivati sulla rete),wp_blogselenca i siti con dominio, percorso e stato,wp_blogmetaconserva i metadati dei singoli siti,wp_signupsewp_registration_logle registrazioni. - Tabelle degli utenti:
wp_usersewp_usermetasono uniche per tutta la rete. Un utente esiste una volta sola, e il suo ruolo su ciascun sito è salvato in una chiave diversa:wp_capabilitiesper il sito principale,wp_2_capabilitiesper il sito 2 e così via. - Tabelle dei singoli siti: il sito principale usa le tabelle con il prefisso base (
wp_posts,wp_options…), ogni altro sito ha le proprie con il numero nel prefisso:wp_2_posts,wp_2_options,wp_2_postmeta, fino awp_N_*.
Anche i file caricati sono separati: quelli del sito principale finiscono in wp-content/uploads, quelli degli altri siti in wp-content/uploads/sites/N. Infine c’è la mappatura dei domini: dalla versione 4.5 WordPress permette di assegnare a un sottosito un dominio qualsiasi, salvato nella colonna domain di wp_blogs. Le configurazioni più vecchie, o più complesse, usano ancora il drop-in wp-content/sunrise.php, caricato molto presto quando in wp-config.php è definita la costante SUNRISE: un file da controllare con attenzione, perché viene eseguito prima di qualsiasi plugin.
Perché un sottosito infetto mette a rischio tutta la rete
L’idea che un sito della rete sia isolato dagli altri è l’equivoco più pericoloso. La separazione delle tabelle è organizzativa, non di sicurezza: tutti i siti girano con lo stesso utente di sistema, nello stesso pool PHP-FPM, e si collegano al database con le stesse credenziali di wp-config.php. Un attaccante che ottiene l’esecuzione di codice PHP su un qualsiasi sottosito, per esempio sfruttando una vulnerabilità in un plugin attivo solo lì, può leggere e modificare i file di tutti gli altri siti e ogni tabella del database, comprese quelle degli utenti e delle impostazioni di rete.
Il codice, poi, è condiviso per costruzione. Un plugin installato sulla rete è presente sul disco per tutti i siti, anche se è attivato su uno solo, e un file PHP infetto al suo interno è raggiungibile direttamente con una richiesta HTTP da qualsiasi dominio della rete. Un plugin attivato sulla rete, elencato nell’opzione active_sitewide_plugins di wp_sitemeta, viene caricato su ogni sito a ogni richiesta; lo stesso vale per i file in mu-plugins, di cui abbiamo parlato nell’articolo sui mu-plugins e i drop-in usati come backdoor.
Infine ci sono i privilegi. In una rete solo i super amministratori possono installare plugin e temi, modificarne il codice e inserire HTML non filtrato nei contenuti: un amministratore di un singolo sito non ha la capacità unfiltered_html. È una protezione utile, ma significa anche che l’obiettivo di un attacco è diventare super amministratore, e che basta una riga in wp_sitemeta per ottenerlo. Il problema è lo stesso visto sui server condivisi, in forma più concentrata: la compromissione di uno è la compromissione di tutti.
L’inventario della rete con WP-CLI
Prima di cercare il malware bisogna sapere cosa c’è. Dopo una copia forense di file, database e log, il primo passo è un inventario completo, che su una rete con molti siti si fa solo da riga di comando. WP-CLI lavora di default sul sito principale; per agire su un sottosito si aggiunge il parametro --url.
# Tutti i siti, con stato e date
wp site list --fields=blog_id,url,registered,last_updated,archived,spam,deleted
# Super amministratori della rete
wp super-admin list
# Plugin attivati sulla rete e mu-plugins
wp plugin list --status=active-network
wp plugin list --status=must-use
# Tutti gli utenti della rete, con data di registrazione
wp user list --network --fields=ID,user_login,user_email,user_registered
# Integrità del core e dei plugin del repository ufficiale
wp core verify-checksums
wp plugin verify-checksums --all
I siti registrati di recente, o con una data di ultimo aggiornamento anomala, meritano attenzione: alcune campagne creano nuovi sottositi da usare come contenitori di pagine di spam, sfruttando la fiducia che il dominio principale ha presso i motori di ricerca. Allo stesso modo, un super amministratore che nessuno riconosce è un segnale da solo sufficiente a trattare l’intera rete come compromessa.
I plugin attivati sui singoli siti non compaiono nell’elenco di rete: ogni sito ha la sua opzione active_plugins nella propria tabella di opzioni. Per ricostruire il quadro completo bisogna interrogare i siti uno per uno.
Controllare i siti uno per uno
Il ciclo su wp site list --field=url è lo strumento di lavoro principale. Per ogni sito si raccolgono gli stessi elementi e si confrontano: le differenze tra siti che dovrebbero essere simili sono spesso più eloquenti di qualsiasi firma.
for url in $(wp site list --field=url); do
echo "== $url"
# Indirizzi del sito: un home diverso da quello atteso è un redirect
wp option get home --url="$url"
# Plugin attivi solo su questo sito
wp plugin list --status=active --field=name --url="$url"
# Amministratori del singolo sito
wp user list --role=administrator --fields=user_login,user_registered --url="$url"
# Contenuti con script o iframe
wp post list --post_type=any --post_status=any --s='<script' \
--fields=ID,post_type,post_modified --url="$url"
done 2>&1 | tee inventario-siti.txt
Per il database conviene una ricerca unica su tutte le tabelle di opzioni e di contenuti, che con molti siti sarebbe lenta da fare con un ciclo. L’elenco delle tabelle si ricava da information_schema, poi si cercano gli indicatori tipici: tag <script, funzioni come eval( o base64_decode(, domini sconosciuti. WP-CLI offre anche una scorciatoia: wp search-replace con --network --dry-run e un termine di ricerca mostra in quali tabelle di tutta la rete compare, senza modificare nulla.
# Tabelle di opzioni di tutti i siti
wp db query "SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name REGEXP '^wp_([0-9]+_)?options$'"
# In quali tabelle della rete compare un dominio sospetto
wp search-replace 'cdn-stat.example.invalid' 'x' --network --dry-run --report-changed-only
# Opzioni caricate automaticamente con codice sospetto, sito 7
wp db query "SELECT option_name, LENGTH(option_value) FROM wp_7_options
WHERE option_value REGEXP 'eval[(]|base64_decode|<script'"
Sul disco, le cartelle uploads/sites/N vanno controllate tutte alla ricerca di file PHP, che in una cartella di caricamenti non dovrebbero esistere. Un find wp-content/uploads -name '*.php' copre in un colpo solo il sito principale e tutti i sottositi. Le tecniche per riconoscere i payload nascosti nelle opzioni sono quelle descritte nell’articolo sui payload serializzati in wp_options, con l’unica differenza che qui le tabelle da esaminare sono decine.
Super amministratori e ruoli: i privilegi che contano
L’elenco dei super amministratori è un array serializzato di nomi utente salvato nella chiave site_admins di wp_sitemeta. Non dipende dai ruoli: un utente con ruolo «sottoscrittore» su tutti i siti è super amministratore se il suo login compare in quell’elenco. È un punto che sfugge a chi controlla solo gli amministratori dei singoli siti, e che un attaccante con accesso al database può modificare con una sola query.
# Il valore grezzo, da confrontare con wp super-admin list
wp db query "SELECT meta_value FROM wp_sitemeta WHERE meta_key = 'site_admins'"
# Chi ha un ruolo su quali siti: tutte le chiavi *_capabilities
wp db query "SELECT u.user_login, m.meta_key, m.meta_value
FROM wp_usermeta m JOIN wp_users u ON u.ID = m.user_id
WHERE m.meta_key REGEXP '^wp_([0-9]+_)?capabilities$'
AND m.meta_value LIKE '%administrator%'"
# Rimuovere un super amministratore illegittimo
wp super-admin remove utente-sospetto
Come per le installazioni singole, gli amministratori fantasma vanno cercati anche tra gli utenti apparentemente innocui, nelle password per le applicazioni e nelle sessioni attive. In una rete, però, la rimozione di un utente va fatta con attenzione: wp user delete con --network lo elimina da tutta la rete, e senza --reassign i contenuti che aveva creato nei siti rischiano di sparire con lui.
Contenere senza fermare la rete: archiviare o disattivare
Quando un sottosito serve malware ai visitatori, la reazione istintiva è spegnere tutto. Su una rete si può fare di meglio: WP-CLI e il pannello di rete permettono di archiviare un sito (wp site archive 7), di disattivarlo (wp site deactivate 7) o di segnarlo come spam. In tutti e tre i casi, i visitatori che non sono super amministratori ricevono un messaggio di sito sospeso o non disponibile, e gli altri siti continuano a funzionare.
Bisogna però sapere esattamente cosa blocca questa misura. Il controllo sullo stato del sito, la funzione ms_site_check(), viene eseguito in wp-settings.php dopo l’azione init: quando interviene, sono già stati caricati i mu-plugins, i plugin di rete, i plugin attivi sul sito e il functions.php del tema. Archiviare un sito impedisce che le sue pagine vengano mostrate, e quindi ferma uno script iniettato nei contenuti o nelle opzioni; non impedisce a una backdoor PHP in un plugin o nel tema di rispondere alle richieste che la attivano. E i file PHP raggiungibili direttamente, come una webshell in uploads/sites/7, restano eseguibili qualunque sia lo stato del sito.
Per un contenimento reale, l’archiviazione va quindi accompagnata da misure a livello di server: il blocco dell’esecuzione di PHP nelle cartelle di caricamento, come spiegato nell’articolo sulle webshell in uploads, la disattivazione sul sito compromesso dei plugin sospetti, e se serve una regola nel server web che risponda con una pagina statica per il solo dominio coinvolto. Il resto della rete resta online, e il danno per i visitatori si ferma.
Il piano di bonifica, nell’ordine giusto
Una volta contenuto il problema e raccolte le prove, la bonifica di una rete segue un ordine preciso, pensato per non lasciare punti da cui l’infezione possa tornare mentre si lavora sul resto.
- Il livello condiviso. Core reinstallato dai file originali,
wp-config.phpesunrise.phpcontrollati riga per riga,mu-pluginssvuotato di tutto ciò che non ha un proprietario noto, plugin e temi sostituiti con copie integre scaricate dalle fonti ufficiali. È il passaggio che protegge tutti i siti insieme. - Il livello di rete nel database.
site_adminseactive_sitewide_pluginsinwp_sitemeta, l’elenco dei siti inwp_blogscon particolare attenzione ai siti recenti e ai domini modificati, gli utenti inwp_userse le relative capacità. - I singoli siti. Per ciascuno, opzioni, contenuti, widget e caricamenti, partendo da quelli in cui l’inventario ha trovato anomalie ma senza saltare quelli apparentemente puliti. Qui il ciclo sui siti torna utile per applicare le stesse verifiche a tutti.
- Il sistema. Cron dell’utente, processi in esecuzione, trigger nel database: una rete grande ha più tabelle in cui nascondere un trigger e più traffico in cui confondere un processo residente.
- Le credenziali. Password del database, chiavi e salt in
wp-config.php(che chiudono le sessioni su tutti i siti in un colpo solo), password dei super amministratori e accessi SSH e SFTP.
Solo alla fine il sito archiviato torna attivo con wp site unarchive o wp site activate, dopo una verifica dall’esterno con un browser senza sessione. Se qualche sito della rete era finito nelle liste di Google, la richiesta di revisione va fatta per ogni proprietà di Search Console coinvolta, come spiegato nell’articolo sulla rimozione dalle blacklist.
Dopo la bonifica: una rete più difficile da colpire
Una rete accumula plugin nel tempo: ogni redazione o filiale ne chiede uno, e dopo qualche anno la cartella contiene decine di componenti, molti attivi su un solo sito o su nessuno. Ognuno è codice eseguibile raggiungibile da tutti i domini. La prima misura è la più semplice: rimuovere dal disco ciò che non è attivo, e rivedere periodicamente ciò che lo è.
Il numero di super amministratori va ridotto al minimo, con l’autenticazione a due fattori obbligatoria per tutti. In wp-config.php, DISALLOW_FILE_EDIT impedisce di modificare il codice dal pannello anche a chi ottiene quei privilegi. Sul server, un monitoraggio dell’integrità dei file e un controllo periodico di site_admins e dell’elenco dei siti segnalano le modifiche prima che diventino un’infezione diffusa. Le altre misure sono quelle della checklist di hardening dopo la bonifica, applicate una volta per tutta la rete.
Le reti multisite sono tra gli interventi più delicati che affrontiamo, proprio perché richiedono di lavorare contemporaneamente a livello di applicazione, di database e di sistema, senza interrompere siti che non hanno colpa. La pulizia garantita in 6, 12 o 24 ore comprende tutti e tre i livelli, dai file condivisi alle tabelle dei singoli siti fino ai processi del server. Se la tua rete mostra segni di infezione su uno o più siti, scrivici prima di spegnerla: nella maggior parte dei casi si può contenere il problema lasciando online tutto il resto.