الأدلة التقنية

كيف اكتشف Andres Freund الباب الخلفي في XZ

خطوات التحقيق والأوامر التي قادت مطوراً لاحظ نصف ثانية زائدة في دخول SSH إلى باب خلفي في مكتبة XZ، وكيف تكررها على سيرفرك.

كيف اكتشف Andres Freund الباب الخلفي في XZ

في نهاية مارس 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.
  • الدروس التي تستحق أن تنقلها إلى فريقك.

الصورة التالية تبين خطوات التحقيق التي سوف نمر عليها بالترتيب:

ثماني بطاقات مرقمة من اليمين إلى اليسار: 1 العرض، الدخول عبر SSH أبطأ ويستهلك المعالج من 0.299s إلى 0.807s، 2 قياس، perf يشير إلى liblzma بدون اسم دالة، 3 ربط، تذكر أخطاء valgrind في اختبارات PostgreSQL بعد تحديث حزم Debian sid، 4 تضييق، إعادة الإنتاج بالأمر env -i LANG=C sshd -h وهو نصف ثانية ومع TERM نحو 0.01s، 5 تتبع، perf record -e intel_pt//ub ثم gdb، 6 اكتشاف، RSA_public_decrypt يقفز إلى liblzma، 7 المصدر، build-to-host.m4 في ملف الإصدار فقط وملفات اختبار في tests/files، 8 البلاغ، إلى Debian وقائمة distros ثم oss-security في 29 مارس 2024، وتحتها حلقة: ملاحظة غريبة ثم فرضية ثم قياس ثم مكان أصغر للبحث
خطوات التحقيق كما وصفها Andres Freund، وكل خطوة فرضية ثم قياس ثم مكان أصغر للبحث

من هو 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 مكتبة الضغط في كل مرة يبدأ فيها. وسوف نرى هذه السلسلة بأرقام حقيقية في التجربة العملية، والصورة التالية تبين السلسلة قبل الحادثة وبعدها:

عمودان: على اليمين قبل الحادثة في Debian في مارس 2024، الملف /usr/sbin/sshd يرتبط بمكتبة libsystemd.so.0 بسبب تعديل من التوزيعة لإشعار sd_notify، وlibsystemd تحتاج liblzma.so.5 كاعتمادية مباشرة NEEDED، والنسخة 5.6.0 أو 5.6.1 تشغل الكود الخبيث عند التحميل، مع رقمين 12 مكتبة يطلبها sshd مباشرة في Debian 12 و28 سطراً في مخرج ldd، وعلى اليسار بعد الحادثة في Debian 13، sshd بدون libsystemd لأن إشعار الجاهزية مكتوب داخل OpenSSH، وlibsystemd 257 تحمل liblzma عند الحاجة عبر dlopen فقط، و8 أسطر في مخرج ldd وصفر مكتبات lzma داخل عملية sshd
كيف وصلت liblzma إلى عملية sshd في Debian وFedora، وكيف انقطعت السلسلة بعد الحادثة

ولاحظ أن هذه السلسلة لا توجد في كل التوزيعات، فكما يذكر الخط الزمني الذي جمعه 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 على جهاز بناء الحزم، 1 الملف المضغوط للإصدار xz-5.6.x.tar.gz فيه m4/build-to-host.m4 معدل، 2 سكربت مخفي يعمل في نهاية configure ويفحص شروط البيئة، 3 ملفات اختبار في Git تبدو تالفة وفيها الجزء الأكبر من الكود، 4 مكتبة liblzma معدلة بكود جاهز Object File يدخل أثناء الربط، مع شروط البناء x86-64 وglibc وgcc مع GNU ld وبناء حزمة deb أو rpm، ثم وقت التشغيل Run Time على السيرفر، 5 يبدأ sshd ويحمل المكتبات عبر libsystemd، 6 المحمل يستدعي crc64_resolve من نوع IFUNC قبل أن تقفل الجداول، 7 تحويل الدالة RSA_public_decrypt إلى كود الباب الخلفي، 8 أمر موقع بمفتاح يملكه المهاجم وحده داخل شهادة الدخول ينفذ بصلاحيات root، مع شروط التشغيل: اسم البرنامج /usr/sbin/sshd وعدم وجود TERM ووجود LANG وعدم وجود LD_DEBUG
الباب الخلفي على مرحلتين: سكربت يعدل المكتبة وقت البناء، ثم كود يحول دالة في sshd وقت التشغيل

في وقت البناء 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 لم يدخلا بشكل واسع في التوزيعات، وما دخل منهما كان غالباً في النسخ التجريبية. والصورة التالية تبين الأسابيع الخمسة بين دخول ملفات الاختبار الخبيثة إلى المستودع والإعلان:

خط زمني من 23 فبراير إلى 3 أبريل 2024: 23 فبراير ملفات الاختبار الخبيثة تدخل Git، 24 فبراير إصدار 5.6.0، 26 فبراير Debian unstable تضيف 5.6.0، 29 فبراير طلب في systemd رقم 31550 لتحميل مكتبات الضغط عند الحاجة، 4 مارس أخطاء valgrind في توزيعات Red Hat داخل _get_cpuid، 5 مارس دمج طلب systemd، 9 مارس إصدار 5.6.1، 25 مارس طلب في Debian لإدخال 5.6.1، 27 مارس Debian unstable تحدث إلى 5.6.1، 28 مارس بلاغ خاص إلى Debian وقائمة distros وRed Hat تعطي CVE-2024-3094، 29 مارس الإعلان العلني في oss-security ثم تنبيه CISA ونشرة Red Hat، 3 أبريل Debian تفصل sshd عن libsystemd في openssh 1:9.7p1-4، وتحتها أربعة أشرطة: الأسبوع الأول الإصدار ينتشر، أسبوعان أخطاء ظاهرة وإصلاح سريع، الأسبوع الأخير ضغط للتحديث، الاكتشاف
الأسابيع الخمسة من دخول ملفات الاختبار الخبيثة في 23 فبراير 2024 إلى الإعلان في 29 مارس، ثم فصل sshd عن libsystemd في Debian

وفيما يلي ما فعلته كل جهة بحسب إعلاناتها الرسمية:

  • 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.

⚠️
التجربة كلها داخل Containers تبدأ أسماؤها بـ 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 ورسوم توضيحية من عملنا.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.