La mattina era cominciata con una frase di tre parole: due siti danno errore 500. Roba da venti minuti, in teoria. Due gestionali WordPress di un cliente, ospitati sullo stesso server condiviso di una nota piattaforma di hosting, che rispondevano con la pagina bianca dell'errore interno. Si controlla il server, si riavvia il servizio giusto, si va avanti. Non è andata così.
Questa è la cronaca di una giornata passata a inseguire un colpevole che per ore ha avuto il volto sbagliato. È una storia di amministrazione di sistema, ma ha la forma di un piccolo giallo: c'è un sintomo, un sospettato ovvio, un'indagine che gira a vuoto, e un colpevole che si nascondeva travestito da una delle vittime.
Il primo sguardo al server racconta una cosa chiara: è in ginocchio. Il carico di lavoro, quel numero che misura quante richieste sono in coda ad aspettare la CPU, è sopra undici. Su una macchina che ne regge otto comode, undici significa che tutto arranca, che ogni richiesta aspetta dietro a un'altra. La memoria è al limite. Tra i processi che mangiano più CPU ne spicca uno: un worker del motore PHP, fermo nello stesso punto da quasi tre ore, che divora una CPU e mezza senza muoversi.
Sembra ovvio. Un processo impazzito, bloccato in un ciclo infinito, che strozza il server e affama tutti gli altri siti. Si prova a ucciderlo. Permesso negato: appartiene a un altro utente di sistema. Si riavvia il motore PHP attraverso le API della piattaforma: il processo sopravvive. Si riavvia l'intero server: il processo torna a galla entro pochi secondi, e il carico risale a venti. A questo punto un dubbio comincia a farsi largo. Un worker bloccato non sopravvive a un riavvio completo della macchina. Se torna, qualcuno lo richiama.
La regola, nelle indagini come nei server, è semplice: quando la spiegazione ovvia richiede di ignorare un fatto, è la spiegazione a essere sbagliata, non il fatto.
La svolta arriva dai registri di nginx, il programma che riceve le richieste dal web e le passa al PHP. Per ogni richiesta, la stessa riga: impossibile aprire un socket verso il backend, troppi file aperti nel sistema. Non nel processo: nel sistema. È una distinzione tecnica che cambia tutto. Ogni programma ha un limite di file che può tenere aperti contemporaneamente; ma esiste anche un limite globale, valido per l'intera macchina, una tabella condivisa da tutti. Quando quella si riempie, nessun programma può più aprire niente. Nemmeno nginx può aprire la porta verso il PHP, e allora risponde a tutti con un errore interno.
Ecco perché i due siti del cliente erano giù: non avevano niente di rotto. Erano spettatori innocenti di una macchina che aveva esaurito una risorsa invisibile. Tutti i quasi duecento siti su quel server stavano soffrendo della stessa cosa. La domanda diventava una sola: chi sta divorando i file aperti dell'intero sistema?
Il colpevole era proprio quel worker che dall'inizio sembrava un PHP impazzito. Solo che non era PHP. Guardando meglio l'albero dei processi, quel worker non era figlio del motore PHP: era figlio di una piccola riga di shell che eseguiva un file nascosto, dentro una cartella nascosta, con un nome scelto per sembrare innocuo: qualcosa che si spacciava per un componente grafico di sistema. Il file era un eseguibile da otto megabyte, scritto in linguaggio Go, compilato e ripulito di ogni riferimento, depositato lì un mese prima.
Il dettaglio che chiude il cerchio è anche il più sfacciato: quel programma, una volta avviato, cambiava il proprio nome interno per farsi chiamare esattamente come un worker del motore PHP. Per ore avevo dato la caccia a un processo legittimo impazzito, mentre era un intruso che indossava il vestito della vittima. Si nascondeva in piena vista, con la faccia di ciò che avrebbe dovuto esserci.
Letto a fondo, l'eseguibile raccontava il suo mestiere. Non era un minatore di criptovalute, come avevo immaginato di getto. Era un agente di una rete di server compromessi con uno scopo preciso: iniettare link nascosti dentro i siti WordPress, visibili solo ai motori di ricerca e invisibili agli umani, una forma di parassitismo che dirotta la reputazione altrui per spingere pagine di spam. Parlava con un server di comando esterno per ricevere istruzioni. E scandagliava la macchina alla ricerca di altri WordPress da infettare. Stava facendo il suo lavoro da un mese, e in quel lavoro consumava risorse fino a stendere tutto il vicinato.
Restava il mistero della sua resistenza. La risposta era nei meccanismi di persistenza, ed erano più d'uno, sovrapposti come le serrature di una porta blindata. C'era un compito pianificato che ogni cinque minuti controllava se il programma fosse vivo e, in caso contrario, lo riavviava. C'era un'impostazione di sistema che teneva attiva una sessione dell'utente anche senza nessuno collegato, in modo da sopravvivere ai riavvii. E c'era una funzione di auto-guarigione: se il file veniva cancellato, l'agente lo riscaricava dal server di comando. Spostarlo, ucciderlo, riavviare la macchina: ogni singola mossa veniva annullata da una delle altre serrature.
Un buon malware non è quello che fa più danno. È quello che, qualunque cosa tu faccia, dopo cinque minuti è ancora lì.
E la porta da cui era entrato? Quasi certamente un'applicazione di prova, un gestionale lasciato acceso con la modalità di debug attiva. È l'equivalente di lasciare la chiave sotto lo zerbino con un cartello che dice dove cercare: in quella modalità un framework web espone messaggi di errore dettagliati e, in certe versioni, una via diretta per eseguire codice da remoto. Una svista di configurazione su un'applicazione secondaria aveva aperto la porta a tutto il resto.
La soluzione pulita non poteva venire dall'interno con i miei permessi limitati. Ma c'era una mossa che chiudeva la questione alla radice: disattivare l'applicazione compromessa. Togliere di mezzo quell'applicazione significava togliere l'utente di sistema sotto cui girava l'intruso, e con lui il compito pianificato che lo resuscitava. Nel giro di pochi minuti il processo è sparito, il carico è precipitato da venti a due, e i due siti del cliente sono tornati a rispondere in meno di un decimo di secondo, come se non fosse successo niente. Per loro, in effetti, non era successo niente: avevano solo avuto un vicino di casa molesto.
In parallelo, l'applicazione che aveva aperto la porta è stata salvata altrove. Non copiando il disco infetto, che avrebbe trasportato il problema, ma ricostruendola da capo: il codice scaricato dal suo deposito sorgente, quindi solo i file ufficiali e nessun intruso clandestino; i dati esportati e reimportati; il tutto rimontato dentro un contenitore isolato su un altro server, in modo che, se anche fosse rimasto un residuo, restasse chiuso in una stanza senza finestre. Con, questa volta, la modalità di debug spenta.
Restano un paio di lezioni, ed è onesto dirle senza enfasi. La prima: la modalità di debug non va su un ambiente raggiungibile da internet, mai, nemmeno su un'applicazione che credi che nessuno guardi. È la singola svista che ha fatto da innesco. La seconda riguarda la convivenza: su un server condiviso una sola applicazione bucata può stendere tutti gli altri, perché certe risorse non hanno confini tra inquilini. I siti che contano davvero, quelli da cui dipende il lavoro di qualcuno, stanno meglio su macchine dove il vicino non può prosciugare l'acqua di tutti.
Alla fine il colpevole non era quello che sembrava, l'indagine era partita dalla parte sbagliata, e la verità stava in una riga di registro che diceva, con la noia di un contabile, che il sistema aveva troppi file aperti. Le cose stanno spesso così. Il segnale c'era dall'inizio; ci sono volute ore per smettere di guardare il sospettato ovvio e dare retta al fatto scomodo. Un processo che mentiva sul proprio nome, e un errore che invece diceva la verità.