مقالات معمّقة حول التكنولوجيا التي تشكّل المستقبل.

نجمة واحدة وعشر سنين: ثغرة SSH الصامتة في Alpine Linux

ثغرة SSH في Alpine Linux سمحت لأي كلمة مرور تتجاوز 256 حرفًا بالدخول إلى حسابات مقفلة لعشر سنوات. إليك كيف تسببت خاصية في musl crypt() بذلك.

باب خزنة بفتحة على شكل نجمة يتوهج بالضوء في غرفة خوادم مظلمة
حرف واحد في ملف نظام كان الفرق بين حساب مقفل وباب مفتوح.

إليك رقمًا يجب أن يقلقك: 257. لما يقارب عشر سنوات، كان كتابة أي كلمة مرور من 257 حرفًا أو أكثر في موجه SSH على نوع معين من أنظمة Linux لا ترفضك، بل تدخلك. أي كلمة مرور: ضغطات عشوائية على لوحة المفاتيح، أو نسخة base64 من صور عطلتك، أو نصف رسالة Slack لصقتها بالخطأ. طالما كانت طويلة بما يكفي، يُفتح الباب. ثغرة SSH في Alpine Linux المعروفة باسم «sshole» من الثغرات التي تبدو كمزحة حتى تتحقق من الشروط، وعندها تقرأ كتقرير حادث.

باختصار: في OpenSSH المبني على musl libc، وهذا يشمل Alpine وOpenWrt وBuildroot وYocto وحصة كبيرة مدهشة من صور Docker الأساسية على الإنترنت، كانت الحسابات التي يفترض أن يستحيل الدخول إليها رياضيًا تقبل أي كلمة مرور بين 257 و1024 حرفًا. الحساب root، وحساب النشر المعتمد على المفاتيح فقط، والمستخدم الافتراضي alpine في صور Alpine الرسمية على AWS وAzure، والذي يأتي مع doas بدون كلمة مرور. صدر الإصلاح في OpenSSH 10.6p2. لكن قصة كيف حدث ذلك سلسلة من القرارات الصغيرة والمنطقية تعود إلى عام 1980، وكل حلقة فيها بدت صحيحة وقتها.

كيف تعمل ثغرة SSH في Alpine Linux فعليًا

لفهم هذا التصادم تحتاج إلى ثلاث قطع، كل واحدة تابعة لقاعدة كود مختلفة وعقد مختلف. أولًا: كيف يخزن Linux كلمات المرور. عند تعيين كلمة مرور، يُحفظ الهاش في /etc/shadow، بسطر لكل حساب. يبدو سطر الحساب العادي هكذا $6$salt$hash، حيث تعني $6$ خوارزمية sha512crypt. لكن الحسابات التي يجب ألا تقبل كلمة مرور أبدًا، مثل الخدمات والحسابات المعتمدة على المفاتيح فقط وroot المقفل، تحصل على عنصر نائب بدلًا من ذلك:

alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::

عند تسجيل الدخول، يأخذ sshd ما كتبته، ويجزّئه باستخدام الملح المخزن، ثم يقارن النص الناتج بالحقل المخزن كسلسلة نصية. تطابق السلسلتين يعني أنك دخلت. النجمة حارس: إنها ليست هاشًا لأي شيء، لذا لا توجد كلمة مرور يمكن أن تطابقها. هذه هي آلية القفل للحسابات المعتمدة على المفاتيح منذ أيام Unix الأولى. وهي تعمل لأن المقارنة لا تنجح أبدًا، إلا إذا أمكن جعل دالة الهاش نفسها تنتج نجمة. وهذا يقودنا إلى القطعة الثانية.

القطعة الثانية هي معالجة الأخطاء في musl. تقليديًا، تُرجع crypt() القيمة NULL عندما تعجز عن أداء مهمتها، مثلًا إذا طلب الملح خوارزمية غير متوفرة في النظام. المشكلة أن عقودًا من البرمجيات لم تتحقق أبدًا من NULL، وكانت تمررها بسعادة إلى strcmp فتنهار. لذلك في عام 2012 اتخذت musl قرارًا تصميميًا متعمدًا: لن تُرجع دالة crypt() الخاصة بها NULL أبدًا. عند الفشل ستُرجع سلسلة مضمونة ألا تطابق أي هاش كلمة مرور حقيقية، كي يرى المستدعون الخاطئون كلمة مرور خاطئة بدلًا من الانهيار. وكانت سلسلة الفشل التي اختاروها هي *. بعد ثلاثة أسابيع أضافت musl خوارزمية sha512crypt مع حد لمدخلات بطول 256 بايت، لأن تكلفة الهاش تتناسب مع طول كلمة المرور، فكلمات المرور الضخمة ناقل لهجمات حجب الخدمة. ارفض أي شيء أطول، وهذا فشل، والفشل يُرجع *. والتعليق الذي يوثّق هذا بقي في الكود المصدري طوال الوقت.

القطعة الثالثة هي تهذيب sshd المفرط. في عام 2016، أظهرت CVE-2016-6210 أن بإمكان المهاجم تعداد أسماء المستخدمين الصالحة عبر قياس توقيت الاستجابات: المستخدمون غير الموجودين كانوا يُرفضون فورًا لأنه لا يوجد هاش لحسابه. كان الإصلاح أنيقًا: للحسابات التي لا تملك هاشًا صالحًا، يستعير sshd ملح حساب آخر ويجزّئ كلمة المرور المدخلة على أي حال، فقط ليستهلك الوقت نفسه. «لا» أبطأ. لكن المشكلة أن هذا المسار لا يُفعَّل للمستخدمين غير الموجودين فقط، بل أيضًا للمستخدمين الحقيقيين الذين يحمل حقلهم في shadow القيمة *، لأن النجمة ليست هاشًا قابلًا للاستخدام. لذلك يحسب sshd الآن crypt(your_password, borrowed_salt) ويقارن النتيجة بـ *.

/* what sshd effectively does in this configuration */
const char *stored = shadow_field(user);        /* "*" for locked accounts */
const char *salt   = pick_salt(user);           /* borrowed: "*" isn't a valid hash */
char *ret = crypt(typed_password, salt);        /* musl: "*" if password > 256 bytes */
if (ret && strcmp(ret, stored) == 0)
allow_login();                              /* "*" == "*"  →  welcome in */

يجب أن تتوافق كل العناصر: الحقل المخزن هو *، وcrypt يفشل، وقيمة الفشل هي أيضًا *. على glibc، تُرجع دالة crypt الفاشلة القيمة NULL، وهذا يُفشل المقارنة بأمان. على أنظمة libxcrypt تكون سلسلة الفشل *0، وهو فرق حرف واحد، وهذا الحرف الواحد هو كل شيء، لأن *0 لا تساوي * أبدًا. أما على musl فقيمة الخطأ وقيمة القفل هما الحرف نفسه، وstrcmp لا تستطيع التمييز بين الخطأ والتطابق. معنيان على قناة واحدة، والمستقبِل لا يستطيع التمييز بينهما.

عقد من القرارات المعقولة تمامًا

ما يجعل هذه الثغرة جديرة بالدراسة ليس آليتها، بل أن أحدًا من المعنيين لم يرتكب خطأ واضحًا. ابدأ من عام 1980: حين كانت تجزئات كلمات المرور محفوظة في /etc/passwd المقروء للجميع، أنشأ Bill Joy حسابًا باسم daemon لا يجب أن يدخله أحد، ووضع * مكان هاش كلمة المرور. كانت علامة نظيفة، وسابقة لعصرها بعقود. (ورد أن حقل الاسم الحقيقي كان يقول «الشيطان نفسه».) ولا يزال Unix يقفل الحسابات بهذه الطريقة تمامًا.

قرار musl عام 2012 يبدو مبررًا من كل زاوية أيضًا. المستدعون الذين ينهارون مشكلة حقيقية، ومكتبة C تسلّح NULL ضد عقود من كود التطبيقات الإهمالي تكسب أعداء كثيرين. إرجاع سلسلة لا يمكن أن تطابق شيئًا هو تدهور رشيق للخدمة. وكذلك تحديد طول كلمة المرور لوقف حجب الخدمة القائم على الهاش. وكذلك إصلاح التوقيت في sshd، فحشو الاستجابات للمستخدمين غير القابلين للتجزئة ممارسة قياسية. وحتى ما فعلته Alpine بعد ذلك مبرر. في عام 2019 كشفت CVE-2019-5021 أن صور Docker الخاصة بـ Alpine كانت تُشحن بحقل كلمة مرور root فارغ، وفي بعض الإعدادات لم تكن هناك كلمة مرور أصلًا. قفلت Alpine حساب root بالعلامة !. لكن بدون PAM يعامل sshd العلامة ! على أنها قفل كامل، وهذا يلغي تسجيل الدخول بالمفاتيح أيضًا، وصور السحابة تعيش وتموت بتسجيلات المفاتيح. لذلك في عام 2022 نقلت Alpine 3.16 حساب root إلى *: مقفل ما زال، والمفاتيح تعمل، والجميع راضون. إلا أن * كانت بالضبط السلسلة التي يمكن خداع crypt في musl لإخراجها.

كل خطوة كانت استجابة مبررة لمشكلة حقيقية. وهذا ما يجعل الأمر مزعجًا: كان بإمكانك أن تكون في الغرفة مع كل قرار وتومئ موافقًا.

الحالات التي كادت تقع أسوأ تقريبًا. طلب مطور في Debian ذات مرة من glibc تبني السلوك الدقيق لـ * في musl؛ وبعد ست سنوات انتقلت Debian إلى libxcrypt، الذي أغلق الثغرة بالصدفة عبر *0. وناقش خيط في BusyBox أن passwd يتعثر مع كلمة مرور من 257 حرفًا، نفس النجمة ونفس الحد، ولم يذكر أحد في الخيط كلمة «sshd». وقام مراجع أمني بترقيع دالة pick_salt() في sshd والثغرة الفعلية موجودة على بعد أربعة أسطر منها، قبل أربعة أيام من أن يكتشفها أحد أخيرًا. مرّ الناس بهذه الثغرة مرارًا، وكانوا ينظرون إليها تقريبًا مباشرة.

قطع دومينو متنوعة المواد تتساقط في سلسلة عبر طاولة مظلمة
أربعة عقود من القرارات الصغيرة المعقولة، كل واحد يُسقط الذي يليه.

هل بنيتك التحتية متأثرة بثغرة OpenSSH في musl؟

ما ينقذ ثغرة sshole أنها تحتاج إلى اكتمال مجموعة من الشروط قبل أن تعمل. إن فاتك أي شرط فأنت في أمان. أنت معرّض إذا كانت كل هذه الشروط صحيحة:

  • خادم sshd لديك مبني على musl: Alpine أو OpenWrt أو Buildroot أو Yocto، أو صورة Docker متفرعة من alpine مع تثبيت openssh-server.
  • sshd لا يمر عبر PAM: حزمة openssh-server في Alpine لا تفعل ذلك افتراضيًا، أو قمت بضبط UsePAM no. مسار PAM لا يستدعي crypt أبدًا لحساب *.
  • المصادقة بكلمة المرور مفعّلة. وهي مفعّلة افتراضيًا.
  • يوجد حساب واحد على الأقل يحمل * مجردة في /etc/shadow: مستخدمون يعتمدون على المفاتيح فقط، وحسابات مرتبطة بـ LDAP، وroot في Alpine.
  • يوجد حساب آخر على الأقل له هاش كلمة مرور حقيقي، فيوجد ملح يمكن استعارته.

إذا توفرت الشروط الخمسة، فكل حساب * على الجهاز يقبل أي كلمة مرور من 257 إلى 1024 حرفًا. أسوأ سيناريو هو صور Alpine الرسمية على AWS وAzure: المستخدم الافتراضي alpine يعتمد على المفاتيح فقط (أي *) ويملك doas بلا كلمة مرور. تسجيل الدخول بكلمة مرور طويلة هناك ليس تصعيدًا للصلاحيات، بل باب أمامي مباشر إلى root. وتستحق آلية واحدة الإشادة لأنها أبقت الثغرة هادئة طويلًا: خيار PermitRootLogin في OpenSSH يرفض تسجيل دخول root بكلمة المرور منذ عام 2015، فكان الهدف الأوضح محميًا جزئيًا افتراضيًا. لكن لم يحمِ شيء حسابات النشر والخدمات.

إذا كنت على Debian أو Ubuntu أو Fedora أو أي نظام يعتمد على glibc، فأنت بخير. وأي شيء يمر عبر PAM: بخير. وDropbear: بخير. وOpenBSD: بخير. تحذير للمتشككين: المحاولات الأولى للتحقق من هذه الثغرة من المجتمع كانت غير متسقة، لأن الشروط الخمسة سهلة الفوات واحدًا تلو الآخر. يجرّب أحدهم صورة Alpine الرسمية، ولا يدرك أن حقل root هو ! وليس *، فيفشل ويمضي. اعتبر «لم أستطع إعادة إنتاجها على حاسوبي» دليلًا منعدمًا تمامًا على بيئة الإنتاج. تحقق من الشروط، لا من الإحساس.

السبب الجذري الحقيقي: الإشارة داخل النطاق والقيم السحرية

يغري الأمر بتصنيف sshole ضمن «حالة غريبة في مكتبة libc» والمضي. لكن نمط الفشل أقدم وأعم من ذلك: الإشارة داخل النطاق (in-band signaling). استُخدم الحرف * في وظيفتين: في /etc/shadow يعني «لا توجد كلمة مرور لهذا الحساب»، وفي crypt الخاصة بـ musl يعني «فشلت عملية التجزئة». رسالتان تتشاركان قناة واحدة، ولا توجد طريقة للمستقبِل ليميز بينهما. القيم الحارسة المضمّنة في البيانات العادية هي الطريقة التي تُفجّر بها الأنظمة نفسها. كانت NULL إشارة خطأ مقبولة (رغم أن المستدعين الهشين يعانون منها). والاستثناء أفضل منها. وResult<T, E> الذي يجبر المستدعي على معالجة الفشل صراحةً هو الأفضل. القاعدة العملية، التي أتمنى أن يوشمها كل مبرمج نظم على جسده: لا قيم سحرية من النصوص. وإذا كان لا بد من واحدة فاختر شيئًا لا يمكن أن يتصادم مع أي قيمة في مجال المشكلة. فـ*0 في libxcrypt تتفوق على * في musl بالضبط ببايت واحد، وذلك البايت هو الفرق بين عيب عابر وثغرة CVE.

هناك درس ثانٍ حول طبقات التوافق الصامتة. قرار musl بالنجمة المتسامحة موجود لمنع انهيار البرمجيات المعطوبة، وهذا يعني أنه يمنعها من أن يُلاحظ عطبها أبدًا. الانهيار قبيح، لكنه أيضًا إشارة: يبلّغ أحدهم عن خلل، فيصلح أحدهم المستدعي. أما ابتلاع الخطأ فيجعل الخطأ يتراكم بهدوء لعقد. ومع الوقت صرت مقتنعًا بأن هذا أعمق انقسام في تصميم الأنظمة: الهندسة التي تقاوم الكوارث تميل إلى الفشل بصوت عالٍ ومبكرًا، بينما الهندسة التي تحسّن السلاسة تتراكم فيها ديون خفية حتى تصل الفاتورة كلها دفعة واحدة.

ويجب أن تُسقط هذه القصة خرافة مريحة: أن الكود القديم المنتشر على نطاق واسع أكثر جدارة بالثقة لأنه اختُبر في المعارك. العمر لا يوزّع التدقيق بالتساوي، بل يركّزه على المسارات الساخنة ويترك المسارات الباردة تتحجر. الجميع يدقق في التشفير. وقلّما يدقق أحد في الغراء المملّ الذي يتحدث فيه sshd مع libc بشأن كلمات المرور، لأن ذلك الكود «يعمل منذ الأزل». وتقاطع أقليتين، أنظمة musl، وداخلها الأنظمة التي لا تستخدم PAM، هو بالضبط المكان الذي يمكن أن تعيش فيه ثغرة دون إزعاج لعشر سنوات.

كيفية إصلاح ثغرة sshole وفحص سجلاتك

الترقيع هو الجزء السهل: OpenSSH 10.6p2 يغلق الثغرة. على Alpine يكفي أمر واحد. وإن لم تستطع التحديث اليوم، فالتخفيف فظ بالقدر نفسه وفعّال تمامًا: أوقف مصادقة كلمة المرور. لا فحص لكلمة المرور، لا ثغرة. وبصراحة، ينبغي أن تفعل الأمرين، فالمصادقة بالمفاتيح فقط كانت الإجابة الصحيحة قبل هذه الثغرة وستبقى كذلك بعد أن يرقّع الجميع. وهذا يذكّرنا بدرس رأيناه في نقاشات أخرى بين الترقيع والتخفيف: حين يكون التخفيف رخيصًا وشاملًا، لا تكن ذكيًا أكثر من اللازم.

# patch: openssh 10.6p2 or later
apk upgrade openssh
# can't patch yet? passwords off, keys only
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
rc-service sshd restart
مصباح يضيء مطبوعات سجلات متتالية في الظلام، وسطر واحد يتوهج بالأحمر
في عشر سنوات من سجلات المصادقة، ربما فتحت كلمة مرور طويلة جدًا حسابًا مقفلًا.

ثم السؤال الأصعب: هل وصل إلى ذلك أحد قبلك؟ عشر سنوات مدة كمون طويلة. ما تبحث عنه هو عمليات مصادقة بكلمة المرور ناجحة لحسابات لم تملك كلمات مرور قط: مستخدمو النشر المعتمدون على المفاتيح، وحسابات الخدمات، وهويات LDAP، وroot على أجهزة Alpine. يجب أن تكون عمليات الدخول الناجحة لهذه الحسابات عبر طريقة password نادرة للغاية، وأي حالة منها إنذار بأعلى درجات الخطورة. الحقيقة المزعجة أن sshd يسجل النجاح لا طول كلمة المرور، لذا يتبخر كثير من الدليل المباشر عند الكتابة. وكما تناولنا سابقًا، لسجلات المصادقة السحابية نقاط عمياء حتى في الظروف الجيدة، وسجلات sshd على مستوى النسخ الافتراضية قصيرة العمر هي أكثر مكان تعيش فيه. ابحث فيما لديك، وافترض أنه غير مكتمل.

ماذا يجب أن تعلّمنا عشر سنوات هادئة؟

الاكتشاف نفسه يستحق قدرًا من الاهتمام يوازي الثغرة. الباحث الذي وجد sshole لم ينظر إلى التشفير، بل حدّق في الحد المملّ حيث يتحدث OpenSSH مع libc، وبنى sshd بكل إعداد خطر له (glibc وmusl وPAM وبدون PAM وshadow وبدون shadow)، ثم أطلق نحو 17600 محاولة دخول مشوّهة على المصفوفة كلها. تجزئات غريبة، وحسابات معطوبة، وكلمات مرور طويلة بشكل مفرط. وافق كل بناء على كل محاولة دخول ما عدا واحدًا: musl دون PAM. ذلك البناء قال نعم لأشياء لا ينبغي أن يقولها. هذا ليس اكتشافًا محظوظًا، بل تغطية شاملة. الاختبار التفاضلي عبر مصفوفات الإعدادات هو الطريقة لإيجاد الثغرات المختبئة في التقاطعات، وهو بالضبط نوع العمل الشامل الذي تبرع فيه أسراب الوكلاء الذكية بشكل مقلق. ولن تحصل الثغرة التالية على عشر سنوات هادئة.

السؤال المفتوح الذي ينبغي أن يُبقي المشغّلين مستيقظين ليلًا هو: هل مرت عشر سنوات فعلًا دون أن يلاحظ أحد، أم أن الأشخاص الخطأ لاحظوا؟ تأمل الحالة العرضية وحدها: الصق محتوى الحافظة الخاطئ في حقل كلمة المرور، نصف خيط Slack، أو مفتاح SSH خاص كامل، أي شيء يزيد على 257 حرفًا، وعلى جهاز مصاب يُفتح الحساب المقفل ببساطة. إذا لم تخطئ يومًا في لصق شيء في حقل كلمة مرور، فأنت أكثر حذرًا من أي شخص عملت معه. هناك شخص ما، في مكان ما، دخل جهازًا لم يقصد الدخول إليه، وأغلق الطرفية بهدوء. وربما كثيرون.

إذن رقّع، نعم. أوقف كلمات المرور، بالتأكيد. ثم خذ ساعة وابحث في أنظمتك عن الأحرف التي تؤدي وظيفتين: القيم الحارسة، والبدائل الصامتة، وفروع «هذا لا يمكن أن يحدث»، وطبقات التوافق التي لا يتذكر أحد كتابتها. كل قاعدة كود ناضجة تحتويها. وهي غير مرئية بالذات لأنها لم تسبب مشكلة يمكن لأحد رؤيتها. وفي مكان ما داخل مكدّسك الآن توجد علامة نجمة تعني شيئين في الوقت نفسه بهدوء. ابحث عنها قبل أن يجرّب أحد بكلمة مرور طويلة جدًا.