WordPress Multisite infetto: bonificare una rete di siti senza fermarla

Tabelle per sito, super amministratori e plugin di rete: come censire, contenere e ripulire una rete WordPress Multisite infetta senza spegnere gli altri siti.

Una rete di sei finestre di siti collegate a un nodo centrale; una delle finestre è evidenziata in rosso e circondata da un perimetro tratteggiato, mentre le altre restano blu e attive

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_site descrive la rete, wp_sitemeta ne contiene le impostazioni (tra cui l’elenco dei super amministratori e dei plugin attivati sulla rete), wp_blogs elenca i siti con dominio, percorso e stato, wp_blogmeta conserva i metadati dei singoli siti, wp_signups e wp_registration_log le registrazioni.
  • Tabelle degli utenti: wp_users e wp_usermeta sono uniche per tutta la rete. Un utente esiste una volta sola, e il suo ruolo su ciascun sito è salvato in una chiave diversa: wp_capabilities per il sito principale, wp_2_capabilities per 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 a wp_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.

Schema a livelli di una rete WordPress Multisite. In alto il livello dei file condivisi da tutti i siti: wp-config.php, core, sunrise.php, mu-plugins, plugins e themes. Al centro il database, diviso in tabelle di rete (wp_site, wp_sitemeta con site_admins e active_sitewide_plugins, wp_blogs, wp_blogmeta), tabelle utenti condivise (wp_users e wp_usermeta con wp_capabilities, wp_2_capabilities, wp_3_capabilities) e tabelle per sito (wp_posts e wp_options per il sito 1, wp_2_posts e wp_2_options per il sito 2, wp_3_posts e wp_3_options per il sito 3). In basso le cartelle dei caricamenti: uploads per il sito 1, uploads/sites/2 e uploads/sites/3. Il sito 3 è evidenziato in rosso come compromesso: frecce tratteggiate mostrano che da lì si raggiungono sia i file condivisi sia le tabelle di rete, come site_admins, perché tutti i siti usano lo stesso utente di sistema e le stesse credenziali del database.
File e utenti sono condivisi da tutta la rete; contenuti, opzioni e caricamenti sono separati per sito.

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.

Sequenza di caricamento di una richiesta a un sottosito archiviato. Da sinistra a destra: wp-config.php e sunrise.php, mu-plugins, plugin attivati sulla rete, plugin del sito, functions.php del tema, azione init, e solo a quel punto ms_site_check che per un sito archiviato risponde con il codice 410 e il messaggio di sito sospeso. Tutti i passaggi prima del controllo sono evidenziati in rosso come codice che viene comunque eseguito; il rendering della pagina, con gli script iniettati nei contenuti, è barrato in verde come bloccato. A parte, una webshell in uploads/sites/7 chiamata direttamente non passa da WordPress e resta eseguibile.
Archiviare un sito ferma le pagine, non il codice: plugin, tema e file PHP diretti vengono eseguiti prima del controllo sullo stato.

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.

  1. Il livello condiviso. Core reinstallato dai file originali, wp-config.php e sunrise.php controllati riga per riga, mu-plugins svuotato 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.
  2. Il livello di rete nel database. site_admins e active_sitewide_plugins in wp_sitemeta, l’elenco dei siti in wp_blogs con particolare attenzione ai siti recenti e ai domini modificati, gli utenti in wp_users e le relative capacità.
  3. 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.
  4. 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.
  5. 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.
Piano di bonifica di una rete multisite in cinque fasi da sinistra a destra: livello condiviso (core, wp-config, sunrise.php, mu-plugins, plugin e temi), livello di rete nel database (site_admins, active_sitewide_plugins, wp_blogs, utenti), singoli siti in ciclo da 1 a N (opzioni, contenuti, widget, uploads/sites/N), sistema (cron, processi, trigger SQL) e credenziali (database, salt, super amministratori, SSH). Sotto le fasi, una barra indica che il sito compromesso resta archiviato fino alla fase tre mentre gli altri siti restano online per tutta la durata.
Prima ciò che è condiviso, poi la rete, poi i siti uno per uno: gli altri siti restano online per tutto il tempo.

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.

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 ↗