Non tutti gli incidenti cominciano con un exploit. Alcuni cominciano con un file che nessuno ricordava di aver lasciato lì: una copia di wp-config.php salvata prima di una modifica, l’archivio zip del sito creato durante un trasloco, la cartella .git di un deploy, il debug.log attivato per un problema risolto mesi prima. Sono file che il web server serve a chiunque li chieda, e qualcuno li chiede di continuo.
Un file dimenticato nella docroot non ha bisogno di una vulnerabilità per essere letto. Basta conoscerne il nome, e i nomi sono sempre gli stessi.
In questo articolo vediamo quali file finiscono esposti per errore, cosa rivelano, come trovarli sul proprio server e da fuori, come bloccarli nella configurazione di NGINX e Apache e cosa fare se si scopre che erano accessibili: rimuoverli non basta, perché quello che contenevano può essere già stato copiato.
Come li cercano gli attaccanti
Nessuno deve indovinare. Gli scanner automatici percorrono internet con elenchi di percorsi noti, e chiunque abbia letto i log di accesso di un sito pubblico li ha visti: richieste a /.env, /.git/config, /wp-config.php.bak, /backup.zip, /phpinfo.php, una dopo l’altra, dallo stesso indirizzo, a pochi millisecondi di distanza. Quasi tutte ricevono un 404. Quella che conta è l’unica che riceve un 200.
Gli elenchi si costruiscono osservando le abitudini: gli editor che salvano copie con la tilde o l’estensione .swp, i client FTP che rinominano in .old, i pannelli di hosting e i plugin di backup che creano archivi con nomi prevedibili, i nomi che chiunque sceglierebbe per un dump del database, come db.sql o nomesito.sql. Per questo la domanda giusta non è se qualcuno troverà il file, ma quando.
La cartella .git: tutta la storia del sito
Deployare con git pull direttamente nella docroot è comodo e diffuso, e lascia la cartella .git dentro il sito. Se il web server non la blocca, chiunque può scaricarla. Non serve nemmeno che l’elenco dei file sia abilitato: strumenti pubblici come git-dumper partono da .git/HEAD, ricavano i riferimenti e scaricano uno a uno gli oggetti del repository, fino a ricostruirlo per intero.
Il danno non sta tanto nel codice del tema, che spesso è pubblico comunque, quanto nella storia. Una password del database in un wp-config.php committato per errore anni fa e poi rimosso, una chiave API in un file di configurazione, le credenziali SMTP di un vecchio plugin: tutto ciò che è entrato almeno una volta nel repository resta negli oggetti, e il comando git log -p lo mostra in pochi secondi. La correzione strutturale è tenere il repository fuori dalla docroot, come descriviamo nell’articolo sul monitoraggio dell’integrità dei file, oppure pubblicare con git archive o rsync --exclude=.git.
Se la cartella è stata esposta, conviene guardare la storia con gli occhi dell’attaccante. Il comando git log -p -S 'DB_PASSWORD' --all, eseguito nel repository, elenca ogni commit che ha aggiunto o tolto quella stringa; lo stesso vale per AUTH_KEY, SMTP, api_key o token. Ogni segreto trovato va considerato pubblico, anche se è stato rimosso dieci commit fa: riscrivere la storia serve a non ripetere l’errore, non a rimediare a quello già fatto. Lo stesso discorso vale per le cartelle .svn e .hg dei progetti più vecchi.
# Repository fuori dalla docroot, file di lavoro nella docroot
git --git-dir=/srv/repo/esempio.git --work-tree=/var/www/esempio.it/htdocs checkout -f main
# Oppure: pubblicare solo i file tracciati, senza .git
git archive main | tar -x -C /var/www/esempio.it/htdocs
Backup, dump e copie di wp-config.php
Il secondo gruppo sono le copie. Un archivio .zip o .tar.gz del sito lasciato nella docroot dopo un trasloco contiene tutto: codice, configurazione, spesso anche un dump del database. Un file .sql contiene gli utenti con gli hash delle password, gli indirizzi email dei clienti, gli ordini. Se il sito è un e-commerce o raccoglie dati tramite moduli, un dump scaricato da un estraneo è a tutti gli effetti una violazione di dati personali, con gli obblighi che descriviamo nell’articolo su sito hackerato e data breach.
Le copie di wp-config.php sono il caso più subdolo. Il file originale è al sicuro perché il web server lo passa a PHP, che lo esegue e non restituisce nulla. Ma wp-config.php.bak, wp-config.php.old, wp-config.php~ o wp-config.php.save non finiscono in .php: il server li tratta come file di testo e li consegna così come sono, con nome del database, utente, password e chiavi di sicurezza. Le copie nascono da editor da terminale, da modifiche fatte «per sicurezza» prima di toccare il file, da client FTP che non sovrascrivono. Vale lo stesso per qualunque file PHP con un suffisso aggiunto, e per le copie di backup che alcuni plugin salvano in cartelle pubbliche dentro wp-content. C’è poi il caso dei plugin di backup che salvano gli archivi in una cartella di wp-content protetta solo da un .htaccess: su Apache funziona, ma se il sito viene migrato su NGINX quel file viene ignorato e gli archivi diventano scaricabili da un giorno all’altro, senza che nessuno abbia toccato nulla. Sui backup in generale, e su dove tenerli, rimandiamo all’articolo sui backup immutabili.
debug.log, phpinfo, .env e adminer
Il terzo gruppo sono gli strumenti di diagnosi dimenticati. Con WP_DEBUG_LOG impostato a true, WordPress scrive gli errori in wp-content/debug.log, come indica la documentazione ufficiale sul debug, cioè in una cartella servita dal web server. Il file rivela percorsi assoluti, nome dell’utente di sistema, plugin installati e relative versioni, a volte frammenti di query con dati dei clienti. Per un attaccante è una mappa del sito, e può crescere fino a diversi gigabyte. Sui server con cPanel c’è un parente stretto: i file error_log che PHP crea nella cartella dello script che ha generato l’errore, sparsi per tutto il sito e anch’essi serviti come testo. E con WP_DEBUG_DISPLAY attivo, gli stessi messaggi finiscono direttamente nelle pagine, visibili a ogni visitatore.
Un phpinfo.php o info.php creato per una verifica mostra la versione esatta di PHP, i moduli, le funzioni disabilitate, i percorsi e le variabili d’ambiente, che su alcune configurazioni includono credenziali. Un file .env, frequente nei progetti basati su Bedrock o in applicazioni affiancate al sito, è per definizione un elenco di segreti. E adminer.php, lo strumento di gestione del database in un solo file, lasciato nella docroot dopo un intervento, offre un modulo di accesso a MySQL a chiunque: con una password debole, o con la password letta da una delle copie di cui sopra, l’attaccante ha il database in mano.
Trovarli sul proprio server
La ricerca dall’interno è la più completa, perché vede anche i file con nomi che nessuno scanner proverebbe. Tre comandi coprono la gran parte dei casi: cartelle di versionamento e file di ambiente, archivi e copie per estensione, strumenti di diagnosi per contenuto. Conviene stampare anche data e dimensione, perché un archivio da due gigabyte dell’anno scorso si riconosce subito.
cd /var/www/esempio.it/htdocs
# Repository e file di ambiente
find . \( -name .git -o -name .svn -o -name .hg -o -name '.env*' \) -printf '%TY-%Tm-%Td %p\n'
# Archivi, dump, copie di backup e log
find . -type f \( -iname '*.zip' -o -iname '*.tar' -o -iname '*.tar.gz' -o -iname '*.tgz' \
-o -iname '*.rar' -o -iname '*.7z' -o -iname '*.sql' -o -iname '*.sql.gz' \
-o -iname '*.bak' -o -iname '*.old' -o -iname '*.orig' -o -iname '*.save' \
-o -iname '*.swp' -o -name '*~' -o -iname '*.log' -o -name error_log \) \
-printf '%TY-%Tm-%Td %10s %p\n' | sort
# phpinfo e adminer, anche con nomi diversi
grep -rlE --include='*.php' 'phpinfo[[:space:]]*\(' . | grep -v '^./wp-\(admin\|includes\)/'
grep -rli --include='*.php' 'adminer' . | head
Nell’output ci saranno anche file legittimi, per esempio archivi zip caricati come allegati negli upload o log di plugin protetti da un .htaccess. Ogni riga va comunque verificata: un file legittimo può essere esposto lo stesso, se la protezione su cui conta non funziona sul server in uso, come accade ai .htaccess sotto NGINX.
Una ricerca fatta una volta sola protegge fino alla prossima modifica urgente. Gli stessi comandi, messi in uno script eseguito ogni settimana da cron e configurato per inviare un’email solo quando l’output non è vuoto, trasformano il controllo in un’abitudine che non dipende dalla memoria di nessuno. Chi ha già un controllo di integrità dei file può aggiungere questi schemi all’elenco dei file nuovi da segnalare.
Verificarli da fuori, come farebbe uno scanner
Il controllo dall’esterno risponde a una domanda diversa: non «il file c’è?», ma «il web server lo consegna?». Un ciclo con curl sugli stessi percorsi che provano gli scanner, stampando codice di stato e dimensione, basta a scoprire la maggior parte dei problemi. La dimensione è importante perché molti siti rispondono 200 anche alle pagine inesistenti, mostrando la pagina 404 del tema: un 200 con la stessa dimensione della home è un falso allarme, un 200 da 3 KB su wp-config.php.bak no.
SITO=https://www.esempio.it
for p in .git/HEAD .git/config .env wp-config.php.bak wp-config.php.old wp-config.php~ \
wp-config.php.save wp-content/debug.log backup.zip sito.zip database.sql \
phpinfo.php info.php adminer.php; do
printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code} %{size_download}' "$SITO/$p")" "$p"
done
# Una .git esposta risponde con il riferimento al ramo corrente
curl -s "$SITO/.git/HEAD"
ref: refs/heads/main
Il ciclo va ripetuto su ogni nome a dominio servito dallo stesso server, sottodomini compresi. Le sorprese peggiori arrivano da lì: un staging. creato per un restyling e mai spento, una cartella /vecchio/ o /old/ con una copia completa del sito precedente, mai aggiornata. Non sono solo file esposti: sono installazioni WordPress intere, con plugin fermi a versioni vulnerabili, che condividono il server e spesso il database con il sito principale.
Bloccarli nel web server
Cancellare i file è necessario, ma non impedisce che ne compaiano di nuovi alla prossima modifica fatta di fretta. Una regola nel web server rende innocui quelli futuri. Su NGINX va messa nel blocco server prima della location che passa i file PHP a FPM, perché le location con espressione regolare vengono valutate nell’ordine in cui compaiono e vince la prima che corrisponde:
# NGINX: file nascosti (tranne .well-known), copie, archivi, dump e log
location ~ /\.(?!well-known/) { deny all; }
location ~* \.(bak|old|orig|save|swp|sql|sql\.gz|log|zip|tar|tgz|tar\.gz|rar|7z)$ { deny all; }
location ~ ~$ { deny all; }
location ~ /error_log$ { deny all; }
# Apache 2.4, nella configurazione del virtual host o in .htaccess
RedirectMatch 404 /\.(git|svn|hg|env)
<FilesMatch "(\.(bak|old|orig|save|swp|sql|log|zip|tar|tgz|gz|rar|7z)|~)$">
Require all denied
</FilesMatch>
<Files "error_log">
Require all denied
</Files>
Se il sito offre legittimamente file zip da scaricare, l’estensione va tolta dall’elenco o la regola limitata ai percorsi fuori dagli upload. phpinfo.php e adminer.php non si possono bloccare per estensione, perché sono PHP validi: vanno cancellati. Per il log di debug la soluzione giusta è non scriverlo nella docroot. WP_DEBUG_LOG accetta anche un percorso di file, e in produzione gli errori non devono mai comparire nelle pagine:
// wp-config.php: log fuori dalla docroot, errori mai mostrati ai visitatori
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', '/var/log/php/esempio.it-debug.log' );
define( 'WP_DEBUG_DISPLAY', false );
La cartella del log deve essere scrivibile dall’utente PHP del sito e, finita la diagnosi, WP_DEBUG va rimesso a false. Tutte queste regole trovano posto accanto alle altre della checklist di hardening.
Se erano esposti: rimuovere non basta
Quando la ricerca trova un file esposto, la prima tentazione è cancellarlo e passare oltre. Prima conviene rispondere a una domanda: qualcuno l’ha scaricato? I log di accesso lo dicono, se sono conservati abbastanza a lungo. Si cercano le richieste a quei percorsi con codice 200 e una dimensione compatibile con il file:
# Richieste servite con successo ai percorsi sensibili: IP, data, percorso, byte
zgrep -hE '"GET /(\.git/|\.env|wp-config\.php[^" ]+|wp-content/debug\.log|[^" ]*\.(sql|zip|tar\.gz))[^"]*" 200 ' \
/var/log/nginx/access.log* | awk '{print $1, $4, $7, $10}'
Se il file conteneva segreti, va dato per compromesso tutto ciò che conteneva, anche se i log non mostrano download: i log possono essere stati ruotati, o la richiesta può essere passata da un percorso diverso. Per una copia di wp-config.php significa cambiare la password dell’utente MySQL e rigenerare le chiavi e i salt, per esempio con wp config shuffle-salts, come spieghiamo nell’articolo su wp-config.php e i salt. Per un .env o una .git significa revocare ogni chiave API e ogni token presenti nella storia, non solo nell’ultima versione. Per un dump del database significa valutare la notifica della violazione e, se il dump contiene gli hash delle password, forzarne il cambio agli utenti.
Infine, un file esposto è spesso il punto d’ingresso di un’infezione successiva: con la password del database un attaccante può creare un amministratore fantasma o inserire codice nelle opzioni, senza toccare un solo file. Se dai log risulta che il file è stato scaricato, il passo successivo è controllare utenti, opzioni e file del sito come dopo qualsiasi compromissione, a partire dalle prime due ore di risposta.
Se hai trovato file di questo tipo sul tuo sito e non sai se qualcuno li ha già usati, o se il sito mostra già segni di compromissione, scrivici: analizziamo log, file, database e sistema, cron e processi compresi, e ripuliamo ogni installazione WordPress con una pulizia garantita in 6, 12 o 24 ore, lasciando le regole di blocco al loro posto.