Backdoor nel database: payload serializzati in wp_options e come trovarli

Widget, theme_mods e opzioni in autoload: come si nascondono i payload serializzati in wp_options e come rimuoverli senza rompere le lunghezze dei valori.

Una finestra di terminale con il valore serializzato di un widget, in cui la riga di uno script iniettato è evidenziata in rosso sotto una lente d’ingrandimento, accanto a un cilindro di database con l’etichetta wp_options

I file del sito sono stati confrontati con gli originali, il core verificato, i plugin reinstallati da zero. Eppure nel footer compare ancora uno script che nessuno ha scritto, e in fondo a ogni pagina c’è un blocco di link nascosti. Quando i file sono puliti e il sintomo resta, la risposta è quasi sempre nel database, e in particolare in una tabella: wp_options.

Un payload nel database sopravvive a qualsiasi reinstallazione dei file, e se è dentro un valore serializzato non si vede nemmeno con una ricerca testuale fatta in fretta.

Nell’articolo sui trigger SQL e il malware nel database abbiamo visto le query di base per cercare codice sospetto nelle opzioni. Qui scendiamo di un livello: com’è fatto il formato di serializzazione di PHP, perché le correzioni fatte con un semplice cerca e sostituisci rompono il sito, dove si annidano i payload (widget, theme_mods, opzioni caricate automaticamente) e come ripulirli senza corrompere i dati.

Il formato serializzato di PHP

WordPress salva in wp_options molti valori che non sono semplici stringhe: array di impostazioni, elenchi di widget, configurazioni di plugin. Per memorizzarli in una colonna di testo usa la funzione serialize() di PHP, e li ricostruisce con maybe_unserialize() quando li legge. Il risultato è una stringa in cui ogni valore è preceduto dal suo tipo e, per le stringhe, dalla sua lunghezza:

a:2:{s:5:"title";s:7:"Contatti";s:4:"text";s:22:"Via Roma 1, 00100 Roma";}

Letto un pezzo alla volta: a:2:{…} è un array di due elementi; s:5:"title" è una stringa di cinque byte usata come chiave; s:7:"Contatti"… e qui c’è già un errore voluto, perché «Contatti» ha otto caratteri, non sette. Una stringa serializzata con la lunghezza sbagliata non si può più deserializzare: unserialize() restituisce false e l’intero valore, non solo quella chiave, è perso. Gli altri tipi che si incontrano sono i: per gli interi, d: per i decimali, b: per i booleani, N; per il valore nullo e O: per gli oggetti, con il nome della classe e le proprietà.

Un dettaglio decisivo: la lunghezza è espressa in byte, non in caratteri. Una stringa con lettere accentate in UTF-8 occupa più byte che caratteri, e «perché» vale s:7, non s:6. È il motivo per cui chi corregge a mano un valore serializzato sbaglia spesso anche contando con attenzione.

Anatomia del valore serializzato dell’opzione widget_custom_html. La stringa a:1:{i:3;a:2:{s:5:"title";s:8:"Contatti";s:7:"content";s:87:"…";}} è scomposta in blocchi colorati con didascalie: tipo array e numero di elementi, indice del widget, chiave stringa con la sua lunghezza in byte, valore. L’ultimo valore è formato da 26 byte di testo del widget e da 61 byte di uno script iniettato, evidenziato in rosso: la lunghezza dichiarata s:87 comprende entrambi. Una nota ricorda che la lunghezza è in byte e che «perché» vale s:7.
Ogni stringa serializzata dichiara la propria lunghezza in byte: lo script iniettato ne fa parte, e toglierlo cambia il numero.

Perché il cerca e sostituisci rompe il sito

Il riflesso più comune davanti a uno script iniettato è esportare il database, aprire il dump con un editor o con sed, cancellare lo script ovunque compaia e reimportare. Sui contenuti di wp_posts funziona, perché lì il testo è salvato in chiaro. Su un valore serializzato, invece, rimuovere i 61 byte di uno script senza correggere il numero che li precede produce una stringa incoerente: WordPress non riesce più a leggerla, get_option() restituisce il valore grezzo o false, e il risultato visibile è una sidebar vuota, un tema che torna alle impostazioni predefinite, un plugin che chiede di essere riconfigurato.

Peggio ancora, alcuni plugin che trovano un’opzione illeggibile la sovrascrivono con i valori predefiniti, e a quel punto i dati originali sono persi. Per questo, prima di qualsiasi intervento sul database, serve un dump completo conservato fuori dal sito, e ogni modifica ai valori serializzati va fatta con strumenti che li capiscono.

Il più diffuso è wp search-replace di WP-CLI, che per ogni valore serializzato lo deserializza, applica la sostituzione e lo riserializza con le lunghezze corrette. Va sempre provato prima in modalità simulata:

# Backup completo, fuori dalla cartella pubblica
wp db export ~/backup/prima-della-pulizia.sql

# Simulazione: quante righe cambierebbero, e in quali tabelle
wp search-replace '<script src="https://cdn-stat.example.invalid/s.js"></script>' '' \
  --all-tables-with-prefix --precise --dry-run --report-changed-only

# Solo dopo aver controllato l'elenco, la sostituzione vera
wp search-replace '<script src="https://cdn-stat.example.invalid/s.js"></script>' '' \
  --all-tables-with-prefix --precise --report-changed-only

Il limite di questo approccio è che funziona solo se lo script è identico ovunque. Molte campagne generano varianti per ogni sito o per ogni opzione, con parametri diversi o frammenti offuscati; lì serve un’analisi valore per valore, di cui parliamo più avanti.

Confronto tra due percorsi di pulizia dello stesso valore serializzato. In alto, sed sul dump SQL toglie lo script ma lascia s:87 davanti a una stringa di 26 byte: unserialize restituisce false e il widget sparisce, segnato con una croce rossa. In basso, WP-CLI o PHP deserializzano, modificano l’array e riserializzano: la lunghezza diventa s:26 e il widget resta al suo posto, segnato con una spunta verde.
Lo stesso script rimosso in due modi: con un editor di testo la lunghezza resta sbagliata, con uno strumento che deserializza viene ricalcolata.

Autoload: le opzioni lette a ogni richiesta

La colonna autoload di wp_options indica se un’opzione va caricata in memoria all’avvio di WordPress, insieme a tutte le altre, con un’unica query. Per un attaccante è una caratteristica comoda: un’opzione caricata automaticamente è sempre disponibile, senza query aggiuntive che possano comparire in un profiler, e una backdoor nei file può leggerla con un semplice get_option() ed eseguirne il contenuto. Il file sul disco resta minuscolo e innocuo all’apparenza, mentre il codice vero vive nel database.

Da WordPress 6.6 la colonna non contiene più solo yes e no, ma anche on, off, auto, auto-on e auto-off; le query di ricerca devono tenerne conto. Un primo esame utile è l’elenco delle opzioni caricate automaticamente ordinate per dimensione, con le più recenti in evidenza:

-- Opzioni caricate a ogni richiesta, dalla più grande
SELECT option_id, option_name, autoload, LENGTH(option_value) AS byte
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')
ORDER BY byte DESC
LIMIT 40;

-- Le ultime opzioni create: gli ID crescono con gli inserimenti
SELECT option_id, option_name, autoload, LENGTH(option_value) AS byte
FROM wp_options
ORDER BY option_id DESC
LIMIT 40;

La seconda query è sottovalutata: wp_options non ha una data di modifica, ma option_id è un contatore crescente, e un’opzione dal nome anonimo creata poco prima dell’infezione salta subito all’occhio accanto ai transient del giorno. Nomi da guardare con sospetto sono quelli casuali, quelli che imitano il core con piccole differenze (wp_core_cache, _site_updates_data) e quelli che non appartengono a nessun plugin installato. Con WP-CLI lo stesso controllo si fa così:

# Opzioni in autoload con la dimensione in byte
wp option list --autoload=on --fields=option_name,size_bytes --format=table

# Contenuto di un'opzione sospetta, deserializzato e leggibile
wp option get nome_opzione_sospetta --format=json

Widget, theme_mods e CSS aggiuntivo

Oltre alle opzioni create apposta, il malware ama modificare quelle esistenti che finiscono direttamente nell’HTML della pagina. Le più colpite sono tre famiglie.

I widget. Ogni tipo di widget ha un’opzione widget_* che contiene, serializzati, tutti i widget di quel tipo: widget_text, widget_custom_html e, con l’editor a blocchi dei widget introdotto in WordPress 5.8, widget_block. Un widget HTML con uno script in fondo viene stampato in ogni pagina che mostra la sidebar o il footer. Quale widget sia effettivamente visibile e dove lo dice un’altra opzione, sidebars_widgets; un widget infetto può anche essere stato spostato in wp_inactive_widgets e restare pronto a essere riattivato.

I theme_mods. Le personalizzazioni del tema fatte dal Customizer sono salvate in theme_mods_{nome-del-tema}. Molti temi commerciali offrono campi per «script nell’header» o «codice di tracciamento» che finiscono proprio qui e vengono stampati senza filtri. Un attaccante con accesso al database non deve fare altro che aggiungere il proprio codice accanto a quello di Google Analytics. Vanno controllate anche le opzioni del tema non attivo, perché un cambio di tema futuro le riattiverebbe.

Il CSS aggiuntivo. Il CSS inserito dal Customizer non sta in wp_options ma in wp_posts, con tipo custom_css; theme_mods contiene solo il riferimento custom_css_post_id. Il CSS non esegue codice, ma è usato per nascondere blocchi di link di spam SEO fuori dallo schermo, visibili ai motori di ricerca e non ai visitatori.

-- Widget, personalizzazioni del tema e sidebar
SELECT option_name, LENGTH(option_value) AS byte
FROM wp_options
WHERE option_name LIKE 'widget\_%'
   OR option_name LIKE 'theme\_mods\_%'
   OR option_name = 'sidebars_widgets'
ORDER BY byte DESC;

-- Script, iframe e decodifiche in questi valori
SELECT option_name FROM wp_options
WHERE (option_name LIKE 'widget\_%' OR option_name LIKE 'theme\_mods\_%')
  AND option_value REGEXP '<script|<iframe|atob\\(|fromCharCode|document\\.write|display: ?none';

-- CSS aggiuntivo con posizionamenti fuori schermo
SELECT ID, post_name, post_modified FROM wp_posts
WHERE post_type = 'custom_css'
  AND post_content REGEXP 'left: ?-[0-9]{3,}px|text-indent: ?-[0-9]{3,}';

Oggetti serializzati e payload codificati

Ci sono due forme di payload che una ricerca di <script non trova. La prima è il codice codificato: il valore di un’opzione contiene una lunga stringa in base64, compressa con gzdeflate o cifrata con un semplice XOR, che una backdoor nei file decodifica ed esegue. Nel database non c’è traccia di eval o di tag HTML: solo testo apparentemente casuale. Qui aiuta cercare stringhe molto lunghe senza spazi, o valori che iniziano con i prefissi tipici del base64 di dati compressi.

La seconda sono gli oggetti serializzati. Un valore che contiene O: seguito dal nome di una classe viene trasformato in un oggetto quando WordPress lo deserializza, e se la classe ha metodi «magici» come __wakeup() o __destruct(), quel codice viene eseguito automaticamente. È il meccanismo della PHP object injection: di per sé il core non ne è vulnerabile, ma un plugin con la classe sbagliata trasforma un’opzione manipolata in esecuzione di codice. Un’opzione che contiene un oggetto dove il plugin si aspetta un array è un campanello d’allarme.

-- Oggetti serializzati nelle opzioni (da ricondurre al plugin che li usa)
SELECT option_name, LEFT(option_value, 120) AS inizio
FROM wp_options
WHERE option_value LIKE 'O:%' OR option_value LIKE '%;O:%';

-- Blocchi lunghi senza spazi: base64, esadecimale, dati compressi
SELECT option_name, LENGTH(option_value) AS byte
FROM wp_options
WHERE option_value REGEXP '[A-Za-z0-9+/=]{400,}'
  AND option_name NOT LIKE '\_transient\_%'
ORDER BY byte DESC;

Entrambe le query producono falsi positivi: alcuni plugin salvano legittimamente oggetti, immagini codificate o licenze in base64. Il lavoro vero è ricondurre ogni risultato al componente che lo legge, cercando il nome dell’opzione nei file di plugin e temi. Un’opzione che nessun file installato nomina, e che però una riga offuscata in un mu-plugin legge con un nome costruito a pezzi, è esattamente ciò che si sta cercando. Nei casi con molte varianti, il confronto tra valori e componenti è un compito in cui ci aiutano modelli di intelligenza artificiale eseguiti in locale, che classificano i valori sospetti senza che i dati del cliente escano dall’infrastruttura.

Transient, multisite e metadati

Le stesse tecniche si applicano fuori dalle opzioni «classiche». I transient, cioè le opzioni temporanee con prefisso _transient_ e _site_transient_, vengono spesso esclusi dai controlli perché si rigenerano da soli; proprio per questo sono un buon nascondiglio, soprattutto quelli senza scadenza, che non hanno la corrispondente riga _transient_timeout_. Svuotarli con wp transient delete --all è quasi sempre innocuo e toglie di mezzo una parte del rumore, ma prima conviene guardare quelli con nomi insoliti e valori molto grandi, perché possono indicare quale backdoor li scrive.

Su un’installazione multisite le impostazioni di rete stanno in wp_sitemeta, e ogni sito della rete ha la propria tabella wp_N_options: una ricerca limitata a wp_options ne controlla uno solo. Infine, i valori serializzati non stanno solo nelle opzioni: wp_usermeta contiene le capacità degli utenti (wp_capabilities), che il malware modifica per promuovere un account ad amministratore, come abbiamo visto parlando degli amministratori fantasma, e wp_postmeta ospita i dati dei page builder, spesso in forma serializzata o JSON. Le query viste finora vanno quindi ripetute su tutte le tabelle con il prefisso del sito, e WP-CLI con --all-tables-with-prefix o --network aiuta a non dimenticarne nessuna.

Trovare i valori già corrotti

Se qualcuno ha già provato a ripulire il database con un editor di testo, è probabile che ci siano opzioni con lunghezze sbagliate. Anche alcuni malware, scrivendo direttamente con SQL, lasciano valori incoerenti. Trovarli con SQL è difficile; con un breve script eseguito da WP-CLI è immediato, perché si usa la stessa funzione di WordPress per riconoscere i valori serializzati:

<?php
// verifica-serializzati.php — eseguire con: wp eval-file verifica-serializzati.php
global $wpdb;
$righe = $wpdb->get_results( "SELECT option_name, option_value FROM {$wpdb->options}" );
foreach ( $righe as $riga ) {
	if ( is_serialized( $riga->option_value ) && false === @unserialize( $riga->option_value, array( 'allowed_classes' => false ) )
		&& 'b:0;' !== $riga->option_value ) {
		WP_CLI::warning( "Serializzazione non valida: {$riga->option_name}" );
	}
}

L’opzione allowed_classes => false è importante: impedisce che durante il controllo vengano istanziati oggetti e quindi eseguiti eventuali metodi magici. Le opzioni segnalate si possono spesso recuperare da un backup precedente all’infezione, oppure ricostruendo a mano le lunghezze con molta attenzione ai byte.

Pulire mantenendo le lunghezze valide

Quando lo script iniettato cambia da un valore all’altro, il modo più sicuro è lavorare sull’array già deserializzato, in PHP, e lasciare che sia WordPress a riserializzarlo con update_option(). Per un widget HTML infetto, per esempio:

# Vedere la struttura del widget e trovare l'indice infetto
wp option get widget_custom_html --format=json | python3 -m json.tool

# Leggere solo il contenuto del widget numero 3
wp option pluck widget_custom_html 3 content

# Sostituire il contenuto del widget: WP-CLI riserializza l'opzione
wp option patch update widget_custom_html 3 content '<p>Orari: lun-ven 9-18</p>'

# Un'opzione creata dal malware e non usata da nessun componente
wp option delete nome_opzione_sospetta

Per le rimozioni con espressioni regolari su molte opzioni si scrive uno script per wp eval-file che legge ogni opzione con get_option(), percorre l’array in modo ricorsivo, applica la correzione solo ai valori di tipo stringa e salva con update_option(). In ogni caso valgono tre regole: backup prima, simulazione con stampa delle differenze, e verifica dopo con lo script di controllo delle serializzazioni e con una visita alle pagine del sito.

Infine, l’ordine delle operazioni. Pulire il database mentre è ancora presente la backdoor nei file, un cron che reinfetta o un trigger SQL significa vedere ricomparire lo stesso payload poche ore dopo. Nei nostri interventi il database viene analizzato insieme ai file e al sistema, e l’opzione infetta è solo l’ultimo anello di una catena che va risalita fino all’ingresso. La pulizia garantita in 6, 12 o 24 ore per ogni installazione WordPress comprende anche questa parte; se sospetti che il problema del tuo sito sia nel database, scrivici prima di lanciare un cerca e sostituisci sul dump.

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 ↗