في نهاية مارس 2024 كان مهندس يعمل على تحسين أداء PostgreSQL يقيس سرعة تعديلاته على جهاز يعمل بنسخة Debian التجريبية، فلاحظ أن عمليات sshd تستهلك من المعالج أكثر مما يجب، وأن تسجيل الدخول عبر SSH صار أبطأ بنحو نصف ثانية، وهذا فرق لا يلاحظه أغلبنا، وإذا لاحظه فسوف يلوم الشبكة أو التحديث الأخير وينتقل إلى عمله. ولكن هذا المهندس لم يتجاهله، وبعد أيام من القياس والتتبع نشر في 29 مارس 2024 أن مكتبة الضغط liblzma من مشروع XZ Utils، الموجودة تقريباً في كل توزيعة Linux، تحتوي على باب خلفي Backdoor يستهدف خادم SSH نفسه، وأخذت الثغرة الرقم CVE-2024-3094 بأعلى درجة خطورة ممكنة وهي 10 من 10.
وقد رويت قصة هذا الباب الخلفي كثيراً من زاوية من زرعه وكيف كسب الثقة، وقد شرحناها نحن من زاوية الحوكمة وتمويل المشرفين في مقال حوكمة المشاريع مفتوحة المصدر، أما هذا المقال فيركز على الجانب الذي يفيدك في عملك اليومي: كيف فكر المهندس الذي اكتشفه، وما الأوامر التي يستخدمها أي مهندس في كل خطوة، ولماذا كانت كل خطوة تضيق مكان البحث، بحيث تخرج بطريقة تحقيق تستخدمها في أي بطء غريب على سيرفراتك.
وسوف نناقش في هذا المقال ما يلي:
- العرض الذي بدأ منه كل شيء، ولماذا جعله فضولياً بدلاً من أن يتجاهله.
- خطوات التحقيق كما وصفها بنفسه، مع الأمر المناسب لكل خطوة والسبب وراءها:
timeوperfوvalgrind وlddوgdb، ثم مقارنة الملف المضغوط للإصدار Release Tarball مع مستودع Git. - كيف كان الباب الخلفي يعمل بشكل عام، بدون أي تفاصيل تساعد على تكراره.
- الاستجابة: من تأثر فعلاً، وماذا فعلت التوزيعات، والسنتان اللتان سبقتا الهجوم باختصار.
- تجربة عملية Lab آمنة على Containers: نضيف بأنفسنا تأخيراً بريئاً إلى sshd ثم نكتشفه بالأدوات نفسها، ونفحص نسخ xz على النظام، ونقارن ملف إصدار حقيقي مع Git.
- الدروس التي تستحق أن تنقلها إلى فريقك.
الصورة التالية تبين خطوات التحقيق التي سوف نمر عليها بالترتيب:

من هو Andres Freund، وماذا كان يفعل؟
Andres Freund مطور مساهم في قاعدة البيانات PostgreSQL، وتذكره صفحة المساهمين في المشروع مع جهة عمله Microsoft، وقد وصفته صفحة Microsoft عن الحادثة بأنه موظف لديها وهو من اكتشف الباب الخلفي. ولاحظ أن عمله ليس الأمن، فهو كما كتب بنفسه في رسالته: "I am *not* a security researcher, nor a reverse engineer"، أي أنه ليس باحثاً أمنياً ولا متخصصاً في الهندسة العكسية Reverse Engineering، وإنما مهندس أداء يعرف أدوات القياس جيداً، وهذه النقطة هي أهم ما في القصة، لأن الأدوات التي استخدمها موجودة على أي سيرفر Linux ويستطيع أي مدير أنظمة أن يتعلمها.
وفي منشور في Mastodon بعد الإعلان بساعات شرح كيف بدأ الأمر: كان يقيس أداء تعديلات صغيرة Micro-benchmarking، ولكي تكون القياسات نظيفة كان يحاول إيقاف أي نشاط على الجهاز يسبب ضجيجاً Noise في الأرقام، فرأى عمليات sshd تستهلك المعالج بشكل ملحوظ رغم أنها كانت محاولات دخول تفشل فوراً بأسماء مستخدمين خاطئة، وهي المحاولات التي يرسلها أي ماسح Scanner على الإنترنت لكل سيرفر يفتح المنفذ 22.
العرض: نصف ثانية لا يلاحظها أحد
في رسالته إلى قائمة oss-security في 29 مارس 2024 وضع Freund الرقم الذي بدأ منه كل شيء، وهو زمن محاولة دخول فاشلة بمستخدم غير موجود، قبل تحديث المكتبة وبعده:
time ssh [email protected]
before:
real 0m0.299s
user 0m0.202s
sys 0m0.006s
after:
real 0m0.807s
user 0m0.202s
sys 0m0.006sفي المخرج أعلاه لاحظ التالي:
- الوقت الكلي
realزاد من 0.299 إلى 0.807 ثانية، أي نحو نصف ثانية لكل محاولة دخول. - وقت المعالج عند الكلاينت
userبقي 0.202 كما هو، وهذا يعني أن الكلاينت ssh لم يتغير، وأن الوقت الزائد يضيع في الطرف الآخر، أي في الخادم sshd، وهذه أول معلومة تضيق مكان البحث: المشكلة في السيرفر وليست في الشبكة ولا في جهازك. - العنوان مختصر في الأرشيف
...alhostلأن أرشيف القائمة يخفي جزءاً من العناوين، والأصل هوlocalhost، أي أن القياس تم على الجهاز نفسه بدون شبكة، وهذه طريقة ذكية لإزالة عامل كامل من المعادلة.
وقد يتساءل البعض: لماذا يهتم أحد بنصف ثانية في تسجيل الدخول؟ والإجابة أن الرقم نفسه ليس المهم، وإنما أن يتغير شيء ثابت بدون سبب معروف، فزمن الدخول عبر SSH على الجهاز نفسه ثابت تقريباً منذ سنوات، وعندما يتضاعف فجأة بعد تحديث حزم فهناك كود جديد يعمل لم يكن يعمل من قبل، والسؤال الطبيعي هو: ما هذا الكود؟ وهذه هي نفس الطريقة التي شرحناها في دليل استكشاف الأخطاء: ما الذي تغير؟، فالسؤال عن التغيير يسبق السؤال عن السبب.
التحقيق خطوة بخطوة
قبل أن نبدأ يجب أن نوضح أمراً مهماً، فرسالة Freund مكتوبة كتقرير منظم حسب الموضوع وليس حسب ترتيب الأيام، ومنشوراته في Mastodon تذكر ترتيب الخطوات الأولى فقط، لذلك سوف نعرض الخطوات بالترتيب المنطقي الذي يتضح من كلامه، ونذكر لكل خطوة الأمر الذي يستخدمه أي مهندس في الموقف نفسه، وما قاله هو بنفسه مربوط بمصدره.
الخطوة الأولى: قس قبل أن تخمن
الحل البسيط عندما يبطئ شيء هو التخمين: ربما DNS، ربما الشبكة، ربما التحديث الأخير، ثم تجرب حلولاً عشوائية حتى يختفي العرض أو تمل منه. ولكن هذا لا يوصلك إلى شيء لأن كل تخمين يحتاج تجربة، ولا تعرف هل اختفى العرض بسبب ما فعلت أم صدفة. والطريقة الصحيحة هي أن تحول الإحساس «الدخول بطيء» إلى رقم يمكن تكراره، وهذا ما فعله بالأمر time على محاولة دخول إلى localhost، فالأمر رخيص ويمكن تكراره عشر مرات ويعطي رقماً تقارن به قبل وبعد.
time ssh nonexistent@localhostولاحظ أنه لم يستخدم مستخدماً حقيقياً، والسبب أن المحاولة الفاشلة أقصر طريق ممكن داخل sshd: اتصال ثم تبادل مفاتيح ثم رفض، بدون أي جلسة أو shell، وبالتالي أي زيادة في الوقت تأتي من الجزء المشترك الذي يمر به كل اتصال.
الخطوة الثانية: أين يذهب وقت المعالج؟
بعد أن عرف أن الوقت يضيع في sshd وأن المعالج مشغول، فالسؤال التالي هو: داخل أي كود؟ والأداة لهذا هي perf، وهي أداة Profiling في نواة Linux تأخذ عينات Samples من المعالج عدة مرات في الثانية وتسجل أي دالة كانت تعمل لحظة العينة، فإذا كانت 90% من العينات في مكتبة واحدة فهناك يضيع الوقت. والأوامر المعتادة هي:
# نظرة حية على أكثر الدوال استهلاكاً في النظام كله
perf top
# تسجيل عينات مع سلسلة الاستدعاءات Call Graph ثم قراءة التقرير
perf record -g -p <PID>
perf reportوهنا جاءت الملاحظة الثانية، فقد كتب في منشوره أن الـ Profiling أظهر وقتاً كبيراً للمعالج داخل liblzma، وأن perf لم يستطع أن ينسب هذا الوقت إلى اسم دالة Symbol، وعندها صار مرتاباً. والسبب في هذا الارتياب أن مكتبة ضغط لا يفترض أن تعمل أصلاً عند محاولة دخول فاشلة إلى SSH، فلا يوجد شيء يضغط أو يفك الضغط هنا، ثم إن الكود الطبيعي في مكتبة منشورة له أسماء يقرؤها perf، أما كود بلا أسماء داخل مكتبة معروفة فهو أمر غير معتاد.
وقد شرحنا قراءة مخرج perf وأوامر الدقيقة الأولى على السيرفر في مقال تحليل أداء سيرفر Linux في 60 ثانية، وفكرة الـ Profiling نفسها في مقال تتبع الطلب البطيء.
الخطوة الثالثة: اربط الملاحظة بملاحظة قديمة
ملاحظة واحدة غريبة قد تكون صدفة، ولكن ملاحظتين في المكان نفسه نمط Pattern. فقد تذكر Freund، كما كتب في المنشور نفسه، أنه رأى قبل أسابيع شكوى غريبة من valgrind في الاختبارات الآلية لـ PostgreSQL بعد تحديث حزم النظام. وvalgrind أداة تشغل البرنامج داخل معالج افتراضي وتراقب كل قراءة وكتابة في الذاكرة، فتشتكي إذا قرأ الكود ذاكرة غير مهيأة أو كتب خارج حدوده.
والطريف أن هذه الشكوى نفسها كانت نتيجة سلسلة صدف كما شرح في منشور لاحق، فهو يشغل عدة أجهزة «buildfarm» لاختبار PostgreSQL آلياً، وأحدها مع valgrind، وقبل نحو سنة وحد إعداداتها وجعلها كلها تبنى مع الخيار -fno-omit-frame-pointer لسبب لا يتذكره، وبحسب ما كتب فإن valgrind لم يكن ليشتكي بدون هذا الخيار، لأن الكود الخبيث في الدالة _get_cpuid() كان يتوقع شكلاً معيناً لإطار الذاكرة Stack Frame، كما أنه اختار Debian unstable ليكتشف مشاكل التوافق Portability مبكراً، وبدون ذلك لم يكن valgrind ليجد ما يشتكي منه. وقال إنه بدون تلك الشكوى من valgrind لا يظن أنه كان سيبحث بعمق عندما رأى استهلاك المعالج العالي في sshd.
الخطوة الرابعة: لماذا يحمل sshd مكتبة ضغط؟
هنا يظهر سؤال منطقي: برنامج OpenSSH لا يستخدم liblzma، فكيف وصلت إلى ذاكرة sshd؟ والأداة لهذا السؤال هي ldd التي تعرض كل المكتبات المشتركة Shared Libraries التي يحملها البرنامج، المباشرة وغير المباشرة، ثم readelf -d التي تعرض الاعتماديات المباشرة فقط المكتوبة في الملف تحت اسم NEEDED، والفرق بين القائمتين يخبرك من جلب المكتبة:
ldd /usr/sbin/sshd | grep -E 'lzma|systemd'
readelf -d /usr/sbin/sshd | grep NEEDED
readelf -d /usr/lib/x86_64-linux-gnu/libsystemd.so.0 | grep NEEDEDوالإجابة كما كتبها في رسالته: OpenSSH لا يستخدم liblzma مباشرة، ولكن Debian وعدة توزيعات أخرى تعدل OpenSSH ليدعم إشعار systemd بأنه جاهز Readiness Notification، ومكتبة libsystemd تعتمد على liblzma لضغط السجلات، وبالتالي يحمل sshd مكتبة الضغط في كل مرة يبدأ فيها. وسوف نرى هذه السلسلة بأرقام حقيقية في التجربة العملية، والصورة التالية تبين السلسلة قبل الحادثة وبعدها:

ولاحظ أن هذه السلسلة لا توجد في كل التوزيعات، فكما يذكر الخط الزمني الذي جمعه Russ Cox فإن Arch Linux وGentoo وNixOS لا تعدل sshd بهذه الطريقة، وقد أكدت Arch Linux في إعلانها أن sshd عندها لا يرتبط بـ liblzma وطلبت من المستخدمين التأكد بالأمر ldd "$(command -v sshd)".
الخطوة الخامسة: أعد إنتاج المشكلة بأقل شيء ممكن
عندما تعرف المكان التقريبي فالخطوة التالية هي أن تجعل المشكلة صغيرة وسريعة التكرار، لأن كل تجربة تأخذ دقيقة تعني عشرات التجارب في الساعة، وكل تجربة تأخذ ثانية تعني آلافاً. وهنا ظهرت مفاجأة ذكرها في رسالته، فعندما شغل sshd يدوياً خارج systemd لم يظهر البطء، رغم أن الكود الخبيث كان يستدعى لفترة قصيرة، فبدأ يغير البيئة حتى وجد الشروط التي يحتاجها الكود ليعمل: المتغير TERM غير موجود، واسم البرنامج /usr/sbin/sshd، والمتغيران LD_DEBUG وLD_PROFILE غير موجودين، والمتغير LANG موجود، وبعض أدوات التصحيح مثل rr يتم اكتشافها.
ثم وصل إلى أصغر تجربة ممكنة، فلا يحتاج حتى أن يشغل الخادم أو يتصل به، لأن الكود يعمل لحظة تحميل البرنامج، فالأمر الأول التالي يأخذ نحو نصف ثانية على جهازه، والأمران بعده نحو 0.01 ثانية:
# بطيء: نحو 0.5 ثانية
env -i LANG=C /usr/sbin/sshd -h
# سريع: نحو 0.01 ثانية
env -i LANG=C TERM=foo /usr/sbin/sshd -h
env -i LANG=C LD_DEBUG=statistics /usr/sbin/sshd -hفي الأوامر أعلاه لاحظ التالي:
- الأمر
env -iيشغل البرنامج ببيئة فارغة تماماً، ثم نضيف متغيراً واحداً فقط، وبهذا الشكل نغير عاملاً واحداً في كل تجربة كما شرحنا في قاعدة فرضية واحدة وقياس واحد وتغيير واحد. - الخيار
-hهنا لا يشغل الخادم، فهو يتوقع اسم ملف مفتاح ويفشل فوراً، ولكن هذا يكفي لأن كل ما نريده هو أن يحمل البرنامج مكتباته ثم يخرج. - أن يختفي البطء بمجرد وجود
TERMمعناه أن الكود يتعمد ألا يعمل عندما يكون هناك شخص في Terminal يحلل، وهذا سلوك لا تفعله أي مكتبة بريئة، وقد كتب Freund أن شرط بناء الحزمة كـ deb أو rpm يبدو مقصوداً ليصعب على المحققين إعادة إنتاج المشكلة.
الخطوة السادسة: أين يتفرع التنفيذ؟
عندما تملك تجربتين، واحدة بطيئة وواحدة سريعة، والفرق بينهما متغير واحد، فأفضل سؤال هو: في أي نقطة يختلف مسار التنفيذ بينهما؟ وقد كتب أنه استخدم لهذا بشكل أساسي perf record -e intel_pt//ub، وهو حدث يستخدم خاصية Intel Processor Trace في المعالج لتسجيل كل قفزة Branch ينفذها البرنامج في وضع المستخدم، وليس عينات فقط، وعلى التجربة الصغيرة من الخطوة السابقة يكون الأمر بهذا الشكل:
perf record -e intel_pt//ub -- env -i LANG=C /usr/sbin/sshd -hثم قارن التسجيلين ليرى أين يتفرع التنفيذ عندما يكون الكود الخبيث نشطاً، ثم استخدم gdb ووضع نقاط توقف Breakpoints قبل نقطة التفرع ليقرأ ما يحدث خطوة بخطوة، واستخدم أيضاً المراقبة watch _rtld_global_ro._dl_naudit في gdb ليرى لحظة إضافة دالة مراقبة Audit Hook إلى المحمل الديناميكي Dynamic Linker. ولاحظ أن خاصية intel_pt تحتاج معالج Intel يدعمها، وقد لا تتوفر داخل الأجهزة الافتراضية، أما gdb وperf record -g فتعمل في كل مكان كما سنرى في التجربة.
وبهذا التتبع وجد الدليل الحاسم، ففي تسجيل لعملية دخول بمفتاح عام Public Key ظهر أن sshd عندما يستدعي الدالة RSA_public_decrypt التي يفترض أن تكون من مكتبة libcrypto في OpenSSL، يقفز التنفيذ إلى كود داخل liblzma.so.5.6.0، ثم يعود هذا الكود ويستدعي libcrypto، أي أن دالة من دوال التحقق من الهوية في sshd صارت تمر أولاً عبر مكتبة الضغط.
الخطوة السابعة: من أين جاء هذا الكود؟
كتب Freund أنه ظن في البداية أن حزمة Debian نفسها هي المخترقة، وهذا أول تخمين منطقي لأنه رأى المشكلة على Debian فقط، ولكن الفحص أظهر أن المشكلة في المصدر الأصلي Upstream. والتقنية التي استخدمها هنا هي التي نريدك أن تتذكرها من هذا المقال: قارن ما ينشره المشروع كملف إصدار Release Tarball مع ما في مستودع Git للإصدار نفسه.
ففي مشاريع C التي تستخدم Autotools يكون ملف الإصدار المضغوط مختلفاً عن المستودع بشكل طبيعي، لأن المشرف يشغل أدوات مثل autoconf وautomake على جهازه ويضع ملفاتها الناتجة مثل configure وملفات m4/*.m4 داخل الملف المضغوط، حتى لا يحتاج من يبني البرنامج إلى هذه الأدوات. وهذا الفرق الطبيعي هو ما استغله المهاجم، فقد وجد Freund أن الملف m4/build-to-host.m4 في ملفات الإصدارين 5.6.0 و5.6.1 يحتوي على سطر غير موجود في النسخة الأصلية من هذا الملف، وأن xz في Git لا يستخدم هذا الملف أصلاً، وأن روابط «Source code» التي تولدها GitHub تلقائياً من المستودع لا تحتوي عليه. وهذا السطر يحقن سكربتاً مخفياً يعمل في نهاية configure، ويستخرج بيانات من ملفين في مجلد الاختبارات بالمستودع هما tests/files/bad-3-corrupt_lzma2.xz وtests/files/good-large_compressed.lzma، ولاحظ أن هذين الملفين لم يكونا مستخدمين في أي اختبار في الإصدار 5.6.0.
وقد يتساءل البعض: كيف لم يلاحظ أحد ملفات ثنائية خبيثة في المستودع؟ والإجابة أن ملف التعليمات في مجلد الاختبارات يقول منذ سنوات إن كثيراً من هذه الملفات صنعت يدوياً بمحرر Hex، وإنه لا يوجد «كود مصدري» لها أفضل من الملفات نفسها، وبالتالي فإن ملفاً جديداً اسمه «ملف تالف للاختبار» لا يثير أي شك عند المراجعة، وهذا ما شرحه Russ Cox في خطه الزمني، وتحليله للسكربت نفسه في مقال مستقل لمن يريد التفاصيل.
وكذلك ربط Freund بين أخطاء valgrind والإصدار 5.6.1، فقد كتب أن الكود المحقون كان يسبب أخطاء في valgrind وتوقفاً Crash في بعض الإعدادات لأن شكل الـ Stack اختلف عما يتوقعه، وأن الإصدار 5.6.1 حاول معالجة هذه الأخطاء بتعديلات قدمت كإصلاحات، ثم عدل الكود الخبيث نفسه ليتوافق معها، أي أن الإصلاح الذي جعل valgrind يصمت كان جزءاً من الهجوم.
الخطوة الثامنة: البلاغ، لمن وبأي ترتيب؟
هذه الخطوة يتجاهلها كثير من المهندسين، والسؤال فيها: إذا وجدت ثغرة خطيرة في مشروع مفتوح، لمن تبلغ؟ وقد شرح Freund في رسالته ما فعله: لم يبلغ المشروع الأصلي لأن مؤشرات تورط أحد المشرفين كانت واضحة، وأرسل بلاغاً أولياً إلى فريق أمن Debian لأنه ظن في البداية أن المشكلة خاصة بها، ثم أبلغ القائمة البريدية الخاصة distros@ التي تجمع فرق أمن التوزيعات لتجهز التحديثات قبل الإعلان، ثم أبلغت إحدى التوزيعات وكالة CISA، وأعطت Red Hat الثغرة الرقم CVE-2024-3094، وكل هذا في 28 مارس بحسب خط Russ Cox الزمني، ثم جاء الإعلان العلني في oss-security في 29 مارس ومعه السكربت المحقون بعد إزالة التمويه Obfuscation عنه، وكود الباب الخلفي الذي استخرجه Florian Weimer منفصلاً، وسكربت فحص صغير كتبه Vegard Nossum.
والدرس هنا أن ترتيب البلاغ جزء من التحقيق، فالإعلان العلني قبل أن تجهز التوزيعات تحديثاتها يعطي المهاجم وقتاً، والبلاغ للمشروع نفسه عندما يكون أحد مشرفيه هو المشتبه به يحذره، لذلك تحتاج كل شركة ومشروع إلى سياسة إفصاح Disclosure Policy واضحة كما شرحنا في مقال الحوكمة.
كيف كان الباب الخلفي يعمل؟
سوف نشرح الفكرة العامة فقط، بالقدر الذي يجعلك تفهم لماذا ظهرت الأعراض التي رآها Freund، ولن ندخل في أي تفاصيل للحمولة Payload أو طريقة تشغيلها، فمن يحتاج التحليل الكامل يجده في مقال LWN عن طريقة عمل الباب الخلفي وفي الأسئلة الشائعة التي جمعها Sam James من Gentoo. والصورة التالية تبين المرحلتين:

في وقت البناء Build Time يعمل السكربت المخفي في configure فقط إذا تحققت شروط معينة ذكرها Freund: معالج x86-64 ونظام Linux بمكتبة glibc، والمترجم gcc مع الرابط GNU ld، والبناء يتم داخل عملية بناء حزمة Debian أو RPM. وإذا تحققت الشروط يستخرج كوداً جاهزاً Object File من ملفات الاختبار ويضيفه إلى liblzma أثناء الربط، وهذا ما يصفه وصف الثغرة في NVD بأن عملية البناء تستخرج ملفاً جاهزاً من ملف اختبار متخف في الكود المصدري وتعدل به دوال في المكتبة. ولهذا السبب ذكرت Arch Linux أن الكود الخبيث لم يدخل في الحزم التي بنتها، لأن السكربت كان يستهدف بيئات بناء Debian وFedora فقط.
أما في وقت التشغيل Run Time فيعتمد الباب الخلفي على خاصية في glibc اسمها الدوال غير المباشرة IFUNC، وهي خاصية مشروعة تسمح للمكتبة باختيار أسرع نسخة من دالة حسب المعالج عند تحميلها، وتشرحها وثائق GCC تحت الخاصية ifunc. ومكتبة liblzma تستخدمها لاختيار دالة حساب CRC المناسبة، فاستبدل المهاجم دوال الاختيار crc32_resolve() وcrc64_resolve() بكوده، ولأن sshd يبنى مع الخيار -Wl,-z,now فإن كل الرموز تحل عند بداية البرنامج، أي أن الكود الخبيث يعمل قبل أن تصل sshd إلى الدالة main، وقبل أن تقفل جداول العناوين للقراءة فقط بسبب الخيار -Wl,-z,relro.
بعد ذلك يقرأ الكود جداول الرموز في الذاكرة، وهذه هي الخطوة البطيئة التي سببت نصف الثانية، ثم يضيف دالة مراقبة Audit Hook إلى المحمل الديناميكي تنتظر لحظة ربط الدالة RSA_public_decrypt في sshd، فيوجهها إلى كوده. وبحسب تحليل LWN فإن هذا الكود يبحث عن أمر داخل المفتاح العام في شهادة الدخول التي يرسلها الكلاينت، ويتحقق أن الأمر موقع بالمفتاح الخاص للمهاجم، فإذا كان كذلك ينفذه بصلاحيات المستخدم الذي يشغل sshd، وهو root في الغالب، وإذا لم يكن كذلك يكمل التحقق الطبيعي كأن شيئاً لم يحدث. وهذا يعني أن فحص السيرفر من الخارج لا يكشف الباب الخلفي تقريباً، لأنه لا يتصرف بشكل مختلف إلا مع من يملك ذلك المفتاح، ولذلك كان الأثر الجانبي الوحيد الظاهر هو الوقت الزائد والأخطاء في valgrind.
الاستجابة: من تأثر فعلاً؟
الخبر الجيد أن الاكتشاف جاء مبكراً، فكما كتب Freund فإن الإصدارين 5.6.0 و5.6.1 لم يدخلا بشكل واسع في التوزيعات، وما دخل منهما كان غالباً في النسخ التجريبية. والصورة التالية تبين الأسابيع الخمسة بين دخول ملفات الاختبار الخبيثة إلى المستودع والإعلان:

وفيما يلي ما فعلته كل جهة بحسب إعلاناتها الرسمية:
- Debian: بحسب متتبع الأمان في Debian فإن النسخ المستقرة buster وbullseye وbookworm لم تتأثر لأن الكود الخبيث غير موجود فيها، وكانت 5.6.0 قد وصلت إلى testing أيضاً في 5 مارس بحسب خط Russ Cox الزمني، أما unstable فقد أعيدت إلى نسخة باسم
5.6.1+really5.4.5-1، وهي طريقة Debian في الرجوع إلى نسخة أقدم مع رقم إصدار أعلى حتى يقبلها مدير الحزم كتحديث. ثم في 3 أبريل 2024 أصدرت Debian النسخة1:9.7p1-4من openssh التي لا ترتبط بـ libsystemd، كما سنرى في سجل التغييرات في التجربة. - Red Hat وFedora: ذكرت نشرة Red Hat أنه لا توجد أي نسخة من Red Hat Enterprise Linux متأثرة، وأن Fedora Rawhide تلقت 5.6.0 أو 5.6.1، وأن Fedora 40 beta احتوت على حزمتين من 5.6.0 وطلبت من مستخدميها الرجوع إلى 5.4، وطلبت من مستخدمي Rawhide التوقف عن استخدامها فوراً حتى يتم الرجوع.
- Arch Linux: ذكرت في إعلانها أن الحزم 5.6.0-1 و5.6.1-1 تحتوي على الكود، ثم حدثت الإعلان بأن الكود لم يدخل في الحزم التي بنتها لأن السكربت يستهدف بيئات Debian وFedora، ومع ذلك طلبت التحديث احتياطاً.
- CISA: أصدرت في 29 مارس تنبيهاً توصي فيه بالرجوع إلى نسخة سليمة مثل XZ Utils 5.4.6 والبحث عن أي نشاط مشبوه.
- المشروع نفسه: نشر المشرف الأصلي Lasse Collin صفحة عن الحادثة يذكر فيها أن الملفات المضغوطة للإصدارين 5.6.0 و5.6.1 تحتوي على الباب الخلفي وأن Jia Tan هو من أنشأها ووقعها، وأن GitHub أوقفت حسابه وحساب Jia Tan ثم أعادت حسابه في 2 أبريل 2024، وأن إصدارات نظيفة جديدة صدرت في 29 مايو 2024.
وتغطية LWN في وقتها جمعت الصورة من جهة مجتمع Linux، في خبر الإعلان الأول ثم في مقال عن ما تعلمه المجتمع من الحادثة.
سنتان من بناء الثقة باختصار
بدأ حساب باسم Jia Tan بإرسال تعديلات صغيرة ومفيدة إلى المشروع في أكتوبر 2021، وفي 2022 ظهرت حسابات أخرى تضغط على المشرف الوحيد Lasse Collin وتطالب بمشرف جديد، فكتب أنه يعمل مع Jia Tan الذي قد يكون له دور أكبر، وفي مارس 2023 بنى Jia Tan أول إصدار باسمه، وفي 2024 أضاف الباب الخلفي في إصدار رسمي موقع. وقد شرحنا القصة كاملة بالتواريخ والمصادر، ولماذا كان المشرف الواحد المرهق بلا تمويل نقطة الضعف الحقيقية، في مقال حوكمة المشاريع مفتوحة المصدر، والدفاعات التقنية ضد هذا النوع من الهجمات في مقال أمن سلسلة التوريد البرمجية.
تجربة عملية: نكرر طريقة التحقيق على نظام سليم
الآن سوف نطبق طريقة Freund بأنفسنا، ولكن على نظام سليم تماماً، فنحن لن ننزل أي ملف من الإصدارات الخبيثة ولن نشغل أي كود من الباب الخلفي، وإنما سوف نكتب بأنفسنا مكتبة صغيرة بريئة تضيع ربع ثانية من المعالج عندما يحملها sshd، ثم نتصرف كأننا لا نعرف بوجودها ونكتشفها بالأدوات نفسها: time ثم top ثم perf ثم إعادة الإنتاج بأقل شيء ثم gdb ثم ldd. وبعد ذلك نفحص نسخ xz وسلسلة الاعتماديات على Debian 12 وDebian 13، ونقارن ملف إصدار حقيقي من xz مع مستودع Git.
lab-xz-، ولا تفتح أي منفذ على جهازك، والمكتبة التي نضيفها كتبناها نحن وكودها كاملاً أمامك. لا تقم بتنزيل الإصدارين 5.6.0 و5.6.1 أو أي «أداة فحص» منشورة على الإنترنت وتشغيلها على سيرفر حقيقي، والسبب أن أي سكربت غير موثوق قد يكون هو نفسه المشكلة، والفحص الآمن هو قراءة أرقام الإصدارات والاعتماديات كما سنفعل.تجهيز ال Container
نشغل Container من Debian 13.1 بإصدار مثبت، ونعطيه صلاحيات إضافية يحتاجها perf وgdb فقط، ثم ندخل إليه:
docker run -d --name lab-xz-ssh \
--cap-add SYS_ADMIN --cap-add PERFMON --cap-add SYS_PTRACE \
--security-opt seccomp=unconfined \
debian:13.1 sleep infinity
docker exec -it lab-xz-ssh bashوداخل ال Container نثبت خادم SSH وأدوات التحقيق ثم نشغل الخادم:
apt-get update
apt-get install -y openssh-server openssh-client linux-perf ltrace gdb gcc libc6-dev procps xz-utils file
mkdir -p /run/sshd
/usr/sbin/sshd
dpkg -l | grep -E "openssh-server|linux-perf|xz-utils|liblzma5|gdb " | awk '{print $2, $3}'والمخرج سوف يكون كما يلي:
gdb 16.3-1
liblzma5:amd64 5.8.1-1+deb13u2
linux-perf 6.12.111-1
openssh-server 1:10.0p1-7+deb13u4
xz-utils 5.8.1-1+deb13u2خط الأساس Baseline قبل أي تغيير
مثل Freund نقيس أولاً محاولة دخول فاشلة بمستخدم غير موجود، ونكررها ثلاث مرات حتى نعرف هل الرقم ثابت:
for i in 1 2 3; do
( time ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR nonexistent@localhost ) 2>&1
doneوالمخرج سوف يكون كما يلي:
nonexistent@localhost: Permission denied (publickey,password).
real 0m0.079s
user 0m0.013s
sys 0m0.000s
nonexistent@localhost: Permission denied (publickey,password).
real 0m0.080s
user 0m0.011s
sys 0m0.000s
nonexistent@localhost: Permission denied (publickey,password).
real 0m0.089s
user 0m0.012s
sys 0m0.000sفي الأمر أعلاه لاحظ التالي:
- الخيار
BatchMode=yesيمنع ssh من طلب كلمة مرور، فيفشل الدخول فوراً، وهذا ما نريده. - الخياران
StrictHostKeyChecking=noوUserKnownHostsFile=/dev/nullيمنعان سؤال قبول مفتاح السيرفر، فلا يدخل وقت تفكيرنا في القياس. - الرقم ثابت حول 0.08 ثانية، وهذا هو الرقم الذي سوف نقارن به.
نضيف تأخيراً بريئاً بأيدينا
الآن نكتب مكتبة صغيرة فيها دالة بناء Constructor تعمل تلقائياً عند تحميل المكتبة قبل main، تماماً مثل دوال IFUNC التي عملت مبكراً في القصة الحقيقية، وتضيع ربع ثانية من المعالج إذا كان البرنامج الذي حملها أحد برامج sshd، ولا تفعل شيئاً لأي برنامج آخر، وهذا يشبه شرط argv[0] الذي وجده Freund:
/* labdelay.c: a harmless delay for the ArabRoot lab.
It burns about 0.25 s of CPU when the program that loads it is one of the sshd binaries,
and does nothing for any other program. */
#include <string.h>
#include <time.h>
#include <unistd.h>
static volatile unsigned long sink;
static double now(void)
{
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec + t.tv_nsec / 1e9;
}
static void burn(double seconds)
{
double end = now() + seconds;
while (now() < end)
for (unsigned long i = 0; i < 200000; i++)
sink += i * i;
}
__attribute__((constructor)) static void lab_delay(void)
{
char exe[256] = {0};
if (readlink("/proc/self/exe", exe, sizeof exe - 1) > 0 && strstr(exe, "/sshd"))
burn(0.25);
}احفظ الملف في /root/labdelay.c، ثم نبني المكتبة بدون أسماء دوال، ونجعل المحمل الديناميكي يحملها في كل برنامج عبر الملف /etc/ld.so.preload الذي يشرحه دليل ld.so:
gcc -O1 -shared -fPIC -s -o /usr/local/lib/liblabdelay.so /root/labdelay.c
echo /usr/local/lib/liblabdelay.so > /etc/ld.so.preloadفي الإعداد أعلاه لاحظ التالي:
- الخيار
-sيحذف أسماء الدوال Symbols من الملف، والدوال كلهاstaticفلا يظهر أي اسم للخارج، وهذا يقلد ما رآه Freund عندما لم يستطع perf أن يسمي الكود. - الطريقة في القصة الحقيقية كانت اعتمادية غير مباشرة عبر libsystemd، أما نحن فنستخدم
/etc/ld.so.preloadلأنه أبسط طريقة لإدخال مكتبة إلى sshd بدون تعديل أي حزمة، والنتيجة من جهة المحقق متشابهة: كود لا يفترض أن يكون هناك يعمل عند تحميل البرنامج. - الشرط
strstr(exe, "/sshd")يطابق/usr/sbin/sshdوأيضاً/usr/lib/openssh/sshd-sessionوsshd-auth، لأن OpenSSH 10.0 في Debian 13 يقسم الخادم إلى ثلاثة برامج، وكل دخول يشغل اثنين منها.
العرض: القياس مرة أخرى
نكرر نفس القياس بدون أي تغيير آخر:
for i in 1 2 3; do
( time ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR nonexistent@localhost ) 2>&1
doneوالمخرج سوف يكون كما يلي:
nonexistent@localhost: Permission denied (publickey,password).
real 0m0.593s
user 0m0.016s
sys 0m0.000s
nonexistent@localhost: Permission denied (publickey,password).
real 0m0.589s
user 0m0.017s
sys 0m0.000s
nonexistent@localhost: Permission denied (publickey,password).
real 0m0.599s
user 0m0.014s
sys 0m0.000sلاحظ أن الصورة هي نفسها في رسالة Freund تقريباً: الوقت الكلي real زاد نحو نصف ثانية، من 0.08 إلى 0.59، أما وقت المعالج عند الكلاينت user فبقي كما هو، لذلك نعرف أن الوقت يضيع في السيرفر، ونصف الثانية هنا هو ربع ثانية في sshd-session وربع ثانية في sshd-auth.
الخطوة التالية أن نرى من يستهلك المعالج أثناء محاولات الدخول، فنكتب سكربتاً صغيراً يرسل أربعين محاولة دخول فاشلة متتالية، مثل ماسح على الإنترنت، ونفتح top أثناء عمله:
cat > /root/logins.sh <<'EOF'
#!/bin/sh
# 40 failed logins one after another, like a scanner hitting the SSH port
PORT=${1:-22}
for i in $(seq 40); do
ssh -p $PORT -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=QUIET nonexistent@localhost 2>/dev/null
done
EOF
chmod +x /root/logins.sh
/root/logins.sh & sleep 3; top -b -n 1 -o %CPU | head -12; waitوالمخرج سوف يكون كما يلي:
top - 14:50:17 up 1 day, 9:38, 0 users, load average: 0.42, 0.52, 0.89
Tasks: 10 total, 2 running, 7 sleeping, 0 stopped, 1 zombie
%Cpu(s): 12.3 us, 0.0 sy, 0.0 ni, 87.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 32036.0 total, 20469.6 free, 3087.6 used, 8998.4 buff/cache
MiB Swap: 8192.0 total, 7423.2 free, 768.8 used. 28948.4 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
5157 root 20 0 16896 9440 8100 S 100.0 0.0 0:00.26 sshd-sess+
1 root 20 0 2588 1144 1052 S 0.0 0.0 0:00.07 sleep
39 root 20 0 0 0 0 Z 0.0 0.0 0:00.00 dpkg-prec+
5091 root 20 0 11768 5300 4100 S 0.0 0.0 0:00.02 sshd
5130 root 20 0 4076 3204 2940 S 0.0 0.0 0:00.00 bashعملية sshd-session عند 100% من نواة كاملة، وهي عملية يفترض أن ترفض الدخول في أجزاء من الثانية، وهذا ما رآه Freund عندما أوقف كل شيء على جهازه ووجد sshd ما زال مشغولاً.
أين يذهب الوقت؟ perf
نريد أن نعرف داخل أي كود تقضي عمليات sshd وقتها، ولكن كل دخول يشغل برنامجاً جديداً، لذلك نشغل نسخة ثانية من الخادم على المنفذ 2222 داخل ال Container تحت perf record نفسه، فيسجل كل العمليات التي تخرج منها، ثم نرسل المحاولات إليها ونوقفها:
perf record -e cpu-clock -F 999 -g -o /root/perf.data -- /usr/sbin/sshd -D -p 2222 2>/root/perf.err &
PP=$!
sleep 1
/root/logins.sh 2222
kill $(pgrep -P $PP)
wait $PP
tail -1 /root/perf.err
perf report -i /root/perf.data --stdio --no-children --sort comm,dso -g none | grep -v "^$" | grep -v "^#"والمخرج سوف يكون كما يلي:
[ perf record: Captured and wrote 0.461 MB /root/perf.data (2031 samples) ]
46.43% sshd-auth liblabdelay.so
46.33% sshd-session liblabdelay.so
1.48% sshd-session [unknown]
1.33% sshd-auth sshd-auth
1.23% sshd liblabdelay.so
1.08% sshd-auth [unknown]
0.64% sshd-session libcrypto.so.3
0.34% sshd libcrypto.so.3
0.30% sshd-auth libc.so.6
0.30% sshd-session libc.so.6
0.25% sshd-session sshd-session
0.15% sshd-auth libcrypto.so.3
0.05% sshd libc.so.6
0.05% sshd [unknown]
0.05% sshd [vdso]في الأمر والمخرج أعلاه لاحظ التالي:
- الحدث
-e cpu-clockمؤقت برمجي يعمل داخل الأجهزة الافتراضية و Docker Desktop، أما الحدث الافتراضيcyclesفيحتاج عدادات المعالج Hardware Counters التي لا تتوفر دائماً في الأجهزة الافتراضية، وتفاصيل الخيارات في دليل perf-record. - الترتيب
--sort comm,dsoيجمع العينات حسب اسم العملية والملف الذي كان الكود فيه، وهو أسرع طريقة لترى «أي مكتبة» قبل «أي دالة». - أكثر من 92% من العينات في مكتبة واحدة اسمها
liblabdelay.soداخل عمليات الدخول، أما التشفير الحقيقي فيlibcrypto.so.3فلم يأخذ إلا نحو 1% في كل العمليات معاً، وهذا بالضبط شكل ما رآه Freund: الوقت في مكتبة لا علاقة لها بتسجيل الدخول. - إذا ربطت perf بعملية sshd الرئيسية بالخيار
-pبدلاً من تشغيلها تحت perf فقد تجد التقرير فارغاً، لأن كل دخول برنامج جديد يشغله الخادم عبرexec، لذلك شغلنا الخادم نفسه تحتperf record.
والآن نسأل: أي دالة داخل هذه المكتبة؟
perf report -i /root/perf.data --stdio --no-children --sort dso,sym -g none | grep -v "^$" | grep -v "^#" | head -8والمخرج سوف يكون كما يلي:
38.70% liblabdelay.so [.] 0x0000000000001358
33.33% liblabdelay.so [.] 0x0000000000001347
20.83% liblabdelay.so [.] 0x0000000000001351
0.74% liblabdelay.so [.] 0x000000000000135c
0.39% [unknown] [k] 0xffffffffb518199a
0.34% liblabdelay.so [.] 0x000000000000134a
0.20% [unknown] [k] 0xffffffffb515679a
0.15% sshd-auth [.] 0x0000000000096f8dلا توجد أسماء دوال، وإنما عناوين Addresses متقاربة جداً داخل المكتبة، وهذا شكل حلقة Loop صغيرة تدور، وهي نفس الملاحظة التي جعلت Freund مرتاباً: perf لم يستطع أن ينسب الوقت إلى اسم دالة.
أصغر تجربة ممكنة
مثل الأمر sshd -h في القصة، لا نحتاج أن نتصل بالخادم أصلاً، فإذا كان الكود يعمل عند تحميل البرنامج فإن أي تشغيل لـ sshd سوف يكون بطيئاً، حتى طباعة رقم الإصدار، ثم نغير متغيراً واحداً وهو اسم البرنامج، بنسخ الملف نفسه إلى اسم آخر:
time /usr/sbin/sshd -V
cp /usr/sbin/sshd /usr/local/bin/s
time /usr/local/bin/s -V
time ssh -Vوالمخرج سوف يكون كما يلي:
OpenSSH_10.0p2 Debian-7+deb13u4, OpenSSL 3.5.7 9 Jun 2026
real 0m0.254s
user 0m0.250s
sys 0m0.000s
OpenSSH_10.0p2 Debian-7+deb13u4, OpenSSL 3.5.7 9 Jun 2026
real 0m0.003s
user 0m0.000s
sys 0m0.000s
OpenSSH_10.0p2 Debian-7+deb13u4, OpenSSL 3.5.7 9 Jun 2026
real 0m0.006s
user 0m0.000s
sys 0m0.000sالملف نفسه بايت ببايت يأخذ 0.254 ثانية باسمه الأصلي و0.003 ثانية باسم آخر، وهذا يثبت أن البطء لا علاقة له بعمل sshd نفسه، وإنما بشيء يقرأ اسم البرنامج قبل أن يبدأ، وهو نفس نوع الملاحظة التي وصل إليها Freund عندما وجد أن المتغير TERM وحده يلغي البطء. ولاحظ أن التجربة الآن تأخذ ربع ثانية بدلاً من اتصال كامل، وهذا ما يجعل الخطوات التالية سريعة.
من يستدعي هذا الكود؟ gdb
نعرف أن المكتبة تقرأ الوقت باستمرار، لذلك نضع نقطة توقف على الدالة clock_gettime ونطبع سلسلة الاستدعاءات Backtrace عند أول توقف:
gdb -q -batch -ex "set breakpoint pending on" -ex "break clock_gettime" -ex run -ex bt \
-ex "info symbol \$pc" --args /usr/sbin/sshd -Vوالمخرج سوف يكون كما يلي:
Breakpoint 1 at 0x9840
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1.1, 0x00007ffff782ec00 in clock_gettime () from /lib/x86_64-linux-gnu/libc.so.6
#0 0x00007ffff782ec00 in clock_gettime () from /lib/x86_64-linux-gnu/libc.so.6
#1 0x00007ffff7fba151 in ?? () from /usr/local/lib/liblabdelay.so
#2 0x00007ffff7fba300 in ?? () from /usr/local/lib/liblabdelay.so
#3 0x00007ffff7fccdce in ?? () from /lib64/ld-linux-x86-64.so.2
#4 0x00007ffff7fcce9c in ?? () from /lib64/ld-linux-x86-64.so.2
#5 0x00007ffff7fe42f0 in ?? () from /lib64/ld-linux-x86-64.so.2
#6 0x0000000000000002 in ?? ()
#7 0x00007fffffffef36 in ?? ()
#8 0x00007fffffffef45 in ?? ()
#9 0x0000000000000000 in ?? ()
clock_gettime in section .text of /lib/x86_64-linux-gnu/libc.so.6اقرأ السلسلة من الأسفل إلى الأعلى: المحمل الديناميكي ld-linux-x86-64.so.2 استدعى كوداً في liblabdelay.so، وهذا الكود استدعى clock_gettime، ولا يوجد أي إطار من sshd نفسه في السلسلة. وهذا يعني أن الكود يعمل أثناء تحميل المكتبات، قبل أن تبدأ الدالة main في sshd، وهو بالضبط وقت عمل دوال IFUNC في القصة الحقيقية. ولاحظ أن الأسطر من 6 إلى 9 أرقام لا معنى لها لأن gdb لم يجد معلومات تكفي ليكمل السلسلة في كود بلا أسماء، وهذه طبيعة العمل على كود محذوفة رموزه.
من أين جاءت المكتبة؟
بقي السؤال الأخير: كيف وصلت هذه المكتبة إلى sshd؟ نعرض المكتبات التي يحملها البرنامج، ونستبعد مكتبات النظام المعتادة لنرى الغريب فقط، ثم نسأل مدير الحزم عن صاحب كل ملف:
ldd /usr/sbin/sshd | grep -v "=> /lib/x86_64"
cat /etc/ld.so.preload
dpkg -S /usr/local/lib/liblabdelay.so
dpkg -S /usr/lib/x86_64-linux-gnu/libcrypto.so.3 /usr/lib/x86_64-linux-gnu/liblzma.so.5
file /usr/local/lib/liblabdelay.soوالمخرج سوف يكون كما يلي:
linux-vdso.so.1 (0x00007fff41d79000)
/usr/local/lib/liblabdelay.so (0x00007f06960aa000)
/lib64/ld-linux-x86-64.so.2 (0x00007f069613e000)
/usr/local/lib/liblabdelay.so
dpkg-query: no path found matching pattern /usr/local/lib/liblabdelay.so
libssl3t64:amd64: /usr/lib/x86_64-linux-gnu/libcrypto.so.3
liblzma5:amd64: /usr/lib/x86_64-linux-gnu/liblzma.so.5
/usr/local/lib/liblabdelay.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=d99bcc9211ae535cf411c6df00f8a2b8c6fff903, strippedفي المخرج أعلاه لاحظ التالي:
- المكتبة الغريبة ظهرت في
lddبدون سهم=>لأنها لم تأت من قائمة اعتماديات، وإنما من/etc/ld.so.preload. - الأمر
dpkg -Sلا يجد حزمة تملكها، أي أن أحداً وضعها يدوياً، وكلمةstrippedفي مخرجfileتؤكد أن أسماءها محذوفة. - في القصة الحقيقية كانت المكتبة المشبوهة liblzma نفسها، وهي مملوكة لحزمة رسمية وموقعة من التوزيعة، لذلك لم يكن السؤال «من وضع الملف؟» كافياً، وإنما «من أين جاء الكود الذي بنيت منه الحزمة؟»، وهذا ما أوصل Freund إلى مقارنة الملف المضغوط مع Git كما سنفعل بعد قليل.
ولاحظ أن الأمر dpkg --verify liblzma5 الذي يقارن ملفات الحزمة المثبتة مع الهاش المسجل لها لا يكشف حالة مثل XZ، لأن الحزمة نفسها بنيت من الملف الخبيث، فالهاش يطابق. وكذلك تحذر صفحة دليل ldd من استخدامه على برنامج غير موثوق لأنه قد يشغل كوداً منه، والبديل الآمن هو objdump -p أو readelf -d مع البحث عن NEEDED، وهما يعرضان الاعتماديات المباشرة فقط.
سلسلة sshd وlibsystemd وliblzma على Debian 12 وDebian 13
الآن نرى السلسلة الحقيقية التي حملت liblzma إلى sshd، على Debian 12 التي ما زالت تستخدم تعديل libsystemd، وبنسخة xz سليمة طبعاً. نشغل Container ثانياً:
docker run -d --name lab-xz-bookworm debian:12.12 sleep infinity
docker exec -it lab-xz-bookworm bash
apt-get update
apt-get install -y openssh-server xz-utils binutils
dpkg-query -W openssh-server liblzma5 libsystemd0 xz-utils
ldd /usr/sbin/sshd | grep -E "systemd|lzma"
ldd /usr/sbin/sshd | wc -lوالمخرج سوف يكون كما يلي:
liblzma5:amd64 5.4.1-1+deb12u2
libsystemd0:amd64 252.39-1~deb12u2
openssh-server 1:9.2p1-2+deb12u10
xz-utils 5.4.1-1+deb12u2
libsystemd.so.0 => /lib/x86_64-linux-gnu/libsystemd.so.0 (0x00007fa9ef3da000)
liblzma.so.5 => /lib/x86_64-linux-gnu/liblzma.so.5 (0x00007fa9eea43000)
28مكتبة الضغط موجودة في عملية sshd، والسؤال: من طلبها؟ نقرأ الاعتماديات المباشرة لـ sshd ثم لـ libsystemd:
readelf -d /usr/sbin/sshd | grep NEEDED
readelf -d /usr/lib/x86_64-linux-gnu/libsystemd.so.0 | grep NEEDEDوالمخرج سوف يكون كما يلي:
0x0000000000000001 (NEEDED) Shared library: [libcrypt.so.1]
0x0000000000000001 (NEEDED) Shared library: [libwrap.so.0]
0x0000000000000001 (NEEDED) Shared library: [libaudit.so.1]
0x0000000000000001 (NEEDED) Shared library: [libpam.so.0]
0x0000000000000001 (NEEDED) Shared library: [libsystemd.so.0]
0x0000000000000001 (NEEDED) Shared library: [libselinux.so.1]
0x0000000000000001 (NEEDED) Shared library: [libgssapi_krb5.so.2]
0x0000000000000001 (NEEDED) Shared library: [libkrb5.so.3]
0x0000000000000001 (NEEDED) Shared library: [libcom_err.so.2]
0x0000000000000001 (NEEDED) Shared library: [libcrypto.so.3]
0x0000000000000001 (NEEDED) Shared library: [libz.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x0000000000000001 (NEEDED) Shared library: [libcap.so.2]
0x0000000000000001 (NEEDED) Shared library: [libgcrypt.so.20]
0x0000000000000001 (NEEDED) Shared library: [liblzma.so.5]
0x0000000000000001 (NEEDED) Shared library: [libzstd.so.1]
0x0000000000000001 (NEEDED) Shared library: [liblz4.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x0000000000000001 (NEEDED) Shared library: [ld-linux-x86-64.so.2]الأسطر الاثنا عشر الأولى من sshd، وliblzma ليست بينها، ولكن libsystemd موجودة، ثم تأتي أسطر libsystemd وفيها liblzma.so.5، وهذه هي السلسلة التي وصفها Freund بالأرقام: sshd يطلب 12 مكتبة مباشرة، ومع الاعتماديات غير المباشرة يحمل 28 سطراً في ldd، وكل مكتبة منها كود يعمل بصلاحيات root داخل أهم خدمة على السيرفر.
ثم نعود إلى ال Container الأول على Debian 13 ونفحص الشيء نفسه بعد أن أزلنا التأخير، وسوف نشرح الإزالة في قسم التنظيف:
ldd /usr/sbin/sshd
ldd /usr/sbin/sshd | grep -E "lzma|systemd" || echo "no liblzma or libsystemd in sshd"
ldd /usr/lib/x86_64-linux-gnu/libsystemd.so.0
readelf -n /usr/lib/x86_64-linux-gnu/libsystemd.so.0 | grep -i lzma
zcat /usr/share/doc/openssh-server/changelog.Debian.gz | grep -n -B3 -A4 "not link against libsystemd"والمخرج سوف يكون كما يلي:
linux-vdso.so.1 (0x00007ffc981cd000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f6d74a92000)
libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3 (0x00007f6d74457000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6d74263000)
libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f6d741b4000)
/lib64/ld-linux-x86-64.so.2 (0x00007f6d74b57000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f6d74192000)
libzstd.so.1 => /lib/x86_64-linux-gnu/libzstd.so.1 (0x00007f6d740c8000)
no liblzma or libsystemd in sshd
linux-vdso.so.1 (0x00007ffe68786000)
libcap.so.2 => /lib/x86_64-linux-gnu/libcap.so.2 (0x00007f9087d6c000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f9087c7c000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9087a88000)
/lib64/ld-linux-x86-64.so.2 (0x00007f9087e93000)
Dlopen Metadata: [{"feature":"lzma","description":"Support lzma compression in journal and coredump files","priority":"suggested","soname":["liblzma.so.5"]}]
556-openssh (1:9.7p1-4) unstable; urgency=medium
557-
558- * Rework systemd readiness notification and socket activation patches to
559: not link against libsystemd (the former via an upstream patch).
560- * Force -fzero-call-used-regs=used not to be used on ppc64el (it's
561- unsupported, but configure fails to detect this).
562-
563- -- Colin Watson <[email protected]> Wed, 03 Apr 2024 12:06:08 +0100في المخرج أعلاه لاحظ التالي:
- sshd في Debian 13 يحمل 8 أسطر فقط في
lddبدلاً من 28، ولا يوجد فيها libsystemd ولا liblzma، لأن Debian غيرت تعديلها في الإصدار1:9.7p1-4بتاريخ 3 أبريل 2024، أي بعد الإعلان بخمسة أيام، كما يظهر في سجل التغييرات الذي يأتي مع الحزمة نفسها، وتجد صفحة الحزمة في متتبع حزم Debian. - libsystemd 257 نفسها لم تعد تطلب liblzma مباشرة، وإنما تعلن في ملاحظة داخل الملف
Dlopen Metadataأنها قد تحملها عند الحاجة بالدالةdlopenلضغط السجلات فقط، وهذا هو التغيير الذي اقترحه طلب الدمج رقم 31550 في systemd المفتوح في 29 فبراير 2024 والمدمج في 5 مارس 2024، وقد ذكر Russ Cox أن هذا التغيير كان سيبطل الهجوم، وأن اقترابه ربما جعل المهاجم يستعجل. - تقليل الاعتماديات في خدمة حساسة ليس ترفاً، فكل مكتبة تخرج من عملية sshd تعني كوداً أقل يعمل بصلاحيات root عند كل اتصال من الإنترنت.
هل على سيرفرك نسخة xz المصابة؟
كان مع رسالة Freund سكربت صغير كتبه Vegard Nossum، وفكرته بسيطة: يبحث بالأمر ldd عن مكتبة liblzma التي يحملها sshd، ثم يبحث داخلها عن تسلسل بايتات Byte Signature معروف من بداية دالة في الكود الخبيث، فيطبع «probably vulnerable» أو «probably not vulnerable». ولكن لا تقم بتنزيل أي سكربت فحص من الإنترنت وتشغيله بصلاحيات root على سيرفر حقيقي، والسبب أن هذا بالضبط ما يستغله المهاجمون بعد كل حادثة مشهورة، والفحص الذي تحتاجه في الغالب أبسط من ذلك: رقم إصدار المكتبة، وهل يحملها sshd. لذلك كتبنا سكربتاً صغيراً يقرأ فقط ولا ينزل ولا يشغل شيئاً، اقرأه سطراً سطراً قبل تشغيله:
#!/bin/sh
# check-xz.sh: reads versions and links only; it downloads and runs nothing
v=$(xz --version 2>/dev/null | sed -n 's/^liblzma //p')
echo "liblzma version : ${v:-xz not installed}"
if command -v dpkg-query >/dev/null 2>&1; then
echo "package : $(dpkg-query -W -f='${Package} ${Version}' liblzma5 2>/dev/null)"
elif command -v rpm >/dev/null 2>&1; then
echo "package : $(rpm -q xz-libs)"
fi
case "$v" in
5.6.0|5.6.1) echo "verdict : BACKDOORED VERSION, downgrade or upgrade now" ;;
*) echo "verdict : not 5.6.0 or 5.6.1" ;;
esac
sshd=$(command -v sshd || echo /usr/sbin/sshd)
if [ -x "$sshd" ]; then
lz=$(ldd "$sshd" | grep -o '/[^ ]*liblzma[^ ]*')
echo "sshd loads lzma : ${lz:-no}"
fiاحفظه باسم check-xz.sh على جهازك، ثم انسخه إلى ال Container الثاني على Debian 12 والأول على Debian 13 وشغله في كل منهما:
for c in lab-xz-bookworm lab-xz-ssh; do
docker cp check-xz.sh $c:/root/check-xz.sh
echo "== $c"
docker exec $c sh /root/check-xz.sh
doneوالمخرج سوف يكون كما يلي:
== lab-xz-bookworm
liblzma version : 5.4.1
package : liblzma5 5.4.1-1+deb12u2
verdict : not 5.6.0 or 5.6.1
sshd loads lzma : /lib/x86_64-linux-gnu/liblzma.so.5
== lab-xz-ssh
liblzma version : 5.8.1
package : liblzma5 5.8.1-1+deb13u2
verdict : not 5.6.0 or 5.6.1
sshd loads lzma : noفي السكربت والمخرج أعلاه لاحظ التالي:
- السكربت يقرأ رقم إصدار المكتبة من
xz --versionوليس من اسم الحزمة، والسبب أن Debian سمت النسخة التي رجعت إليها5.6.1+really5.4.5-1كما يظهر في متتبع الأمان، فإذا بحثت في اسم الحزمة عن «5.6.1» فسوف تجد إنذاراً خاطئاً False Positive على نظام سليم. - Debian 12 ما زال sshd فيها يحمل liblzma، ولكن بالنسخة 5.4.1 السليمة، وDebian 13 لا يحملها أصلاً.
- إذا كنت تفحص أسطولاً من السيرفرات أو Docker Images فأدوات فحص الاعتماديات تعرف هذه الثغرة من قواعد بياناتها، كما شرحنا في مقال فحص الاعتماديات SCA ومقال فحص ال Docker Images مع Trivy، وهي أنسب من سكربت يدوي عندما يكبر العدد.
مقارنة ملف الإصدار مع Git على إصدار سليم
هذه هي التقنية التي كشفت مصدر الباب الخلفي، وسوف نطبقها على الإصدار 5.8.1 من xz نفسه، وهو إصدار سليم بناه ووقعه المشرف الأصلي Lasse Collin، حتى ترى شكل الفرق الطبيعي بين الملف المضغوط والمستودع، وتعرف أين تنظر. نشغل Container ثالثاً ونثبت الأدوات:
docker run -d --name lab-xz-diff debian:13.1 sleep infinity
docker exec -it lab-xz-diff bash
apt-get update
apt-get install -y git curl ca-certificates gnupg xz-utils diffutils autoconf automake libtool autopoint gettext po4a doxygen make gccأولاً ننزل الملف المضغوط وتوقيعه من صفحة الإصدار في GitHub، والمفتاح العام من موقع المشروع، ونتحقق من التوقيع:
mkdir -p /work && cd /work
curl -sSLO https://github.com/tukaani-project/xz/releases/download/v5.8.1/xz-5.8.1.tar.gz
curl -sSLO https://github.com/tukaani-project/xz/releases/download/v5.8.1/xz-5.8.1.tar.gz.sig
curl -sSL https://tukaani.org/misc/lasse_collin_pubkey.txt | gpg --quiet --import
gpg --verify xz-5.8.1.tar.gz.sig xz-5.8.1.tar.gz
sha256sum xz-5.8.1.tar.gzوالمخرج سوف يكون كما يلي:
gpg: Signature made Thu Apr 3 11:38:23 2025 UTC
gpg: using RSA key 3690C240CE51B4670D30AD1C38EE757D69184620
gpg: Good signature from "Lasse Collin <[email protected]>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
Primary key fingerprint: 3690 C240 CE51 B467 0D30 AD1C 38EE 757D 6918 4620
507825b599356c10dca1cd720c9d0d0c9d5400b9de300af00e4d1ea150795543 xz-5.8.1.tar.gzالتوقيع صحيح، ولكن تذكر أن الإصدارين 5.6.0 و5.6.1 كانا موقعين أيضاً توقيعاً صحيحاً من مفتاح Jia Tan كما تذكر صفحة المشروع، فالتوقيع يجيب عن سؤال «من نشر الملف؟» ولا يجيب عن سؤال «هل محتوى الملف هو نفسه ما في المستودع؟». لذلك ننزل المستودع عند وسم الإصدار نفسه ونتحقق من توقيع الوسم Tag، ثم نقارن المجلدين:
tar xf xz-5.8.1.tar.gz
git -c advice.detachedHead=false clone -q --depth 1 --branch v5.8.1 https://github.com/tukaani-project/xz.git xz-git
git -C xz-git log -1 --format="%H %an %ad"
git -C xz-git tag -v v5.8.1 2>&1 | grep -i "gpg\|signature" | head -4
diff -rq --exclude=.git xz-git xz-5.8.1 > diff.txt
wc -l < diff.txt
grep -c "^Only in xz-5.8.1" diff.txt
grep "^Only in xz-git" diff.txt
grep "differ$" diff.txtوالمخرج سوف يكون كما يلي:
a522a226545730551f7e7c2685fab27cf567746c Lasse Collin Thu Apr 3 14:34:43 2025 +0300
gpg: Signature made Thu Apr 3 11:34:49 2025 UTC
gpg: using RSA key 3690C240CE51B4670D30AD1C38EE757D69184620
gpg: issuer "[email protected]"
gpg: Good signature from "Lasse Collin <[email protected]>" [unknown]
88
78
Only in xz-git: .codespellrc
Only in xz-git: .gitattributes
Only in xz-git: .github
Only in xz-git: .gitignore
Only in xz-git/build-aux: ci_build.bash
Only in xz-git/doc: SHA256SUMS
Only in xz-git/m4: .gitignore
Only in xz-git/po: .gitignore
Only in xz-git/tests: code_coverage.sh
Files xz-git/ChangeLog and xz-5.8.1/ChangeLog differفي المخرج أعلاه لاحظ التالي:
- هناك 88 فرقاً بين المجلدين، منها 78 ملفاً أو مجلداً موجوداً في الملف المضغوط فقط، و9 في Git فقط، وملف واحد مختلف، فالفرق موجود في كل إصدار سليم وليس دليلاً على شيء بحد ذاته.
- ما في Git فقط هو ملفات تخص المطورين مثل
.githubو.gitignore، وملفChangeLogيختلف لأن المشرف يولده من تاريخ Git عند تجهيز الإصدار.
والمهم هو ما في الملف المضغوط فقط، وهنا ننظر إلى ملفات m4 لأنها المكان الذي اختبأ فيه السطر الخبيث:
grep "^Only in xz-5.8.1/m4" diff.txtوالمخرج سوف يكون كما يلي:
Only in xz-5.8.1/m4: build-to-host.m4
Only in xz-5.8.1/m4: gettext.m4
Only in xz-5.8.1/m4: host-cpu-c-abi.m4
Only in xz-5.8.1/m4: iconv.m4
Only in xz-5.8.1/m4: intlmacosx.m4
Only in xz-5.8.1/m4: lib-ld.m4
Only in xz-5.8.1/m4: lib-link.m4
Only in xz-5.8.1/m4: lib-prefix.m4
Only in xz-5.8.1/m4: libtool.m4
Only in xz-5.8.1/m4: ltoptions.m4
Only in xz-5.8.1/m4: ltsugar.m4
Only in xz-5.8.1/m4: ltversion.m4
Only in xz-5.8.1/m4: lt~obsolete.m4
Only in xz-5.8.1/m4: nls.m4
Only in xz-5.8.1/m4: po.m4
Only in xz-5.8.1/m4: progtest.m4نفس الملف build-to-host.m4 موجود هنا أيضاً، وهو ملف طبيعي يأتي من أدوات gettext، ولذلك لم يكن وجوده في 5.6.0 مثيراً للشك، والسؤال الصحيح ليس «هل الملف موجود؟» وإنما «هل محتواه هو نفس ما تولده الأدوات الرسمية؟». والطريقة للإجابة هي أن تولد هذه الملفات بنفسك من المستودع بنفس الأدوات ثم تقارن، ونبدأ بمعرفة الأدوات التي استخدمها المشرف من رؤوس الملفات:
head -5 xz-5.8.1/m4/build-to-host.m4
grep -m1 "Generated by GNU Autoconf" xz-5.8.1/configure
grep -m1 "generated by automake" xz-5.8.1/Makefile.in
grep -m1 -i "serial" xz-5.8.1/m4/gettext.m4والمخرج سوف يكون كما يلي:
# build-to-host.m4
# serial 5
dnl Copyright (C) 2023-2024 Free Software Foundation, Inc.
dnl This file is free software; the Free Software Foundation
dnl gives unlimited permission to copy and/or distribute it,
# Generated by GNU Autoconf 2.72 for XZ Utils 5.8.1.
# Makefile.in generated by automake 1.17 from Makefile.am.
# serial 81 (gettext-0.23)المشرف استخدم autoconf 2.72 وautomake 1.17 وgettext 0.23، وهي نفس النسخ الموجودة في Debian 13، لذلك نولد الملفات من نسخة من المستودع ونقارن أهم الملفات:
cp -a xz-git xz-regen
(cd xz-regen && autoreconf -fi > /work/autoreconf.log 2>&1); echo "autoreconf exit $?"
for f in m4/build-to-host.m4 m4/gettext.m4 m4/libtool.m4 aclocal.m4 configure build-aux/ltmain.sh; do
if cmp -s xz-regen/$f xz-5.8.1/$f; then echo "same $f"; else echo "DIFFERS $f"; fi
done
grep -m1 "^VERSION=" xz-5.8.1/build-aux/ltmain.sh xz-regen/build-aux/ltmain.shوالمخرج سوف يكون كما يلي:
autoreconf exit 0
same m4/build-to-host.m4
same m4/gettext.m4
DIFFERS m4/libtool.m4
same aclocal.m4
DIFFERS configure
DIFFERS build-aux/ltmain.sh
xz-5.8.1/build-aux/ltmain.sh:VERSION=2.5.4.1-baa1-dirty
xz-regen/build-aux/ltmain.sh:VERSION="2.5.4 Debian-2.5.4-4"في المخرج أعلاه لاحظ التالي:
- الملف
build-to-host.m4في الإصدار السليم مطابق بايت ببايت لما تولده أدوات Debian، وهذه هي المقارنة التي كانت ستكشف السطر الزائد في 5.6.0 فوراً، لأن الملف هناك كان مختلفاً عن النسخة الأصلية التي تأتي مع gettext. - ملفات libtool وconfigure مختلفة، والسبب ظاهر في السطر الأخير: المشرف استخدم نسخة تطوير من libtool رقمها
2.5.4.1-baa1-dirty، ونحن استخدمنا نسخة Debian، لذلك يختلفconfigureالذي يتضمن أجزاء من libtool. وهذا هو الجزء الصعب في هذه التقنية: لن تجد تطابقاً كاملاً في الغالب، وكل فرق يحتاج تفسيراً مثل هذا، وما لا تجد له تفسيراً هو ما يستحق القراءة سطراً سطراً. - لهذا السبب تتجه مشاريع كثيرة بعد الحادثة إلى أن تبني التوزيعات من وسم Git مباشرة، أو أن يكون ملف الإصدار قابلاً للتوليد من المستودع بنفس النتيجة، وهي فكرة البناء القابل للتكرار Reproducible Build التي شرحناها في مقال أدوات البناء، وتفاصيلها في موقع مشروع Reproducible Builds.
وكذلك يمكنك أن ترى أن ملفي الاختبار الخبيثين لم يعودا في المستودع، مع بقاء ملاحظة المجلد التي سمحت بإخفائهما:
ls xz-git/tests/files | grep -E "bad-3-corrupt|good-large"; echo "exit $?"
sed -n 1,12p xz-git/tests/files/READMEوالمخرج سوف يكون كما يلي:
exit 1
.xz, .lzma, and .lz Test Files
------------------------------
0. Introduction
This directory contains bunch of files to test handling of .xz,
.lzma (LZMA_Alone), and .lz (lzip) files in decoder implementations.
Many of the files have been created by hand with a hex editor, thus
there is no better "source code" than the files themselves. All the
test files and this README may be distributed under the terms of
the BSD Zero Clause License (0BSD).حذف التجربة
أولاً نزيل التأخير من ال Container الأول ونتأكد أن الرقم عاد كما كان، فالتحقيق لا ينتهي بإيجاد السبب وإنما بقياس يثبت أن إزالته أعادت خط الأساس:
rm /etc/ld.so.preload /usr/local/lib/liblabdelay.so /usr/local/bin/s
for i in 1 2 3; do
( time ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR nonexistent@localhost ) 2>&1 | grep -E "real|denied"
done
time /usr/sbin/sshd -Vوالمخرج سوف يكون كما يلي:
nonexistent@localhost: Permission denied (publickey,password).
real 0m0.095s
nonexistent@localhost: Permission denied (publickey,password).
real 0m0.079s
nonexistent@localhost: Permission denied (publickey,password).
real 0m0.080s
OpenSSH_10.0p2 Debian-7+deb13u4, OpenSSL 3.5.7 9 Jun 2026
real 0m0.003s
user 0m0.000s
sys 0m0.000sثم نحذف ال Containers الثلاثة بأسمائها من جهازك:
docker rm -f lab-xz-ssh lab-xz-bookworm lab-xz-diffماذا نتعلم كمهندسين؟
- الفضول تجاه الأشياء الصغيرة: نصف ثانية في تسجيل الدخول وشكوى من valgrind في اختبار هي أشياء يتجاهلها أغلبنا، ولكن كل منها كان خيطاً، والقاعدة العملية: إذا تغير رقم ثابت بدون سبب تعرفه فاكتب ملاحظة بتاريخها، حتى لو لم يكن عندك وقت للتحقيق الآن، والسبب أن الملاحظة الثانية هي التي تحول الصدفة إلى نمط، كما حدث مع Freund عندما تذكر شكوى valgrind.
- القياس قبل التخمين: كل خطوة في التحقيق كانت فرضية ثم قياس يضيق المكان: الخادم وليس الكلاينت، ثم liblzma وليس OpenSSL، ثم مرحلة التحميل وليس الاتصال، ثم الملف المضغوط وليس Git. وهذه نفس الطريقة التي شرحناها في دليل استكشاف الأخطاء، ولا تحتاج أدوات خاصة وإنما
timeوperfوgdb وldd. - اجعل التجربة صغيرة: الانتقال من «تسجيل دخول عبر الشبكة» إلى «تشغيل sshd ببيئة فارغة» حول التجربة من ثوان إلى أجزاء من الثانية، وجعل كل متغير قابلاً للاختبار وحده.
- قارن الملف المضغوط مع Git: إذا كانت توزيعتك أو خط CI عندك يبني من ملفات إصدار مضغوطة، فاعرف ما فيها مما ليس في المستودع، وولد الملفات المولدة بنفسك وقارن، أو ابن من وسم Git الموقع مباشرة، وقد شرحنا موقع هذه الفحوص في الخط في مقال خط CI/CD الآمن، وتوقيع ما تبنيه في قسم شهادة البناء Provenance.
- التوقيع يثبت من نشر الملف وليس ما فيه: كل ملفات XZ الخبيثة كانت موقعة توقيعاً صحيحاً من مشرف معتمد في المشروع، لذلك التوقيع ضروري ولكنه جزء واحد من الثقة.
- قلل اعتماديات الخدمات الحساسة: مخرج
lddلملف sshd في Debian 12 فيه 28 سطراً، وفي Debian 13 ثمانية أسطر فقط، وكل مكتبة تخرج من عملية تعمل بصلاحيات root وتستقبل اتصالات من الإنترنت هي باب أقل، وهذا ما فعلته Debian وsystemd بعد الحادثة، وما تستطيع فعله أنت في Docker Images التي تبنيها باختيار Base Image صغيرة كما شرحنا في مقال فحص ال Docker Images. - شغل نسخة تجريبية في مكان ما: الباب الخلفي اكتشف لأن أحدهم كان يشغل Debian unstable لأسباب عملية، فوجود بيئة اختبار تستخدم إصدارات أحدث من الإنتاج يعطيك فرصة لترى المشاكل قبل أن تصل إليك.
- ادعم المشرفين: الهجوم كله بني على مشرف واحد مرهق بلا تمويل، وهذا ما ناقشناه بالتفصيل في مقال الحوكمة، وأقل ما تستطيعه شركتك هو أن تعرف المشاريع الصغيرة التي تعتمد عليها في خدماتها الحساسة، وأن تساهم في مراجعتها أو تمويلها.
الخلاصة
- في 29 مارس 2024 أعلن Andres Freund، مطور PostgreSQL في Microsoft، أن الإصدارين 5.6.0 و5.6.1 من XZ Utils يحتويان على باب خلفي يستهدف sshd، وأخذ الرقم CVE-2024-3094 بدرجة 10 من 10.
- بدأ الاكتشاف من زيادة نحو نصف ثانية في تسجيل الدخول عبر SSH واستهلاك غريب للمعالج، ثم perf أظهر الوقت داخل liblzma بدون أسماء دوال، ثم تذكر أخطاء valgrind في اختبارات PostgreSQL.
- سلسلة التحقيق كانت: قياس بالأمر
time، ثم Profiling بالأمرperf، ثمlddلفهم كيف وصلت المكتبة إلى sshd عبر libsystemd، ثم تجربة صغيرة ببيئة فارغة، ثم تتبع بـ intel_pt وgdb، ثم مقارنة الملف المضغوط مع Git. - الباب الخلفي حقن وقت البناء من ملف
build-to-host.m4الموجود في الملف المضغوط فقط ومن ملفات اختبار في المستودع، وعمل وقت التشغيل عبر IFUNC وتحويل الدالةRSA_public_decrypt، ولا يفتح إلا لمن يملك مفتاح المهاجم. - تأثرت النسخ التجريبية فقط تقريباً: Debian testing وunstable وFedora Rawhide وFedora 40 beta، ولم تتأثر Debian المستقرة ولا Red Hat Enterprise Linux.
- بعد الحادثة لم يعد sshd في Debian يرتبط بـ libsystemd، ولم تعد libsystemd تطلب liblzma مباشرة، وتستطيع التأكد على سيرفرك بقراءة رقم الإصدار و
lddبدون أي سكربت من الإنترنت. - الطريقة أهم من القصة: لاحظ التغيير، قسه، ضيق المكان بتغيير واحد في كل مرة، وقارن ما تبنيه مع مصدره.
وإذا أردت أن تكمل من هنا، فهذه المقالات تشرح الأدوات والطريقة وسلسلة التوريد التي مر ذكرها:
دليلاستكشاف الأخطاء وتحليل الأداء للمبتدئين: من العرض إلى السببعندما يبطئ الموقع أو يعطي 502، هذه طريقة منظمة توصلك من العرض إلى السبب قبل أن تخمن أو تشتري سيرفراً أكبر.
دليلتحليل أداء سيرفر Linux في 60 ثانية: ماذا تكتب أولاً؟ماذا تكتب أول ما تدخل إلى سيرفر بطيء، وكيف تعرف خلال دقيقة هل المشكلة في المعالج أم الذاكرة أم القرص أم الشبكة.
دليلتتبع الطلب البطيء من المتصفح إلى قاعدة البياناتكيف تأخذ طلباً واحداً بطيئاً أو فاشلاً وتعرف في أي طبقة ضاع وقته، بقياسات حقيقية في كل طبقة بدل التخمين.
دليلمن يدير المشروع مفتوح المصدر؟ نماذج الحوكمة Governanceمن يقرر في مشروع يساهم فيه آلاف الأشخاص، ومن يستطيع تغيير رخصته، وما الذي تفحصه قبل أن تعتمد على أي مشروع.
دليلأمن سلسلة التوريد البرمجية Software Supply Chain للمبتدئينكل ما بين كود غيرك وسيرفرك قد يحمل كود المهاجم، وهنا تعرف التهديدات الحقيقية بحوادث موثقة والدفاع المناسب لكل حلقة.
دليلSCA: كيف تجد الثغرات المعروفة في مكتبات مشروعككيف تجد أداة فحص الاعتماديات SCA المكتبات المصابة في مشروعك، ولماذا تختلف نتائج الأدوات، وكيف تبقي مكتباتك محدثة.
دليلفحص ال Docker Images وملفات البنية ككود IaCماذا يرى فاحص الثغرات داخل ال Docker Image وما الذي يفوته، وكيف تختار Base Image أصغر وتجعل الفحص بوابة في خط CI.
دليلخط CI/CD آمن من البداية للنهاية على Gitea Actionsكيف تجمع فحوص الأمن في خط واحد لا يستطيع طلب الدمج تجاوزه، ثم ترسل إلى الإنتاج نفس ال Docker Image الموقعة التي فحصتها.
دليلأدوات البناء Build Tools وماذا يحدث عندما تبني مشروعكما الذي يحدث بين ضغطة البناء والملف الناتج، ولماذا ينجح البناء على جهازك ويفشل على سيرفر CI، ومتى يعطيك نفس النتيجة مرتين.سجل التحديثات (Changelog)
- أكتوبر 2026: كتابة المقال اعتماداً على رسالة Andres Freund في oss-security ومنشوراته في Mastodon، ونشرات NVD وRed Hat وCISA ومتتبع الأمان في Debian وإعلان Arch Linux، وصفحة Lasse Collin عن الحادثة، وتغطية LWN، وتحليل Russ Cox وأسئلة Sam James الشائعة، مع تجربة عملية على Debian 13.1 وDebian 12.12 ورسوم توضيحية من عملنا.