Path Traversal: quando “../” diventa un’arma

Ogni applicazione web che serve file all’utente — documenti, immagini, report, log — deve rispondere a una domanda semplice: come faccio a sapere che il file richiesto è quello che voglio servire io, e non qualsiasi altro file sul server?

Se la risposta è “mi fido di quello che l’utente mi manda”, il server è già compromesso in potenza. Il Path Traversal è una delle vulnerabilità più antiche del web — documentata dalla fine degli anni ’90 — e rientra tipicamente nelle vulnerabilità di access control o security misconfiguration secondo l’OWASP Top 10. Non richiede exploit sofisticati, basta solo che uno sviluppatore abbia dimenticato di fare una domanda ovvia.

Il Path Traversal è la conseguenza diretta di fidarsi dell’input senza verificarlo. E il file system, a differenza di un database, non ha un livello di query che possa proteggerti: se il path è valido, il file viene letto.

Questo articolo è pensato per sviluppatori junior e mid-level che vogliono capire il problema dall’interno, non solo evitarlo per abitudine.

Il codice: cinque righe che aprono il server

Osserva questo endpoint PHP. È scritto in modo che molti sviluppatori, specialmente alle prime armi, considererebbero ragionevole: prende un nome file dalla query string e lo serve dalla directory dei documenti.

//PHP — endpoint vulnerabile NON usare in produzione
<?php
 
// Endpoint: /api/file?name=report.pdf
$filename = $_GET['name'];
$path     = '/var/www/docs/' . $filename;
 
if (file_exists($path)) {
    header('Content-Type: application/octet-stream');
    readfile($path);
} else {
    echo 'File non trovato.';
}


Il codice compila, funziona, supera i test manuali. Digiti “report.pdf”, ottieni report.pdf: sembrerebbe tutto corretto, ma non è così.

Anatomia del fallimento

Riga 1 — L’ingresso non validato

$filename = $_GET['name'];

Questa riga prende qualsiasi cosa l’utente scriva nell’URL e la tratta come un nome file legittimo. Non c’è nessun controllo sul contenuto: niente verifica che sia solo un nome (senza slash), niente controllo sull’estensione, niente whitelist. L’input dell’utente entra direttamente nel sistema come dato affidabile.

⚠️ Regola fondamentale della sicurezza: tutto ciò che arriva dall’esterno è per definizione non affidabile. L’input non è un dato: è un’ipotesi da verificare.

Riga 2 — La concatenazione cieca

$path = '/var/www/docs/' . $filename;

Il path viene costruito incollando il prefisso fisso con il nome fornito dall’utente. Il problema è che il file system interpreta “../” come “sali di un livello nella directory”. Quindi, se $filename vale “../../../../etc/passwd”, il path risultante è tecnicamente valido e punta fuori dalla directory consentita.

⚠️ Il sistema operativo non sa che quella sequenza è malevola. Fa semplicemente ciò per cui è progettato: risolvere i path.

Riga 3 — file_exists() non è una guardia di sicurezza

if (file_exists($path)) { readfile($path); }

file_exists() verifica solo che il file esista, non che sia lecito servirlo. Se l’attaccante punta a /etc/passwd, il file esiste eccome. La funzione restituisce true e readfile() lo trasmette integralmente al client, non siamo di fronte ad un errore, non abbiamo log di sicurezza, né un avviso.

Come viene sfruttato?

L’attacco è banale da costruire. Basta modificare il parametro name nell’URL con una sequenza di “../” sufficiente a risalire alla root del file system, seguita dal path del file target.

Bash — richieste di attacco reali
# L'attaccante costruisce questa richiesta:
GET /api/file?name=../../../../etc/passwd
 
# Il server risolve il path come:
# /var/www/docs/../../../../etc/passwd
# → /etc/passwd
 
# Altri bersagli comuni:
?name=../../../../etc/shadow
?name=../../../../var/www/html/config.php
?name=../../../../home/ubuntu/.ssh/id_rsa

Il numero di “../” necessari dipende dalla profondità della directory di partenza. Aggiungerne in eccesso non causa errori: il file system si ferma alla root. Quindi l’attaccante può usarne molti senza rischio di fallire per quel motivo.

⚡ Con un solo endpoint vulnerabile e qualche minuto, un attaccante può leggere: credenziali del sistema (/etc/shadow), chiavi SSH private, file di configurazione con password di database, codice sorgente dell’applicazione, variabili d’ambiente con token e API key.

Negli attacchi reali gli aggressori spesso usano URL encoding per aggirare filtri semplici. Ad esempio, la sequenza ../ può essere scritta come ..%2F o addirittura doppio-encoded come ..%252F, permettendo di risalire nel filesystem anche quando il server prova a bloccare ../. È quindi importante che l’applicazione decodifichi correttamente l’input e applichi controlli sul percorso canonico risolto.

Prima di correggere, conviene verificare se il proprio endpoint è effettivamente vulnerabile. Un test minimale con curl è sufficiente:

bash

curl "https://tuodominio.com/api/file?name=../../../../etc/passwd"

Per un fuzzing più sistematico si può usare ffuf con una wordlist di path traversal comuni — ne esistono di pronte nel repository SecLists di OWASP. Se uno di questi restituisce 200 con contenuto, il problema è confermato.

Come si corregge: difesa a strati in Python

La correzione non è una singola riga. È un sistema di controlli sovrapposti, ognuno dei quali blocca una classe di attacchi diversa. Nessun singolo controllo è sufficiente da solo.

Il codice seguente implementa cinque livelli di difesa, numerati nei commenti:

Python / Flask — implementazione difensiva
import os
import pathlib
from flask import Flask, request, abort, send_file
 
app   = Flask(__name__)
# 1. ROOT JAIL: directory consentita, immutabile
ROOT  = pathlib.Path('/var/www/docs').resolve()
 
@app.route('/api/file')
def serve_file():
    name = request.args.get('name', '')
 
    # 2. SANITIZZAZIONE: solo nome file, niente path separators
    if not name or '/' in name or '\\' in name or name.startswith('.'):
        abort(400, 'Nome file non valido.')
 
    # 3. COSTRUZIONE SICURA + VERIFICA REALE DEL PATH
    target = (ROOT / name).resolve()
 
    # 4. CONTROLLO CHE IL PATH SIA DENTRO LA ROOT JAIL
    if not target.is_relative_to(ROOT):
        abort(403, 'Accesso negato.')
 
    # 5. VERIFICA ESISTENZA PRIMA DI SERVIRE
    if not target.is_file():
        abort(404, 'File non trovato.')
 
    return send_file(target)

Livello 1 — Root Jail

ROOT = pathlib.Path('/var/www/docs').resolve()

La directory consentita viene definita come costante all’avvio, non calcolata a runtime. Il metodo .resolve() la espande nel path assoluto canonico, eliminando qualsiasi ambiguità relativa. Questo diventa il confine invalicabile dell’intero sistema.

⚠️ Attenzione, però, perché questo esiste da Python 3.9; se usi la versione 3.8 il codice non funziona.

Livello 2 — Sanitizzazione dell’input

Il nome file viene controllato prima ancora di costruire il path. Si rifiutano esplicitamente: slash (/ e \) che permetterebbero di attraversare directory; nomi che iniziano con punto, che nascondono file di sistema o permettono sequenze come “..”. Questo è un approccio a blacklist — utile, ma non sufficiente da solo, perché potrebbero esistere varianti di encoding non coperte.

Livello 3 — Costruzione sicura del path e resolve()

target = (ROOT / name).resolve()

.resolve() canonicalizza il path eliminando sequenze relative, ma la sicurezza è garantita dal controllo successivo che verifica che il risultato sia ancora dentro la root jail. . Trasforma qualsiasi path — anche con sequenze “../” o encoding URL — nel suo equivalente assoluto canonico. Non interpreta: calcola. Il risultato è sempre un path reale, senza ambiguità.

⚠️ Attenzione: su Python 3.5, .resolve() lanciava un’eccezione se il path non esisteva. Da Python 3.6+ accetta il parametro strict=False (default) e risolve il path anche se il file non esiste ancora. In produzione è bene essere espliciti: .resolve(strict=False) documenta l’intenzione e rende il comportamento prevedibile tra versioni diverse.

Livello 4 — Verifica che il path sia dentro la Root Jail

if not target.is_relative_to(ROOT): abort(403)

Questo è il controllo più importante. Anche se tutti i livelli precedenti fossero bypassati, questa riga garantisce che il file risolto si trovi fisicamente all’interno della directory consentita. È un controllo matematico sul path, non sul nome: non può essere aggirato con encoding o varianti sintattiche.

Livello 5 — Verifica che sia un file regolare

target.is_file() controlla non solo che il path esista, ma che sia effettivamente un file regolare — non una directory, non un link simbolico, non un device file. I link simbolici in particolare sono un vettore classico per aggirare le root jail in sistemi mal configurati. Un attaccante potrebbe creare un symlink dentro la directory consentita che punti a file esterni alla jail, aggirando i controlli se questi non verificano che il path risolto sia effettivamente all’interno della root.

🧠 La difesa in profondità (defense in depth) non è paranoia: è il riconoscimento che ogni singolo controllo può avere falle o edge case. La sovrapposizione di controlli indipendenti riduce la superficie di attacco a quasi zero anche quando uno dei livelli fallisce.

Oltre il codice: misure di sistema

La sanitizzazione nel codice è necessaria ma non sufficiente. Un sistema sicuro aggiunge ulteriori livelli a livello infrastrutturale:

Containerizzazione e filesystem di sola lettura

Eseguire l’applicazione in un container Docker con filesystem di sola lettura e volume montato solo per la directory dei documenti limita drasticamente il danno anche in caso di vulnerabilità non scoperta. L’attaccante può leggere solo ciò che il container vede.

Principio del minimo privilegio

L’utente di sistema con cui gira il processo web non dovrebbe avere accesso in lettura a /etc/shadow, alle chiavi SSH o ai file di configurazione del database. Se il processo non può leggere quei file, il Path Traversal non li espone, indipendentemente dal codice.

Chroot jail e namespace Linux

Una chroot jail cambia la root apparente del file system per un processo: ../../../ non può uscire dalla jail perché il processo non vede nulla al di fuori di essa. I namespace Linux (usati da Docker) offrono isolamento ancora più; robusto.

Logging e alerting

Qualsiasi richiesta che viene bloccata dai controlli di sicurezza dovrebbe essere loggata con IP sorgente, timestamp e input ricevuto. Sequenze di tentativi falliti in breve tempo sono un segnale di attacco in corso e dovrebbero attivare un alert automatico.

Riepilogo: cosa ricordare

Il Path Traversal è una vulnerabilità concettualmente semplice e tecnicamente facile da prevenire, ma richiede disciplina: non basta un singolo controllo, e non basta farlo “di solito”. Ogni endpoint che tocca il file system è una superficie di attacco.

NON FARE MAIFARE SEMPRE
❌ Concatenare input utente in path di file✔ Definire una root jail come costante
❌ Usare file_exists() come controllo di sicurezza✔ Usare .resolve() per canonicalizzare il path
❌ Fidarsi del nome file ricevuto dall’esterno✔ Verificare con is_relative_to() dopo la risoluzione
❌ Gestire i file con privilegi di root✔ Applicare il principio del minimo privilegio
❌ Ignorare i tentativi falliti nei log✔ Loggare e alertare su ogni accesso bloccato

Lascia un commento