Quando si pensa al malware su WordPress vengono in mente i plugin vulnerabili e la cartella degli upload. Il tema, invece, viene considerato un contenitore di grafica: fogli di stile, immagini, qualche template. È un errore che costa caro, perché un tema è codice PHP a tutti gli effetti, viene caricato a ogni richiesta del front-end e contiene file che quasi nessuno legge dopo la consegna del sito. Per chi ha appena ottenuto un accesso al server, è uno dei posti più comodi dove lasciare una backdoor o un’iniezione di JavaScript.
Il tema è l’unico componente del sito che per costruzione contiene codice personalizzato. Proprio per questo è il più difficile da verificare e il più facile da infettare senza che nessuno se ne accorga.
In questo articolo vediamo dove si nasconde il codice malevolo dentro i temi, perché i temi figlio e le personalizzazioni rendono inutile la ricetta «reinstallo il tema e sono a posto», come confrontare i file con gli originali del produttore e come chiudere il capitolo, compresa la parte che si dimentica sempre: i temi installati ma non usati.
Perché il tema è un nascondiglio ideale
Le ragioni sono pratiche. Il file functions.php del tema attivo viene eseguito a ogni caricamento di WordPress, sia nel front-end sia nel pannello, come spiega la documentazione ufficiale per gli sviluppatori di temi: qualunque cosa ci venga aggiunta gira con i privilegi di WordPress, può registrare hook, leggere i cookie di sessione e stampare codice nella pagina. I template come header.php e footer.php finiscono in ogni pagina HTML, quindi sono il punto perfetto per uno script che deve raggiungere tutti i visitatori.
Poi c’è la questione della verifica. Per il core e per i plugin della directory ufficiale WP-CLI può confrontare ogni file con gli hash pubblicati da wordpress.org, come abbiamo descritto parlando del monitoraggio dell’integrità dei file. Per i temi quel comando non esiste: wp core verify-checksums e wp plugin verify-checksums non hanno un equivalente per la cartella themes. Un tema commerciale o scritto su misura non ha inoltre una baseline pubblica, e gli scanner a firme vedono solo ciò che somiglia a malware già noto, un limite che abbiamo approfondito nell’articolo sui limiti degli scanner di sicurezza.
Infine, un tema viene modificato spesso e da molte mani: l’agenzia, lo sviluppatore che aggiunge un pixel di tracciamento, il cliente che incolla un codice nel pannello. In mezzo a tante modifiche legittime, una in più passa inosservata.
functions.php e i file che include
La forma più comune è anche la più semplice: qualche riga aggiunta in cima o in fondo a functions.php. A volte il codice è in chiaro, più spesso è offuscato con catene di base64_decode, gzinflate e str_rot13 che finiscono in un eval, le stesse tecniche descritte nell’articolo sui file offuscati. Un trucco frequente è spingere il codice molto a destra con centinaia di spazi, così che nell’editor la riga sembri vuota, oppure appenderlo dopo il tag di chiusura ?> finale.
Le varianti più curate non toccano quasi functions.php: aggiungono una sola riga include o require_once verso un file che sta altrove. Il file di destinazione può trovarsi dentro il tema, con un nome plausibile come inc/class-theme-loader.php, oppure fuori, in una sottocartella degli upload o in un file con estensione .ico o .png che in realtà contiene PHP. Il risultato è che il payload vero non contiene la firma nel punto in cui un controllo superficiale la cerca, e la riga di inclusione in functions.php sembra innocua.
cd /var/www/esempio.it/public_html/wp-content/themes
# Funzioni tipiche dell'offuscamento e dell'esecuzione di input esterni
grep -rnE --include='*.php' \
'eval\s*\(|base64_decode\s*\(|gzinflate\s*\(|str_rot13\s*\(|assert\s*\(|\$_(POST|GET|REQUEST|COOKIE)\s*\[' .
# Inclusioni verso file che non sono PHP, o fuori dalla cartella del tema
grep -rnE --include='*.php' '(include|require)(_once)?\s*\(?\s*["'\''][^"'\'']*\.(ico|png|jpg|gif|txt)' .
grep -rnE --include='*.php' '(include|require)(_once)?[^;]*(uploads|/tmp/|\\x2f)' .
# Righe anomale per lunghezza: il codice spinto a destra o compresso su una riga
awk 'length > 1000 { print FILENAME ":" FNR ": " length " caratteri" }' $(find . -name '*.php')
Questi comandi producono falsi positivi, soprattutto nei temi commerciali che usano base64_decode per le icone o per i dati di demo. Ciò che conta è il contesto: un eval che riceve il contenuto di un cookie non ha giustificazioni, una stringa base64 che contiene un’immagine SVG sì.
header.php, footer.php e i template modificati
Quando l’obiettivo è colpire i visitatori, e non conservare un accesso al server, il bersaglio sono i template che producono l’HTML. Uno <script> inserito prima di </head> in header.php o prima di </body> in footer.php raggiunge ogni pagina del sito. È il meccanismo dietro molti redirect condizionali verso siti di truffe, delle finte schermate di aggiornamento del browser descritte nell’articolo sui falsi captcha e, nei negozi online, degli skimmer che leggono i dati delle carte.
Lo script raramente è scritto in chiaro. Di solito carica un file esterno da un dominio registrato da poco, costruisce l’URL con String.fromCharCode o si attiva solo per chi arriva da Google e non è loggato, così che l’amministratore non lo veda mai. Per questo conviene scaricare la pagina da riga di comando simulando un visitatore anonimo.
# La pagina come la vede un visitatore anonimo arrivato da Google su smartphone
curl -s -A 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/126.0 Mobile Safari/537.36' \
-e 'https://www.google.com/' https://www.esempio.it/ | grep -oE '<script[^>]*src=[^>]+>' | sort -u
# Script esterni scritti direttamente nei template del tema
grep -rnoE '<script[^>]*src=["'\''][^"'\'']+' wp-content/themes/ --include='*.php'
C’è poi un canale che non passa dai file: le opzioni del tema. Molti temi commerciali offrono nel pannello campi come «Script nell’header» o «Codice personalizzato», salvati nel database dentro l’opzione theme_mods_ seguita dal nome del tema o in opzioni proprie. Lo stesso vale per il CSS aggiuntivo del Customizer. Chi ha un accesso amministrativo può incollare lì uno script, e nessun controllo sui file lo troverà mai:
# Opzioni dei temi che contengono script o iframe (adattare il prefisso delle tabelle)
wp db query "SELECT option_name, LENGTH(option_value) AS byte FROM wp_options
WHERE option_name LIKE 'theme_mods_%'
AND (option_value LIKE '%<script%' OR option_value LIKE '%<iframe%' OR option_value LIKE '%eval(%');"
File finti e temi inattivi ancora raggiungibili
Il secondo grande gruppo sono i file che non appartengono al tema ma ci sono stati messi dentro perché lì nessuno li cerca. Una webshell chiamata theme-options.php, un 404.php duplicato in una sottocartella, un wp-login.php copiato nella cartella di un tema per ingannare chi cerca «file del core fuori posto».
Il caso più sottovalutato riguarda i temi inattivi. Un tema non attivo non viene caricato da WordPress, ed è facile concludere che non possa fare danni. Ma i suoi file restano sul disco, dentro la docroot, e ogni file PHP è raggiungibile direttamente con una richiesta HTTP: https://www.esempio.it/wp-content/themes/twentytwentyone/404.php viene eseguito dal web server come qualunque altro script. Se l’attaccante ha aggiunto codice in cima a quel file, quel codice gira prima ancora che il template si accorga di essere stato chiamato fuori da WordPress. Un tema vecchio e mai aggiornato può inoltre avere vulnerabilità proprie, sfruttabili anche se non è attivo.
Il traffico normale non richiede quasi mai un file PHP di un tema per indirizzo diretto: i temi vengono caricati da index.php. Per questo i log di accesso sono un ottimo filtro:
# Richieste dirette a file PHP dentro i temi: IP, metodo e percorso
grep -hE '"(GET|POST) /wp-content/themes/[^ "]*\.php' /var/log/nginx/access.log* \
| awk '{ print $1, $6, $7 }' | sort | uniq -c | sort -rn | head -30
# File PHP nei temi modificati negli ultimi 30 giorni (la ctime non si falsifica con touch)
find wp-content/themes -type f -name '*.php' -cmin -43200 -printf '%CY-%Cm-%Cd %CH:%CM %p\n' | sort
Una sequenza di POST verso /wp-content/themes/qualcosa/inc/cache.php da pochi indirizzi IP è quasi sempre una backdoor in uso. Il percorso trovato nei log è il punto di partenza: data di modifica, contenuto, altri file creati nello stesso minuto.
Temi figlio: perché «reinstallare il tema» non basta
Il consiglio più diffuso dopo un’infezione è reinstallare il tema da una copia pulita. Per un tema scaricato da wordpress.org e mai toccato funziona. Nella maggior parte dei siti professionali, però, il tema attivo è un tema figlio: una cartella separata che dichiara nel proprio style.css il tema padre con l’intestazione Template: e che contiene il codice scritto apposta per quel sito. Il suo functions.php viene caricato prima di quello del padre, e i template che contiene sostituiscono quelli del padre.
Il problema diventa evidente: reinstallare il tema padre sostituisce i file del padre e non tocca il figlio. Il figlio non ha un originale da scaricare, perché esiste solo su quel sito o nel repository di chi l’ha sviluppato. E proprio lì gli attaccanti preferiscono scrivere, perché sanno che il padre viene aggiornato e sovrascritto periodicamente, mentre il figlio resta com’è per anni. Lo stesso vale per i temi sviluppati interamente su misura, dove padre e figlio coincidono con il codice del cliente.
# Quale tema è attivo e da quale padre dipende
wp option get template # tema padre (o il tema stesso, se non è un figlio)
wp option get stylesheet # tema effettivamente attivo
# Stato, versione e aggiornamenti di tutti i temi installati
wp theme list --fields=name,status,version,update
Per il tema figlio la baseline va cercata altrove: nel repository Git dell’agenzia, in un pacchetto consegnato al momento del lancio, in un backup molto vecchio, confrontato con le cautele descritte nell’articolo sui backup che riportano il malware. In mancanza di tutto questo resta la lettura riga per riga, che su un tema figlio di qualche decina di file richiede ore, non giorni.
Confrontare con l’originale del produttore
Per il tema padre il confronto è lo strumento più efficace, a patto di usare la stessa versione installata sul sito. Per i temi della directory ufficiale il pacchetto di una versione precisa si scarica dall’indirizzo downloads.wordpress.org/theme/nome.versione.zip; per i temi commerciali va preso dall’area clienti del produttore, non da siti di terze parti, per le ragioni spiegate nell’articolo sui temi e plugin nulled.
SITO=/var/www/esempio.it/public_html
TEMA=astra
VER=$(wp --path="$SITO" theme get "$TEMA" --field=version)
mkdir -p /root/confronto && cd /root/confronto
curl -fsSLO "https://downloads.wordpress.org/theme/$TEMA.$VER.zip"
unzip -q "$TEMA.$VER.zip" -d originale
# File diversi, mancanti o in più rispetto all'originale
diff -rq originale/"$TEMA" "$SITO/wp-content/themes/$TEMA"
# Il dettaglio riga per riga di un file sospetto
diff -u originale/"$TEMA"/functions.php "$SITO/wp-content/themes/$TEMA/functions.php"
L’output di diff -rq ha tre tipi di righe interessanti. «Files … differ» indica un file modificato: può essere una personalizzazione fatta male direttamente nel padre, e va comunque letta. «Only in» seguito dal percorso del sito indica un file che nell’originale non c’è, ed è la categoria in cui finiscono webshell e file di appoggio. «Only in» seguito dal percorso dell’originale indica un file mancante, di solito irrilevante.
Temi commerciali e plugin inclusi nel pacchetto
Molti temi commerciali arrivano con plugin premium inclusi: slider, page builder, moduli di importazione delle demo. Il tema li installa alla prima attivazione, ma il loro aggiornamento dipende dal fatto che il produttore del tema pubblichi una nuova versione con il plugin aggiornato, e che il proprietario del sito installi quella nuova versione. Il plugin non si aggiorna dalla propria fonte, perché la licenza è del produttore del tema, e spesso il proprietario non sa di averlo.
Il caso di scuola è la campagna SoakSoak del dicembre 2014. Secondo l’analisi di Sucuri, il vettore era una vulnerabilità del plugin Slider Revolution (RevSlider) che consentiva il caricamento di file arbitrari; la falla era stata corretta mesi prima, ma il plugin era distribuito dentro molti temi commerciali, e tanti proprietari non sapevano di averlo né potevano aggiornarlo separatamente. Sucuri aveva già segnalato lo sfruttamento attivo di una falla di RevSlider a settembre dello stesso anno, sottolineando proprio il problema dei plugin inclusi nei temi. La lezione vale ancora: per ogni plugin arrivato con il tema bisogna sapere da dove si aggiorna e chi lo fa.
In pratica, dopo aver elencato i plugin con wp plugin list --fields=name,version,update, per quelli che non hanno un aggiornamento disponibile dalla directory si confronta la versione installata con l’ultima pubblicata dal produttore del plugin, non da quello del tema, e si verificano le vulnerabilità note sui bollettini di sicurezza. Se il plugin non serve più, lo slider di una vecchia home page per esempio, la soluzione migliore è rimuoverlo.
Bonificare il tema e togliere quelli che non servono
La bonifica del tema segue un ordine preciso. Prima si conservano i file sospetti con i loro metadati, perché le date di modifica servono a ricostruire il punto d’ingresso. Poi il tema padre si sostituisce integralmente con il pacchetto originale della stessa versione, o meglio ancora dell’ultima versione disponibile, senza copiare sopra i vecchi file. Il tema figlio si ripristina dal repository o si ripulisce a mano, file per file, verificando ogni inclusione. Le opzioni del tema nel database si controllano come visto sopra.
Poi si fa pulizia. Il controllo di Salute del sito di WordPress raccomanda esplicitamente di rimuovere i temi inattivi, conservando solo il tema attivo, il suo tema padre e un tema predefinito di WordPress da usare come riserva. È un consiglio da seguire alla lettera:
# Temi inattivi: rimuovere tutto tranne il padre del tema attivo e un tema predefinito
wp theme list --status=inactive --field=name
wp theme delete twentytwentyone twentytwentytwo vecchio-tema-2019
# Niente editor dei file nel pannello: in wp-config.php
define( 'DISALLOW_FILE_EDIT', true );
La costante DISALLOW_FILE_EDIT toglie l’editor dei file di temi e plugin dal pannello: chi ruba una password di amministratore non può più scrivere codice in 404.php con due clic, uno dei metodi più usati. Rientra nelle misure descritte nell’articolo sull’hardening di wp-config.php. Sul web server si può anche negare l’esecuzione diretta dei file PHP dentro wp-content/themes, con la stessa logica usata per gli upload nell’articolo sulle webshell; prima però va verificato che il tema non usi file PHP richiamati per indirizzo diretto, cosa rara ma non impossibile nei temi più vecchi.
Resta il punto più importante: capire come il codice è arrivato nel tema. Un file modificato è la conseguenza, non la causa. La causa può essere un plugin vulnerabile, una password di amministratore riutilizzata, credenziali FTP rubate dal computer dello sviluppatore o un altro sito sullo stesso account di hosting. E il tema raramente è l’unico punto di persistenza: le stesse infezioni lasciano spesso tracce nei mu-plugin, nei cron di sistema e nel database.
Se il sito si reinfetta dopo qualche giorno con lo stesso codice nello stesso functions.php, qualcosa lo riscrive: un processo in memoria, un cron, un trigger nel database o un accesso non revocato. Oltre alla checklist di hardening dopo la bonifica, conviene tenere il tema figlio sotto controllo di versione, così che ogni differenza rispetto all’ultimo deploy sia visibile con un comando.
Se hai trovato codice sospetto nel tuo tema, o hai già reinstallato il tema e l’infezione è tornata, scrivici. Analizziamo i temi, i figli personalizzati e le opzioni nel database insieme al resto del sistema, cerchiamo il punto d’ingresso e garantiamo la pulizia di ogni installazione WordPress in 6, 12 o 24 ore dal momento in cui riceviamo gli accessi necessari.