كل الأبحاث
تحليل
2026-06-24 · 7 دقيقة
تعمية كلب الحراسة: ثغرة Parser Differential في أداة GuardDog
أداة GuardDog ماسح أمني مهمته كشف الحزم الخبيثة على PyPI وnpm — لكنه كان يثق بما تعده مكتبة zipfile في بايثون من الفهرس المركزي للأرشيف. يمكن صياغة ملف Wheel بحيث يعود هذا العرض فارغًا بينما تبقى الحمولة في الترويسات المحلية: فيُبلغ الماسح "Found 0 potentially malicious indicators" ويصدر حكم "نظيف"، بينما يفك pip الكود وينفذه. ثغرة اختلاف تفسير (CWE-436) بدرجة 7.4، أُبلغت عبر قناة المطوّر وأُصلحت عبر PR #790.
من يحرس الحُرّاس؟
في أمن سلسلة توريد البرمجيات، تقف أدوات مثل GuardDog في الخط الأول. وهي أداة سطر أوامر مفتوحة المصدر من Datadog، تفحص حزم PyPI وnpm قبل أن تصل إلى بيئتك، عبر مزيج من تحليل البيانات الوصفية وفحص السلوك بمحرّكي Semgrep وYARA، لتقرر إن كانت الحزمة نظيفة أم تحمل حمولة خبيثة. آلاف خطوط الـ CI/CD تعتمد على حكمها.
وهنا سؤال يستحق التوقف: ماذا لو أن الأداة التي *تفحص* الحزمة، والأداة التي *تثبتها*، لا تقرآن الملف نفسه بالطريقة نفسها؟
هذه الفجوة هي جوهر النتيجة. ليست قاعدة كشف ناقصة ولا تعبير Semgrep غير مكتمل، بل أعمق بطبقة: في كيفية قراءة الأرشيف من الأساس.
لماذا صيغة ZIP أخطر مما تبدو
ملف الـ Wheel بامتداد `.whl` ليس إلا أرشيف ZIP باسم مختلف. وصيغة ZIP — رغم عمرها ووضوحها الظاهري — من أكثر صيغ الحاويات غموضًا في الاستخدام الواسع.
السبب أن أرشيف ZIP يحمل *مصدرين للحقيقة* حول محتواه:
- سجلات الترويسة المحلية (Local File Headers)** المبثوثة قبل كل ملف — وهي ما تتبعه أداة التثبيت حين تفكّ الأرشيف فعليًا.
- الفهرس المركزي (Central Directory)** في نهاية الأرشيف، ويُختم بسجلنهاية الفهرس المركزي (EOCD)* — وهو الفهرس الذي تعدّ منه معظم المكتبات.
والمواصفة لا تفرض أن يتطابق العرضان. وحين يختلفان تنشأ ظاهرة *Parser Differential*: قارئان لنفس البايتات يصلان إلى نتيجتين. وهذه الفئة مصنفة رسميًا في *CWE-436 — Interpretation Conflict*:
حين يتعامل المنتج (أ) مع المُدخَل بشكل مختلف عن (ب)، فيتصرّف بناءً على تصور خاطئ لحالته.
قلب الثغرة | حارس يرى أرشيفًا فارغًا
يفك GuardDog الأرشيف بمكتبة `zipfile` القياسية قبل فحصه، وهذه المكتبة تعدّ المحتوى من عرض **الفهرس المركزي**. والنتيجة أن هذا العرض يمكن تفريغه بينما تبقى الحمولة سليمة تمامًا في الترويسات المحلية. ويمكن صياغة الأرشيف بثلاث طرق على الأقل لتحقيق ذلك:
- ضبط قيمة *size of central directory* في سجل EOCD على `0`،
- أو إلحاق سجل EOCD ثانٍ يُعلن صفر مدخلات،
- أو توجيه إزاحة الفهرس المركزي (cd_offset) نحو فهرس فارغ.
وفي كل الحالات تعود `zipfile.namelist()` فارغة، فتتتابع سلسلة الانهيار:
1. عملية الاستخراج في GuardDog تسحب *صفر* ملفات، لأن هذا كل ما يُعلنه الفهرس المركزي.
2. لا يجد الماسح ما يفحصه، فلا تطلَق أي قاعدة، ويُبلغ *"Found 0 potentially malicious indicators"* — حكم "نظيف" بثقة تامة.
3. لكن pip وأدوات التثبيت الأخرى *لا تعتمد على هذا الفهرس*، بل تمشي على الترويسات المحلية وتفكّ الحمولة وتنفّذها.
باختصار: الماسح يرى صندوقًا فارغًا، والمستخدم يستقبل صندوقًا مليئًا. الحمولة عبرت البوابة لأن الحارس قرأ مصدر الحقيقة الخطأ.
وهذا ليس تجاوزًا لقاعدة بعينها داخل GuardDog، بل تعطيل للأداة *كلها* دفعة واحدة. أيا كانت جودة القواعد الكشفية، فهي بلا قيمة إذا كان الملف يبدو فارغًا أصلًا.
لماذا هذه أخطر من درجتها الرقمية
درجة الـ CVSS البالغة 7.4 تصف الثغرة كوحدة تقنية، لكن ثقلها الحقيقي يظهر في السياق:
- تعطيل صامت.* الأدوات الأمنية حين تخترق لا تصرخ GuardDog هنا لا يُطلق إنذارًا كاذبًا ولا يتعطل، بل يُصدر "نظيف" بثقة. وهذا أخطر أنواع الفشل: فشل يبدو نجاحًا.
- موقعها في السلسلة.* الأداة في النقطة التي يُفترض أن تُوقف فيها الهجمة، فاختراقها يعني أن كل ما بعدها يثق ثقة عمياء بحكم معطوب.
- قابلية التوسع.* مهاجم واحد يصيغ حزمة واحدة ويرفعها لمستودع عام، فتصير كل بيئة تعتمد على GuardDog معرّضة بالتساوي.
- ضعف الأثر الجنائي. بما أن الفحص "نجح"، لا يعود أحد ليتحقق. تكون الحمولة قد وصلت ونُفّذت قبل أن يشكّ أحد في الماسح.
وليست فئة نظرية: المشكلة الجذرية — أن مواصفة ZIP غامضة بما يكفي ليختلف مُحلّلان متوافقان معها — جرى تأصيلها في ورقة **USENIX Security 2025 بعنوان "My ZIP isn't your ZIP"**. وهذه النتيجة تنقل المبدأ العام إلى هدف دقيق: أداة أمنية بعينها تُعمى بالكامل.
الإصلاح | لا تثق بالفهرس، تحقّق من الترويسات
بدل التوقّف عند الإبلاغ، عالج الإصلاح دالة `safe_extract` مباشرة (PR #790): قبل الاستخراج، يمشي على **الترويسات المحلية** بنفسه ويرفض الأرشيف إذا كان عددها أكثر مما عدّته `zipfile`. يقرأ الحجم المضغوط لكل ترويسة ويتخطّى بياناتها بدل البحث عن توقيع `PK` — لأن البحث عن التوقيع الخام يُعطي نتائج إيجابية كاذبة على البايتات المضغوطة — ويتوقّف مبكرًا عند واصفات البيانات بلا أحجام مضمّنة وعند علامات zip64، فلا يمكنه إلا أن يَعُدّ أقل، ما يضمن ألا يرفض أرشيفًا سليمًا أبدًا مع التقاطه للتعارض المُصاغ. الأرشيف الطبيعي يُستخرج كالمعتاد، وأرشيف `namelist() == []` صار يُطلق خطأً.
والمبدأ خلف الإصلاح يتعمّم: الثقة في نتيجة الفحص يجب ألا تتجاوز الثقة في المُحلّل الذي أنتجها.** كل طبقة تقرأ مُدخَلًا غير موثوق طرفٌ في احتمال Parser Differential، والدفاعات الدائمة هي التطبيع (إعادة كتابة الأرشيف بشكل قانوني موحّد قبل الفحص)، والفحص الصارم (رفض الأرشيف الذي تتعارض مصادر حقيقته)، ومطابقة المُحلّلات (أن يتبع الماسح المنطق نفسه الذي ستتبعه أداة التثبيت).
الطبقة نفسها | أكثر من ثغرة
دالة فك الأرشيف في الأدوات الأمنية سطح هجوم يستحق تدقيقًا مستقلًا. ففي الفترة نفسها تقريبًا، شهدت `safe_extract()` في GuardDog أيضًا إصلاح ثغرة **Path Traversal تؤدي إلى الكتابة فوق ملفات اعتباطية وتنفيذ كود عن بُعد** (CWE-22)، وثغرة **Zip Bomb لحجب الخدمة** (CWE-409). فالكود الذي يفتح أرشيفًا غير موثوق يستحق تدقيقًا بقدر الكود الذي يحكم على محتواه.
الإفصاح
أُبلغت الثغرة عبر قناة الإفصاح المسؤول الخاصة بـ Datadog وعولجت عبر PR #790. ولم يُنشر أي دليل استغلال تشغيلي لتسليح حزمة؛ فهذه المقالة والـ pull request العام يشرحان فئة الباگ والصياغات التي تُطلقه والإصلاح — لا بناءً خبيثًا جاهزًا.
في أمن سلسلة توريد البرمجيات، تقف أدوات مثل GuardDog في الخط الأول. وهي أداة سطر أوامر مفتوحة المصدر من Datadog، تفحص حزم PyPI وnpm قبل أن تصل إلى بيئتك، عبر مزيج من تحليل البيانات الوصفية وفحص السلوك بمحرّكي Semgrep وYARA، لتقرر إن كانت الحزمة نظيفة أم تحمل حمولة خبيثة. آلاف خطوط الـ CI/CD تعتمد على حكمها.
وهنا سؤال يستحق التوقف: ماذا لو أن الأداة التي *تفحص* الحزمة، والأداة التي *تثبتها*، لا تقرآن الملف نفسه بالطريقة نفسها؟
هذه الفجوة هي جوهر النتيجة. ليست قاعدة كشف ناقصة ولا تعبير Semgrep غير مكتمل، بل أعمق بطبقة: في كيفية قراءة الأرشيف من الأساس.
لماذا صيغة ZIP أخطر مما تبدو
ملف الـ Wheel بامتداد `.whl` ليس إلا أرشيف ZIP باسم مختلف. وصيغة ZIP — رغم عمرها ووضوحها الظاهري — من أكثر صيغ الحاويات غموضًا في الاستخدام الواسع.
السبب أن أرشيف ZIP يحمل *مصدرين للحقيقة* حول محتواه:
- سجلات الترويسة المحلية (Local File Headers)** المبثوثة قبل كل ملف — وهي ما تتبعه أداة التثبيت حين تفكّ الأرشيف فعليًا.
- الفهرس المركزي (Central Directory)** في نهاية الأرشيف، ويُختم بسجلنهاية الفهرس المركزي (EOCD)* — وهو الفهرس الذي تعدّ منه معظم المكتبات.
والمواصفة لا تفرض أن يتطابق العرضان. وحين يختلفان تنشأ ظاهرة *Parser Differential*: قارئان لنفس البايتات يصلان إلى نتيجتين. وهذه الفئة مصنفة رسميًا في *CWE-436 — Interpretation Conflict*:
حين يتعامل المنتج (أ) مع المُدخَل بشكل مختلف عن (ب)، فيتصرّف بناءً على تصور خاطئ لحالته.
قلب الثغرة | حارس يرى أرشيفًا فارغًا
يفك GuardDog الأرشيف بمكتبة `zipfile` القياسية قبل فحصه، وهذه المكتبة تعدّ المحتوى من عرض **الفهرس المركزي**. والنتيجة أن هذا العرض يمكن تفريغه بينما تبقى الحمولة سليمة تمامًا في الترويسات المحلية. ويمكن صياغة الأرشيف بثلاث طرق على الأقل لتحقيق ذلك:
- ضبط قيمة *size of central directory* في سجل EOCD على `0`،
- أو إلحاق سجل EOCD ثانٍ يُعلن صفر مدخلات،
- أو توجيه إزاحة الفهرس المركزي (cd_offset) نحو فهرس فارغ.
وفي كل الحالات تعود `zipfile.namelist()` فارغة، فتتتابع سلسلة الانهيار:
1. عملية الاستخراج في GuardDog تسحب *صفر* ملفات، لأن هذا كل ما يُعلنه الفهرس المركزي.
2. لا يجد الماسح ما يفحصه، فلا تطلَق أي قاعدة، ويُبلغ *"Found 0 potentially malicious indicators"* — حكم "نظيف" بثقة تامة.
3. لكن pip وأدوات التثبيت الأخرى *لا تعتمد على هذا الفهرس*، بل تمشي على الترويسات المحلية وتفكّ الحمولة وتنفّذها.
باختصار: الماسح يرى صندوقًا فارغًا، والمستخدم يستقبل صندوقًا مليئًا. الحمولة عبرت البوابة لأن الحارس قرأ مصدر الحقيقة الخطأ.
وهذا ليس تجاوزًا لقاعدة بعينها داخل GuardDog، بل تعطيل للأداة *كلها* دفعة واحدة. أيا كانت جودة القواعد الكشفية، فهي بلا قيمة إذا كان الملف يبدو فارغًا أصلًا.
لماذا هذه أخطر من درجتها الرقمية
درجة الـ CVSS البالغة 7.4 تصف الثغرة كوحدة تقنية، لكن ثقلها الحقيقي يظهر في السياق:
- تعطيل صامت.* الأدوات الأمنية حين تخترق لا تصرخ GuardDog هنا لا يُطلق إنذارًا كاذبًا ولا يتعطل، بل يُصدر "نظيف" بثقة. وهذا أخطر أنواع الفشل: فشل يبدو نجاحًا.
- موقعها في السلسلة.* الأداة في النقطة التي يُفترض أن تُوقف فيها الهجمة، فاختراقها يعني أن كل ما بعدها يثق ثقة عمياء بحكم معطوب.
- قابلية التوسع.* مهاجم واحد يصيغ حزمة واحدة ويرفعها لمستودع عام، فتصير كل بيئة تعتمد على GuardDog معرّضة بالتساوي.
- ضعف الأثر الجنائي. بما أن الفحص "نجح"، لا يعود أحد ليتحقق. تكون الحمولة قد وصلت ونُفّذت قبل أن يشكّ أحد في الماسح.
وليست فئة نظرية: المشكلة الجذرية — أن مواصفة ZIP غامضة بما يكفي ليختلف مُحلّلان متوافقان معها — جرى تأصيلها في ورقة **USENIX Security 2025 بعنوان "My ZIP isn't your ZIP"**. وهذه النتيجة تنقل المبدأ العام إلى هدف دقيق: أداة أمنية بعينها تُعمى بالكامل.
الإصلاح | لا تثق بالفهرس، تحقّق من الترويسات
بدل التوقّف عند الإبلاغ، عالج الإصلاح دالة `safe_extract` مباشرة (PR #790): قبل الاستخراج، يمشي على **الترويسات المحلية** بنفسه ويرفض الأرشيف إذا كان عددها أكثر مما عدّته `zipfile`. يقرأ الحجم المضغوط لكل ترويسة ويتخطّى بياناتها بدل البحث عن توقيع `PK` — لأن البحث عن التوقيع الخام يُعطي نتائج إيجابية كاذبة على البايتات المضغوطة — ويتوقّف مبكرًا عند واصفات البيانات بلا أحجام مضمّنة وعند علامات zip64، فلا يمكنه إلا أن يَعُدّ أقل، ما يضمن ألا يرفض أرشيفًا سليمًا أبدًا مع التقاطه للتعارض المُصاغ. الأرشيف الطبيعي يُستخرج كالمعتاد، وأرشيف `namelist() == []` صار يُطلق خطأً.
والمبدأ خلف الإصلاح يتعمّم: الثقة في نتيجة الفحص يجب ألا تتجاوز الثقة في المُحلّل الذي أنتجها.** كل طبقة تقرأ مُدخَلًا غير موثوق طرفٌ في احتمال Parser Differential، والدفاعات الدائمة هي التطبيع (إعادة كتابة الأرشيف بشكل قانوني موحّد قبل الفحص)، والفحص الصارم (رفض الأرشيف الذي تتعارض مصادر حقيقته)، ومطابقة المُحلّلات (أن يتبع الماسح المنطق نفسه الذي ستتبعه أداة التثبيت).
الطبقة نفسها | أكثر من ثغرة
دالة فك الأرشيف في الأدوات الأمنية سطح هجوم يستحق تدقيقًا مستقلًا. ففي الفترة نفسها تقريبًا، شهدت `safe_extract()` في GuardDog أيضًا إصلاح ثغرة **Path Traversal تؤدي إلى الكتابة فوق ملفات اعتباطية وتنفيذ كود عن بُعد** (CWE-22)، وثغرة **Zip Bomb لحجب الخدمة** (CWE-409). فالكود الذي يفتح أرشيفًا غير موثوق يستحق تدقيقًا بقدر الكود الذي يحكم على محتواه.
الإفصاح
أُبلغت الثغرة عبر قناة الإفصاح المسؤول الخاصة بـ Datadog وعولجت عبر PR #790. ولم يُنشر أي دليل استغلال تشغيلي لتسليح حزمة؛ فهذه المقالة والـ pull request العام يشرحان فئة الباگ والصياغات التي تُطلقه والإصلاح — لا بناءً خبيثًا جاهزًا.
GuardDogDatadogSupply Chain SecurityParser DifferentialCWE-436Interpretation ConflictZIPPython WheelPyPIResponsible DisclosureStatic Analysis