رمز واحد، معنيان لا صلة بينهما، تجاوز واحد للمصادقة
أرسلتُ كلمة مرور خاطئة أولاً. ثم أرسلتُ كلمة مرور واحدة بطول 300 بايت إلى حساب لم تُحدَّد له كلمة مرور على الإطلاق. سمح لي sshd بالدخول.
نظامان لم يسمع أحدهما عن الآخر قطّ جعلا ذلك ممكناً، إذ اختار كلاهما الرمز نفسه للتعبير عن "لا شيء قابل للاستخدام هنا". يستخدم /etc/shadow منذ عقود نجمة مفردة لتمييز حساب لا ينبغي له قبول كلمة مرور أبداً. ودالة crypt() في musl تُعيد، في بعض حالات الفشل الداخلي، هذا الرمز نفسه بدل القيمة الفارغة (null) التي يفرضها POSIX فعلياً. لا خطأ في أيٍّ من الخيارين بمفرده. لكن ضعهما في مقارنة السلاسل نفسها، ويمكن إقناع sshd بأن كلمة المرور تطابقت فيما لم تجرِ أي مقارنة فعلية قط.
هذه الفقرة الأولى هي أيضاً نقطة البيانات الوحيدة التي لن تسمح هذه المقالة لنفسها بتضخيمها: كلمة مرور واحدة مفرطة الطول، أُرسلت كأول وآخر محاولة في اتصال جديد ضد الحساب نفسه، دون إرسال كلمة مرور خاطئة قبلها، لم تنجح في المصادقة، في نفس التشغيل المعملي. نجحت تتابعية واحدة. والأخرى لم تنجح. هذه نقطة بيانات واحدة من تشغيل واحد، لا زعم بأن كل محاولة أولى بكلمة مرور ضد حساب قابل للاستغلال تنجح. الفرق بين هاتين النتيجتين وسبب حدوثه يُعالَجان بصراحة وأمانة لاحقاً، بما في ذلك الأجزاء التي لا تزال دون حل فعلياً.
تستعرض هذه المقالة الخلفية أولاً: ما الذي تفعله مصادقة SSH فعلياً، وما الذي يخزّنه /etc/shadow، وما هو PAM ولماذا يهم البناء دونه هنا، ولماذا musl ليست glibc، وما الذي يُفترض أن تفعله crypt() عند الفشل. فقط بعد ترسيخ ذلك، يصبح لآلية السبب الجذري والأدلة المعملية والترقيع والأسئلة المفتوحة معنى حقيقي، دون أن يُطلب من القارئ تصديق أي شيء دون دليل.
إن كنتَ تُدير جهاز Linux قائماً على musl بمصادقة كلمة مرور عبر SSH مفعَّلة ودون PAM، فالنسخة المختصرة هي: افحص ملف shadow بحثاً عن نجمة مفردة، وافحص إن كان أي شيء آخر على الجهاز يحمل هاش $5$ أو $6$، ورقِّع. والنسخة الطويلة، بما فيها التفسير الدقيق لسبب نجاح هذا وما أكدته تجربة معملية فعلية وما لم تؤكده، هي بقية هذه المقالة.
دورة استجابة في يوم واحد، والتفصيل في التنبيه الذي تتجاهله معظم التغطيات
صدر OpenSSH 10.6p2 في 5 نوفمبر 2026 ببند أمني واحد، وصدر بسرعة. يذكر إعلان الإصدار الخاص بداميان ميلر أن الإصلاح صدر في اليوم التالي لعلم فريق OpenSSH بالمشكلة، ويُحاجج بأن حالات حديثة لاكتشاف ثغرات مشابهة بشكل مستقل من قِبل وكلاء الذكاء الاصطناعي في غضون ساعات تعني الآن أن حظراً طويلاً على الإفصاح يصبّ في مصلحة المهاجمين أكثر من المُدافعين. هذا هو التبرير المُصرَّح به للجدول الزمني المضغوط، لا افتراضاً تصنعه هذه المقالة بالنيابة عن الفريق. [2]
تصف ملاحظات إصدار OpenSSH أن sshd يتجنّب الآن "تجاوزاً لمصادقة كلمة المرور يؤثر في بعض أنظمة Linux المبنية والمُشغَّلة مع تعطيل دعم PAM" (PAM، أي Pluggable Authentication Modules، هي الطبقة التي تستخدمها معظم أنظمة Linux لتقرير نجاح تسجيل الدخول؛ تتناولها أقسام قليلة قادمة بتفصيل)، وتُنسب البلاغ إلى Ozgun Kultekin من Wakefield. [3] يوجد الشرح التقني المفصَّل للباحث نفسه على sshhole.com، بعنوان "*sshole"، ويذكر بشكل مستقل نفس نافذة 257-1024 بايت ونفس تطابق الرمزين الذي تتعقّبه هذه المقالة بالكامل لاحقاً. [4] اسم مستخدم الموقع على GitHub، oz9un، يتوافق مع الاسم المنسوب إليه الفضل، رغم أن لا شيء في السجل يؤكد هذا الارتباط صراحةً؛ وهذا غير جوهري لأي ادعاء تقني هنا، ويستحق الذكر بوضوح لا كحقيقة مؤكدة.
تنبيه Alpine Linux الخاص هو المصدر الأساسي الثاني الذي تستند إليه هذه المقالة بأكملها. يصف بدقة حالة الحساب القابل للاستغلال: حقل shadow الذي يكون حرفياً "*"، ويشير صراحةً إلى أن الحساب المُقفَل، الذي يكون حقله "!"، غير متأثر. يصوغ الشرط المسبق العام كحاجة إلى حساب محلي آخر واحد على الأقل بهاش كلمة مرور حقيقي على الجهاز نفسه -- تنسيق SHA-256/SHA-512 الافتراضي في Alpine، الموضَّح في الجدول أدناه مباشرة -- ويقول إن حساباً واحداً من هذا النوع يكفي لجعل كل حساب بلا كلمة مرور قابلاً للوصول، ويصف الهجوم نفسه بأنه يتطلب اتصالاً واحداً ولا يحتاج إعادة محاولات، عبارة تعود هذه المقالة إليها مباشرة لاحقاً. [5]
يحمل CVE-2026-123158 نفسه تقييم CVSS 3.1 أساسياً قدره 8.1 (عالٍ)، بمتجه AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H، وتصنيف CWE-394، "Unexpected Status Code or Return Value" (قيمة إرجاع أو رمز حالة غير متوقَّع)، وهو التصنيف الصحيح لخلل يتلخص في جوهره بالثقة بقيمة تُشير إلى خطأ دون التحقق منها. يُقيِّم إغناء CISA الخاص على السجل نفسه نضج الاستغلال بأنه "poc" وإمكانية التشغيل الآلي بـ"لا". لم يُسجَّل أي استغلال في الواقع العملي حتى وقت كتابة هذا المقال. [1]
تُبسِّط معظم التغطية الثانوية لهذا CVE الأمر إلى "كلمة مرور مفرطة الطول تتجاوز SSH". التنبيه نفسه أدقّ من ذلك في ثلاث نقاط تتجاهلها معظم تلك التغطية: يجب أن يكون الحساب في حالة "*" تحديداً، لا "!"؛ ويجب أن يحمل شيء آخر على الجهاز نفسه هاشاً قابلاً للاستخدام مسبقاً؛ ونافذة البايتات هي 257 إلى 1024، لا "طويلة" بشكل عام. تُبنى بقية هذه المقالة حول هذه الثلاث تفاصيل بالضبط، بالإضافة إلى التفصيل الذي يصوغه التنبيه بثقة لكن تجربة معملية عملية لديها سبب لفحصه لا لتصديقه دون دليل.
ما يحدث فعلياً عندما تكتب كلمة مرور SSH
توجد SSH لتسمح لجهاز بإدارة جهاز آخر عبر شبكة لا يثق بها أيّ من الطرفين بالضرورة، بشكل مُشفَّر، ومن الناحية المثالية مع إثبات هوية كلا الطرفين. يتصل العميل بـsshd، الخدمة الخلفية على الخادم؛ ويتفاوض الطرفان أولاً على قناة آمنة، وفقط بعد وجود تلك القناة تحدث المصادقة -- بمفتاح عام، أو بكلمة مرور، أو بطريقة أخرى مُهيَّأة -- قبل أن تُشغَّل أي شِل أو أمر فعلياً. مصادقة كلمة المرور، الطريقة التي يخصّها هذا CVE بأكمله، خطوة واحدة محددة داخل تلك المصافحة الأوسع، لا آلية منفصلة مُلحَقة بجانبها.
في نظام Linux، تعيش معلومات الحساب في /etc/passwd، سطر واحد لكل حساب: اسم المستخدم، رقم هوية المستخدم، المجلد المنزلي، الشِل. كانت كلمة المرور نفسها، أو بدقة أكثر هاشها، تعيش هناك أيضاً قبل عقود. وهي الآن تعيش في ملف منفصل، /etc/shadow، قابل للقراءة فقط من root، لغرض محدد: ألا يُسلِّم اختراق حساب مستخدم عادي أيضاً كل هاشات كلمات المرور على الجهاز.
لكن حقل shadow هذا ليس هاشاً دائماً. يحمل عدداً صغيراً من القيم الخاصة التي يجب على المسؤولين والأدوات وsshd نفسه تفسيرها بشكل صحيح، وهي مُلخَّصة أدناه.
بحسب التصميم، يجب ألا يُمكن مصادقة حساب مُعلَّم بنجمة مفردة عبر كلمة مرور تحت أي إدخال كان، لأن "*" ليست تنسيق هاش على الإطلاق. إنها اتفاقية: هذا الحساب موجود، لكن لن تفتحه أي كلمة مرور أبداً. هذه الاتفاقية هي ما يعتمد عليه المسؤولون فعلياً للحسابات التي تعتمد على المفتاح فقط، وهويات الخدمات، وصور الحاويات الأساسية التي تُشحَن بحساب root لا يتوقع أحد تسجيل الدخول إليه بكلمة مرور. الثغرة بأكملها التي تتعقّبها هذه المقالة هي حالة توقفت فيها هذه الاتفاقية عن الصحة بصمت.
| حقل كلمة المرور | المعنى | مُتأثِّر |
|---|---|---|
$1$... / $2a$/$2b$/$2y$... / $5$... / $6$...تعمل crypt() بشكل طبيعي هنا وتُنتج هاشاً حقيقياً للمقارنة. | هاش حقيقي لكلمة المرور (MD5 أو bcrypt أو SHA-256 أو SHA-512) | لا |
! أو !...ينصّ تنبيه Alpine صراحةً على ذلك. | الحساب مُقفَل | لا |
*ضمن الشروط المحددة تماماً في هذا المقال. | لا توجد كلمة مرور قابلة للاستخدام (حساب يعتمد على المفتاح فقط) | نعم |
(سلسلة فارغة)يُعالَج هذا عبر فرع سابق ومنفصل في sys_auth_passwd()، قبل المسار الذي يخصّ هذا CVE. | لا تُطلَب كلمة مرور | لا |
PAM، ولماذا يُغيّر بناء sshd دونه كل شيء
PAM، أي Pluggable Authentication Modules، هي الطبقة التي تستخدمها معظم توزيعات Linux لتقرير نجاح محاولة تسجيل الدخول فعلياً، بما فيها SSH. تطبيق مثل sshd لا يُنفِّذ عادة فحص كلمة المرور بنفسه؛ بل يسأل PAM، وتُشغِّل PAM أيّ وحدات ضبطها المسؤول لتلك الخدمة، مع pam_unix لكلمات المرور المحلية كالحالة الشائعة، ووحدات أخرى متاحة لخدمات الدليل أو أنظمة المصادقة متعددة العوامل. هذا التفويض الافتراضي معمول به في توزيعات glibc مثل Debian وUbuntu وFedora وRHEL: على هذه الأنظمة، لا يستدعي sshd دالة تجزئة كلمة المرور مباشرة على الإطلاق. [2]
تُترجم Alpine Linux، ومعظم بناءات التضمين القائمة على musl (OpenWrt وBuildroot وYocto)، OpenSSH مع --without-pam بدلاً من ذلك. على هذه الأنظمة، لا توجد طبقة PAM يُسلَّم القرار إليها. يرجع sshd إلى كود مقارنته العام الخاص في auth-passwd.c، مستدعياً crypt(3) مباشرة ضد سجل shadow الخاص بالحساب نفسه. هذا القرار الواحد في وقت البناء هو مفترق الطريق الذي يعيش هذا CVE بالكامل على أحد فرعيه: نظام مفعَّل لـPAM لا يصل إلى المقارنة القابلة للاستغلال على الإطلاق، ليس بسبب أي دفاع محدد يوقفه، بل لأن مسار الكود هذا لا يُنفَّذ قط في ذلك البناء. [2]
تفصيل آخر يستحق المعرفة قبل القسمين التاليين، لأن قسم السبب الجذري يعتمد عليه مباشرة: بالنسبة لاسم مستخدم يعتبره sshd غير صالح، لا يزال المسار غير المفعَّل لـPAM يُجري مقارنة -- مقابل كلمة مرور زائفة مُخزَّنة مؤقتاً -- بحيث يستغرق رفض حساب غير موجود تقريباً نفس وقت رفض كلمة مرور خاطئة لحساب حقيقي. هذا دفاع زمني مقصود وقديم ضد تعداد أسماء المستخدمين، ويشارك نفس كود اختيار الملح الذي يُوجّه حساب "*" إلى الفرع الخاطئ. "كود اختيار الملح" هنا يعني فقط المنطق الذي يُقرّر القيمة المرجعية التي تُقارِن crypt() كلمة المرور بها؛ أما ماهية هذه القيمة المرجعية بالضبط وكيف تُخطئ، فتتابعه أقسام الآلية أدناه.
خطوة أولى مشتركة: يستقبل sshd طلب مصادقة بكلمة مرور، ثم فرعان: بناء مفعَّل لـPAM يُسلّم القرار إلى حزمة PAM ولا يستدعي crypt() مباشرة أبداً، وبناء غير مفعَّل لـPAM يُشغّل كود مقارنته الخاص، ينتهي بمقارنة عبر crypt() مع الهاش المخزَّن، المُعلَّمة بأنها الخطوة الخطِرة الوحيدة.
sshd طلب مصادقة بكلمة مرورsshd دالة crypt() مباشرة أبداً--without-pam: Alpine وبناءات مدمجة قائمة على muslauth-passwd.c مقارنة sshd الخاصةلا توجد طبقة PAM تُسلَّم إليهاcrypt() مع الهاش المخزَّنالمسار الدقيق الذي يعيش فيه هذا CVEmusl وglibc، ولماذا لا تلمس هذه الثغرة Debian
libc، مكتبة C القياسية، هي الطبقة بين البرنامج ونواة نظام التشغيل تحته: تخصيص الذاكرة، معالجة السلاسل النصية، وبالنسبة لهذه القصة، crypt(). يرتبط كل برنامج Linux مكتوب بلغة C بواحدة منها. تستخدم معظم توزيعات سطح المكتب والخادم glibc، مكتبة GNU C. تستخدم Alpine Linux musl بدلاً من ذلك، تنفيذاً أصغر ومكتوباً بشكل مستقل لنفس مجموعة الواجهات القياسية.
ليست musl وglibc قابلتين للتبديل على مستوى التنفيذ في كل ما تعرضانه. فهما تنفّذان الواجهات القياسية نفسها، بما فيها crypt()، لكن ليس دائماً بسلوك داخلي متطابق خلف تلك الواجهة. تظل هذه الفجوة غالباً غير مرئية لكود التطبيق الذي يستدعيها عبرها. وتوقفت عن كونها غير مرئية هنا.
تُشحن Alpine OpenSSH مبنيّاً مقابل musl. يستدعي OpenSSH واجهة crypt(3) القياسية تماماً كما يفعل مع أي libc أخرى. تنفيذ musl الخاص لتلك الواجهة -- وتحديداً مساراته لخوارزميتي SHA-256 وSHA-512 -- هو المكان الذي يعيش فيه فعلياً سلوك الفشل المحدد الذي تتعقّبه الأقسام الثلاثة التالية، لا في كود OpenSSH نفسه ولا في تعبئة Alpine له.
يستحق هذا الدقة، لأنه سهل التضخيم: تحتاج الثغرة musl تحديداً، لا مجرد "مبنيّ دون PAM". يوجد كود مقارنة OpenSSH غير المعتمد على PAM في أي بناء مُترجَم بهذه الطريقة، بما في ذلك، نظرياً، بناء glibc غير قياسي. لكن سلوك الفشل الذي يتصادم معه هذا CVE -- إعادة crypt() رمزاً واحداً بدل NULL -- هو خيار تنفيذ خاص بـmusl. لم يجد هذا التحقيق سلوكاً مُعادلاً في glibc، وإعلان إصدار OpenSSH نفسه صريح بأن الفئة المتأثرة هي Linux، وmusl، ودون PAM، لا غير Linux، ولا glibc، ولا الأنظمة المستخدمة لـPAM. [2]
مكدَّس من خمس طبقات، من الأعلى إلى الأسفل: Alpine Linux كتوزيعة، وsshd من OpenSSH مبنيّ دون PAM، وواجهة مكتبة crypt(3)، وتنفيذ musl libc الخاص لدالة crypt()، وفي الأسفل كود التجزئة الخاص بكل خوارزمية. تُربط كل طبقة بالتي تليها عبر استدعاء أو اتفاقية محددة مذكورة بينهما. لا توجد طبقة مُعلَّمة كخطِرة: لم يتحقق هذا التحقيق بشكل مستقل من سلوك الفشل على مستوى الشيفرة المصدرية لـcrypt_sha256.c/crypt_sha512.c في musl.
sshd من OpenSSH، مبنيّ بخيار --without-pamcrypt(attacker_password, stored_hash)crypt(3)__crypt_r()crypt()$6$, $5$, ...)crypt_sha256.c / crypt_sha512.cما يُفترض أن تفعله crypt() عند الفشل
crypt(3) هي الواجهة التقليدية في يونكس لحساب وفحص ذلك الهاش أحادي الاتجاه -- لا التشفير القابل للعكس -- وهو قيمة shadow المُخزَّنة التي قابلتها قبل قسمين. تحسب crypt(password, salt) الهاش، وتُستخدم هذه الدالة نفسها لإعادة الاستخدام عند التحقق من محاولة تسجيل دخول -- بإعادة حساب الهاش من كلمة المرور المُحاوَلة وهاش الحساب المُخزَّن نفسه المُعاد استخدامه كملح، ثم مقارنة النتيجتين بمقارنة سلاسل نصية عادية. يوجد الملح لئلا ينتهي حسابان يتشاركان كلمة المرور نفسها بالهاش المُخزَّن نفسه أيضاً.
يحدّد POSIX ما يُفترض أن تفعله crypt() عندما يحدث خطأ داخلي أثناء ذلك الحساب: إعادة مؤشر فارغ (null)، وضبط errno لتوضيح السبب. المستدعي الذي يفحص وجود NULL يعرف أن الحساب فشل، ولا يجب أبداً أن يُعامل النتيجة كهاش صالح للمقارنة بأي شيء آخر.
لا تتبع crypt() في musl هذا العقد دائماً. ما تفعله بدلاً من ذلك، وسبب تصادم هذا الانحراف المحدد مع اتفاقيات /etc/shadow الخاصة من القسم السابق، هو الآلية التي تتعقّبها بقية هذه المقالة على مستوى الشيفرة المصدرية، بدءاً من الآن.
لماذا يُوجَّه حساب صالح إلى مسار الملح الاحتياطي
هذا هو أول تفصيل غير واضح، وهو زلّة منطقية بحرف واحد، لا شيء مثير. فحص اختيار الملح الخاص بـOpenSSH في sys_auth_passwd() لا يسأل عن كون الهاش المُخزَّن للحساب هو القيمة النائبة "*". بل يسأل إن كان طول ذلك الهاش حرفين على الأقل.
الشرط الحارس هو `if (authctxt->valid && pw_password[0] && pw_password[1]) salt = pw_password; else salt = NULL;`. بالنسبة لسلسلة الحرف الواحد "*"، فإن pw_password[0] هي النجمة نفسها، وهي صحيحة (truthy)، لكن pw_password[1] هو محرف الإنهاء NUL للسلسلة، وهو زائف (falsy) في C. حرف واحد صحيح. ومُنهٍ واحد زائف. هذا هو الشرط بأكمله، وهو خاطئ بالفعل لهذه الحالة. يُقيَّم بالقيمة false رغم أن authctxt->valid صحيحة، أي حتى لحساب حقيقي وموجود تماماً. يسقط الكود إلى فرع salt = NULL نفسه المستخدَم لاسم مستخدم غير موجود على الإطلاق، لا لأن شيئاً يفحص "*" تحديداً، بل كأثر جانبي لفحص طول يصادف أن يُقرأ كـ"هل هذا طويل بما يكفي ليكون هاشاً بشكل معقول". [6]
عندما يكون salt قيمته NULL، تستدعي xcrypt() دالة pick_salt()، المُعاد إنتاجها بالكامل أدناه مع الحفاظ على إشعار ترخيص BSD الأصلي. تحتفظ pick_salt() بمخزن مؤقت ثابت سعته 32 بايتاً مرتبطاً بعملية واحدة، وتُعيده مباشرة إن كان مُعبّأً مسبقاً. في أول استدعاء لها في العملية، تمر على كل حساب محلي عبر setpwent()/getpwent()، وتنسخ البادئة المحدِّدة للخوارزمية من أول هاش تجده يبدأ بـ"$" ويحتوي على "$" ثانية، أي أي هاش بتنسيق crypt المعياري: MD5 ($1$)، أو bcrypt ($2a$/$2b$/$2y$)، أو SHA-256 ($5$)، أو SHA-512 ($6$) -- لا واحداً محدداً منها. إن لم يحمل أي شيء في النظام هاشاً قابلاً للاستخدام، تعود إلى الملح التقليدي بنمط DES، "xx". [7]
يصوغ تنبيه Alpine الشرط المسبق كحاجة إلى حساب آخر يحمل تحديداً هاشاً $5$ أو $6$. الكود نفسه أوسع من ذلك: يقبل أول هاش بتنسيق crypt المعياري بأي ترتيب تصادف فيه getpwent() تعداد الحسابات، وهذا يشمل أيضاً MD5 وbcrypt. هذه الفجوة بين صياغة التنبيه وسلوك الكود الفعلي هي قراءة هذا التحقيق الخاصة، لا شيئاً يذكره أي من المصدرين صراحةً، لكنها تستحق الإشارة: جهاز يستخدم فقط هاشات MD5 أو bcrypt في مكان آخر سيُلبّي الشرط المسبق الفعلي تماماً، حتى لو كانت صياغة Alpine قد تُلمّح إلى غير ذلك.
حدٌّ آخر، وهو خاص بـOpenSSH نفسه، لا صلة له بـmusl: ترفض auth_password() أي كلمة مرور أطول من MAX_PASSWORD_LEN، المُعرَّفة بـ1024، قبل أن تُصبح sys_auth_passwd()، وبالتالي crypt()، في متناول اليد على الإطلاق. هذا بالضبط سبب توقف نافذة التنبيه عند 1024 بايت لا أن تبقى مفتوحة؛ كلمة مرور أطول لا تقترب أبداً من المقارنة القابلة للاستغلال. [8]
/* auth-passwd.c, sys_auth_passwd() -- pre-fix */
char *pw_password = authctxt->valid ?
shadow_pw(pw) : xstrdup(pw->pw_passwd);
...
if (authctxt->valid && pw_password[0] && pw_password[1])
salt = pw_password;
else
salt = NULL;
encrypted_password = xcrypt(password, salt);
...
ret = strcmp(encrypted_password, pw_password) == 0;
/* openbsd-compat/xcrypt.c -- Copyright (c) 2003 Ben Lindstrom, BSD license */
static const char *
pick_salt(void)
{
struct passwd *pw;
char *passwd = NULL, *p;
size_t typelen;
static char salt[32];
if (salt[0] != '\0')
return salt;
strlcpy(salt, "xx", sizeof(salt));
setpwent();
while ((pw = getpwent()) != NULL) {
if (passwd != NULL)
freezero(passwd, strlen(passwd));
if ((passwd = shadow_pw(pw)) == NULL)
continue;
if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) {
typelen = p - passwd + 1;
if (passwd[1] == '2' && strlen(p + 1) >= 22)
typelen += 1 + 22;
strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt)));
explicit_bzero(passwd, strlen(passwd));
goto out;
}
}
out:
if (passwd != NULL)
freezero(passwd, strlen(passwd));
endpwent();
return salt;
}حيث تلتقي "*" الخاصة بـ/etc/shadow بـ"*" musl الخاصة
في هذه النقطة من الكود، يعتقد sshd شيئاً واحداً محدداً: أن السلسلة التي تُعيدها crypt() لكلمة مرور حقيقية لا يمكن أبداً أن تساوي هاش الحساب النائب الخاص به. يتحكم المهاجم تماماً في الإدخال الواحد الذي يُحطِّم هذا الاعتقاد: طول كلمة مروره.
يصف تنبيه Alpine بوضوح حالة الحساب القابل للاستغلال: حقل كلمة مرور shadow الذي يكون حرفياً "*" -- الاتفاقية الخاصة بحساب دون هاش كلمة مرور قابل للاستخدام، حساب يعتمد على المفتاح فقط. وهو صريح بأن الحساب المُقفَل، الحقل "!"، غير متأثر. [5]
تُعيد crypt() في musl، في مساراتها لتجزئة SHA-256 وSHA-512، السلسلة الحرفية "*" -- ثابت سلسلة نصية مُخزَّن في ذاكرة للقراءة فقط -- بدل NULL، عندما يفشل التحقق الداخلي من الهاش. هذا هو بالضبط انتهاك العقد الذي وصفه القسم السابق، مُفعَّلاً الآن بشرط واحد ملموس: كلمة مرور مُدخَلة مفرطة الطول. لم تُقرأ الفحوصات الدقيقة الخاصة بكل خوارزمية داخل crypt_sha256.c وcrypt_sha512.c بشكل مستقل في هذا التحقيق؛ ما تم توثيقه هنا مقيَّد بتنبيه Alpine، وتدوينة الباحث المُبلِّغ نفسه، ونقاش في قائمة بريدية لـmusl من ديسمبر 2024 يصف سلوك "*" عند الفشل نفسه في سياق مختلف تماماً، قبل نحو عامين من وجود هذا CVE. [5] [4] [9] ملف crypt.c الخاص بـmusl نفسه هو موزِّع رقيق لا يُنفِّذ شيئاً من هذا الفحص بنفسه، كما أكّد الفحص المباشر للملف. [10]
يُفعَّل الفشل الذي يُنتج "*" بكلمة مرور مُدخَلة مفرطة الطول. يذكر تنبيه Alpine وسجل CVE النافذة نفسها: 257 إلى 1024 بايت. الحد الأدنى هو عتبة musl الداخلية الخاصة؛ أما الحد الأعلى، كما ورد قبل قسمين، فهو سقف MAX_PASSWORD_LEN الموجود مسبقاً في OpenSSH، ولا صلة له بـmusl على الإطلاق. [5] [1]
نجمعها معاً: مهاجم يُرسل كلمة مرور في نطاق البايتات ذلك، ضد حساب هاشه المُخزَّن هو "*"، يجعل crypt() تحسب وتُعيد "*". المقارنة التي تتبع ذلك هي `strcmp("*", "*")`، وهي صحيحة بحكم التعريف، لأن الجانبين أصبحا الآن السلسلة الحرفية الواحدة نفسها تماماً. تنجح المصادقة، لحساب مُهيَّأ صراحة ليرفض كلمة المرور دائماً.
لا يبدو أيّ من نصفي هذا خاطئاً بمفرده، وهذا بالضبط ما يجعل تفويته سهلاً. أنظمة الأنواع لا تساعد هنا: كلا جانبي المقارنة من نوع char*، و"*" سلسلة عادية نحوياً، مُعادة بنجاح في كلا جانبي قراءة shadow وcrypt(). لا شيء هنا يبدو كحالة خطأ لمحلل ثابت. وكان PAM سيضبط هذا السلوك عرَضاً، عبر خلل أسوأ: على نظام مفعَّل لـPAM، تُسقِط قيمة الإرجاع نفسها من musl pam_unix بدلاً من ذلك، لأن pam_overwrite_string() تحاول الكتابة في ذلك الثابت النصي المخصَّص للقراءة فقط -- وهذا رفض خدمة لا تجاوز، ومشكلة أُبلغ عنها مسبقاً وبشكل مستقل في ديسمبر 2024. لا تملك الأنظمة غير المعتمدة على PAM استدعاءً وسيطاً مُعادِلاً، فينتج عن سلوك musl الأساسي نفسه نجاح مصادقة صامت بدل الانهيار. [9]
/* musl libc, src/crypt/crypt.c -- MIT license */
char *crypt(const char *key, const char *salt)
{
static char buf[128];
return __crypt_r(key, salt, (struct crypt_data *)buf);
}
/* a thin dispatcher only -- the overlong-key / "*"-on-failure behavior
lives inside crypt_sha256.c / crypt_sha512.c, not here. That specific
file was not independently read in this investigation; see the text. */جانبان، كلٌّ منهما مُعلَّم برمز نجمة: على اليسار، اتفاقية /etc/shadow القائلة بأن النجمة المفردة تعني أن كلمة المرور لن تكون صالحة أبداً؛ وعلى اليمين، إشارة فشل دالة crypt() في musl، التي تُعيد أيضاً نجمة حرفية في بعض حالات الفشل. يتقارب الجانبان عند نقطة مميَّزة نصّها "strcmp لنجمة ونجمة يساوي صفراً"، مع الحكم بأن المصادقة تنجح.
/etc/shadow الخاصةالنجمة المفردة تعني: لن تكون كلمة المرور صالحة أبداً.
crypt() في muslتُعيد الرمز نفسه بدل NULL عند الفشل.
strcmp("*", "*") == 0
تطابق: المصادقة تنجح
ما جرى بناؤه فعلياً، وتثبيته، وتشغيله
كل ما في القسمين التاليين يأتي من تشغيل فعلي واحد، لا تجربة فكرية: صورة Alpine 3.24.4 ثابتة الهاش، مبنية من نسخة مُعدَّلة من مستودع إثبات مفهوم عام، نُفِّذت في 7 نوفمبر 2026 وأُعيد التحقق منها بشكل مستقل مقابل أدلتها المُسجَّلة بالهاش قبل كتابة أيّ شيء مما يلي.
كانت الصورة الأساسية هي alpine:3.24، مُثبَّتة على هاش بيان linux/amd64 الخاص بها لا موثوقة كعلامة متغيّرة. لم يُترَك شيء هنا لنزوة علامة متغيّرة. بُنيت صورة المعمل نفسها عمداً بـOpenSSH 10.6p1، مُترجَمة بخيار --without-pam: النسخة القابلة للاستغلال قبل الإصلاح، لا 10.6p2 المُصلَحة. سُجِّلت إصدارات musl وlibcrypto3 وshadow وzlib مباشرة من بيانات حزمة الصورة المبنية نفسها، لا بالتقريب من فهرس فرع نشط.
ضُبطت إدخالات shadow لـroot وalice وalpine وvictim على "*"؛ وأُعطي bob هاشاً حقيقياً وحتمياً من SHA-512، مبنياً بملح ثابت مُمرَّر عبر ملف لا عبر وسيط سطر أوامر، كضابط اختبار سلبي مقصود. ضبط sshd_config الخيار PasswordAuthentication على yes، وعمداً، خلافاً للقيمة الافتراضية لإثبات المفهوم العام، الخيار PermitRootLogin على no، لأن root لم يكن أبداً جزءاً من نطاق الاختبار المُعتمَد لهذا المعمل.
كان العزل أشد صرامة من إعدادات Docker الافتراضية، لا أضعف: عملت الحاوية على شبكة bridge مُهيَّأة بـ--internal، دون مسار افتراضي للخروج، وPrivileged بقيمة false، وCapDrop بقيمة ALL، مع إعادة إضافة فقط CAP_NET_BIND_SERVICE وCAP_SETGID وCAP_SETUID وCAP_SYS_CHROOT، كلٌّ مُبرَّر مقابل الشيفرة المصدرية الخاصة بفصل الصلاحيات في OpenSSH 10.6p1 لا مُفترَضاً، بالإضافة إلى no-new-privileges. لم تظهر أي كلمة مرور أو سرّ في سطر أوامر أي عملية؛ قُرئت كل بيانات الاختبار من ملف.
كان alice وbob فقط جزءاً من خطة الاختبار المُعتمَدة. ما التزمت به تلك الحدود فعلياً وما لم تلتزم به هو قسم مستقل بذاته، موضوع مباشرة بعد النتائج لا مطويّاً بهدوء فيها.
Base image alpine:3.24, pinned to linux/amd64 digest
sha256:0f49f331d9212aff11b932863be4eb01cdf962969
aa86dab7d1ee21ac6f5f52b (resolves to Alpine 3.24.4)
Lab image sshole-lab:revision3
sha256:b973cd33dfd98a767b93cd5f95987d817f7b8eeda0
6e804e173b431ae6540e45
OpenSSH 10.6p1, built --without-pam (the pre-fix version,
deliberately -- this run tests the vulnerable build)
musl 1.2.6-r3
Capabilities --cap-drop=ALL
--cap-add=NET_BIND_SERVICE --cap-add=SETUID
--cap-add=SETGID --cap-add=SYS_CHROOT
--security-opt=no-new-privileges
Network sshole-lab-net, --internal (no default route out)
sshd_config PasswordAuthentication yes
PermitRootLogin noفشلت الحالة A. نجحت الحالة B. لم يتحرك bob في أيّ اتجاه.
دخلت ثلاث فرضيات في هذا التشغيل، صِيغت صراحةً كأمور يجب اختبارها، لا تأكيدها. كانت كل من الصحة أو الخطأ أو عدم القطعية نتيجة مشروعة وقابلة للتقرير قبل بدء الاختبار.
اختبرت الحالة A محاولة واحدة غير مُمهَّدة بكلمة مرور مفرطة الطول: 300 بايت، أُرسلت كأول وآخر طلب مصادقة بكلمة مرور في اتصال جديد ضد alice، التي حقلها في shadow هو "*". لم تنجح. يُظهر سجل الخادم اتصالاً واحداً، وسطراً واحداً "Failed password for alice"، وإغلاق الاتصال. لا يظهر سطر "Accepted" في أي مكان لهذا الاتصال. هذا مُسجَّل في الملف كـ"لم تُستنسَخ".
اختبرت الحالة B كلمة المرور نفسها، مُمهَّدة: كلمة مرور قصيرة وخاطئة عمداً أُرسلت أولاً، تليها مباشرة كلمة المرور نفسها المفرطة الطول بـ300 بايت، كطلبي مصادقة متتاليين داخل اتصال SSH واحد ضد alice. نجحت. يُظهر سجل الخادم "Failed password for alice ... port 33394 ssh2" تليها مباشرة "Accepted password for alice ... port 33394 ssh2"، كلاهما على المنفذ المصدر نفسه، من البداية إلى النهاية، اتصال واحد. أكّد سجل العميل العلامة المتوقعة لتنفيذ الأمر باسم alice. هذا مُسجَّل كـ"استُنسِخت".
استخدمت الحالتان C1 وC2 bob، ضابط الهاش الحقيقي. نجحت C1، كلمة مرور bob الحقيقية، كفحص سلامة للضابط الإيجابي. وفشلت C2، كلمة المرور نفسها المفرطة الطول بـ300 بايت التي نجحت للتو ضد alice، ضد bob: "Accepted password for bob ... ssh2" لكلمة المرور الصحيحة، و"Failed password for bob ... ssh2" للمفرطة الطول. تؤكد الحالتان معاً أن كلمة المرور المفرطة الطول بمفردها لا تتجاوز المصادقة بشكل عام؛ إنها تتطابق فقط مع القيمة النائبة المتدهورة "*"، تماماً كما تتوقّع آلية السبب الجذري أعلاه.
هذه النتائج الأربع نقطة بيانات واحدة من بناء واحد محدد ثابت الهاش، نُفِّذ مرة واحدة، في 7 نوفمبر 2026. إنها ليست ادعاءً عن كيفية تصرُّف CVE-2026-123158 في كل تركيبة ممكنة من musl وOpenSSH في كل مكان؛ إنها ما فعلته فعلياً هذه الصورة بالضبط، وهذا بناء OpenSSH 10.6p1 بالضبط، وهذه المدخلات بالضبط، في ذلك التشغيل الواحد. يُسجِّلها الملف عمداً بهذا النطاق المحدَّد، وتحافظ هذه المقالة على الصياغة نفسها بدل تعميمها.
Case A -- single-shot, alice, no priming -- not reproduced
Failed password for alice ... ssh2
[connection closes; no Accepted line for this connection]
Case B -- primed (short wrong, then overlong, one connection), alice -- reproduced
Failed password for alice ... port 33394 ssh2
Accepted password for alice ... port 33394 ssh2
Case C1 -- bob, real password, positive control -- reproduced
Accepted password for bob ... ssh2
Case C2 -- bob, same overlong password, negative control -- reproduced
Failed password for bob ... ssh2جدول بأربع حالات اختبار من تشغيل معملي واحد. الحالة A، كلمة مرور مفردة مفرطة الطول دون تمهيد ضد alice، سجّلت كلمة مرور فاشلة وإغلاق الاتصال: لم تُستنسَخ. الحالة B، نفس كلمة المرور مع تمهيد بمحاولة خاطئة قصيرة أولاً، سجّلت كلمة مرور فاشلة تليها مباشرة كلمة مرور مقبولة: استُنسِخت. الحالة C1، كلمة مرور bob الحقيقية، سجّلت كلمة مرور مقبولة كخط أساس. الحالة C2، نفس كلمة المرور المفرطة الطول ضد bob، سجّلت كلمة مرور فاشلة كضابط سلبي. سؤال مفتوح، يُتابَع بشكل منفصل، يشير إلى أن سبب أهمية التمهيد في هذا التشغيل لا يزال دون حل على مستوى الشيفرة المصدرية.
لماذا كان التمهيد مهماً في هذا التشغيل لا يزال دون حل على مستوى الشيفرة المصدرية: تُعيد pick_salt() ملحاً بشكل متزامن عند أي استدعاء، ولا شيء في منطقها الخاص يتطلب بوضوح تمهيداً مسبقاً.
ما لا يُثبته هذا التشغيل، ومحاولات root التي لم يكشف عنها أحد
يُعتدّ بتشغيل معملي بمقدار صراحته في الإقرار بما لم يفحصه. يُقرّ هذا التشغيل بثلاثة أمور محددة بوضوح، لا أن يترك مجموعة نظيفة من النتائج داخل النطاق تحلّ محلّ عملية نظيفة.
الأول هو ملاحظة متعلقة بالنطاق، لا بالآلية. يحتوي ملف الأدلة نفسه الذي سجّل اختبار الحالة A الوحيد ضد alice أيضاً، قبل ذلك في نافذة التسجيل نفسها، على تتابع غير مُفصَح عنه ضد root: سطر واحد "Failed none for root"، ثم سطران متتاليان "Failed password for root ... ssh2" داخل اتصال واحد، ثم إغلاق الاتصال. لا يظهر سطر "Accepted" لـroot في أيّ مكان. القيمة المُرسَلة خلف تلك المحاولتين غير معروفة، ولن يُخمِّنها هذا السجل. لم يكن root أبداً جزءاً من نطاق هذا المعمل المُعتمَد -- alice وbob فقط -- في كل طبقة من تخويل هذه المهمة، ولم يذكره ملخّص مُشغِّل التشغيل الخاص لما جرى اختباره قطّ.
لم يُخترَق شيء: فشلت كلتا المحاولتين ضد root، وتأكّد PermitRootLogin no في التهيئة الفعلية الخاصة بالخدمة لهذا التشغيل. انحراف عن النطاق يفشل على أي حال لا يزال انحرافاً عن النطاق. هذا تحديداً سبب تسجيل حالة المعمل في هذا الملف بأنها "جزئية" لا "مُستنسَخة"، تمييز يستحق الحفاظ عليه حتى حيث تبدو الأدلة ضمن النطاق نظيفة فيما عدا ذلك. أما سبب حدوث هذا التتابع أصلاً -- هل جرى تشغيل قائمة أسماء مستخدمين افتراضية أوسع مُضمَّنة في الأداة بدل أوامر الخطة المخصَّصة المحدودة النطاق، أو شيء آخر مختلف تماماً -- فهو سؤال يُترَك لمن شغَّل المعمل ليُجيب عنه مباشرة، لا للتخمين هنا.
الثغرة الثانية هي قيد في القياس، لا في النطاق. شغَّل اختبار الحالة B حلقة استقصاء متزامنة لمعرِّف العملية خصيصاً لتسجيل دليل مباشر على ما إذا كانت عملية نظام التشغيل نفسها خدمت محاولة التمهيد والمحاولة المفرطة الطول كلتيهما، دليل كان سيتعلّق مباشرة بالسؤال المفتوح في القسم التالي. جمعت عيّنة واحدة قابلة للاستخدام بالضبط خلال التبادل. هذا يتوافق مع استمرارية عملية واحدة عبر المحاولتين، لا مقارنة متواصلة قبل وبعد عبر الفارق بينهما. دليل يُرجِّح. لا يُثبت.
تتعلّق الثغرة الثالثة بمسار شبكة المعمل نفسه، لا بالثغرة نفسها. حدّدت الخطة نشر المعمل على 127.0.0.1:8222؛ يُفيد مُشغِّل التشغيل بأن هذا الربط لم يوفّر اتصالاً فعلياً بالمضيف بعد ربط الحاوية أيضاً بشبكة --internal دون خروج، واستُخدمت بدلاً من ذلك حاوية عميل اختبار ثانية قابلة للتصرُّف على تلك الشبكة الداخلية نفسها، للوصول إلى المعمل باسم DNS الخاص بحاويته. ما زال ناتج فحص الحاوية الخاص يُظهر أن ربط المنفذ المطلوب مقبول في HostConfig.PortBindings، في حين أن NetworkSettings.Ports في الملف نفسه، الذي يُسجّل ما أنشأه Docker فعلياً وقت التشغيل لا ما كان مطلوباً فقط، يُقرأ فارغاً: `{"22/tcp": []}`. هذا يدعم التقرير. ولا يُثبته بشكل قاطع. لم يُحاول أحد في هذا التشغيل فعلياً الاتصال بـ127.0.0.1:8222 من خارج الحاوية للتأكد من أنه لم يكن فعلياً نشطاً.
لا شيء من هذا يُغيّر ما أظهرته الحالات A وB وC1 وC2. إنه يُغيّر مقدار الثقل الذي يمكن لهذا التشغيل الواحد أن يحمله كسابقة للتشغيل التالي، والجواب الصادق هو أقل من ما سيقدّمه تشغيل مُلتزِم بالنطاق بالكامل، خصوصاً في نقطة حساب root.
الإصلاح لا يلمس musl. يُضيف شرطاً واحداً إلى مقارنة واحدة.
الالتزام المُصلِح، 4353fdc43ef646c43a8dda7025cc1a898caf1482، "avoid password authn bypass"، الذي كتبه داميان ميلر في 5 نوفمبر 2026، لا يلمس musl، ولا يلمس pick_salt()، ولا يلمس سلوك كلمة المرور المفرطة الطول الذي يُفعِّل كل هذا. [11]
يُضيف `#define MIN_PASSWORD_HASH_LEN 4` بجانب MAX_PASSWORD_LEN الموجودة مسبقاً في auth-passwd.c، ويُغيِّر قرار المصادقة النهائي ليتطلّب `strlen(pw_password) >= MIN_PASSWORD_HASH_LEN` إضافة إلى فحص strcmp() غير المُعدَّل، حيث pw_password هو هاش الحساب المُخزَّن نفسه، لا الجانب الذي يتأثر بالمهاجم في المقارنة أبداً. [11]
بما أن "*" و"!" كلتاهما حرف واحد تماماً، فإن `strlen(pw_password) >= 4` تكون زائفة لكل حساب كان يمكن لهذا الخلل أن يصل إليه، ويقصر `&&` الدائرة قبل أن تُنفَّذ strcmp() على الإطلاق، بصرف النظر عما تُعيده crypt() لأي إدخال، طويلاً أو قصيراً.
طُبِّق تغيير ملكية الذاكرة نفسه -- نسخة في الكومة عبر xstrdup() وfreezero() بدلاً من مؤشر لا يملكه المستدعي -- عبر auth-passwd.c، وopenbsd-compat/port-uw.c (المسار المُعادِل الخاص بـUnixWare)، وpick_salt() وshadow_pw() في xcrypt.c. لكن فحص الطول الجديد نفسه وصل فقط إلى مسار auth-passwd.c العام، لا إلى مقارنة port-uw.c المنفصلة. UnixWare ليست هدفاً لـmusl، فلا يمكن لهذا التصادم المحدد أن يحدث عبر ذلك المسار، وهو ما قد يكون سبب عدم اعتبار الفحص ضرورياً هناك؛ هذه قراءة معقولة خاصة بهذا التحقيق، لا شيء يذكره الالتزام صراحةً. [11]
تذكر رسالة الالتزام نفسها أن التجاوز "يؤثر في بعض الإعدادات وإعدادات وقت التشغيل"، وأن الفريق "لا يعتبر السبب الجذري داخل OpenSSH" رغم كونه "مُجهَّزاً جيداً لتجنّبه". تتوافق هذه الصياغة مع ما يفعله هذا الإصلاح فعلياً: يمنع تحديداً التصادم الموصوف أعلاه -- إشارة من حرف واحد تتصادم مع قيمة shadow النائبة من حرف واحد -- بوضع حدٍّ أدنى لطول الهاش المُخزَّن لا بالتحقق من أن الهاش المحسوب صحيح البنية لخوارزميته المُعلَنة. فإن أعادت musl، أو أي برنامج متوافق مع musl libc يرتبط بها، في أي وقت سلسلة قصيرة أخرى ليست نائبة على مسار فشل مختلف، بطول أربعة أحرف أو أكثر، فلن يرصد هذا الفحص المحدد ذلك. لم يجد هذا التحقيق مساراً من هذا النوع موجوداً حالياً؛ هذا حدٌّ لما يضمنه الإصلاح، لا خللاً متبقّياً معروفاً. [11]
/* auth-passwd.c, commit 4353fdc -- post-fix */
#define MIN_PASSWORD_HASH_LEN 4
...
ret = encrypted_password != NULL &&
strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
strcmp(encrypted_password, pw_password) == 0;لم تنقل Alpine كل الفروع مباشرة إلى 10.6p2
لم تُنقل Alpine كل الفروع المدعومة إلى OpenSSH 10.6p2. بل أرجعت الإصلاح إلى أيّ إصدار كان كل فرع يُشغِّله فعلاً، وهي ممارسة معيارية للتوزيعات: الحفاظ على إصدار الحزمة الحالي للفرع، وترقيع الخلل المحدد.
يسرد متتبّع أمان Alpine سبعة إصدارات مُصلَحة خاصة بكل فرع، لا رقماً واحداً للأسطول بأكمله. حصل كل فرع مدعوم حالياً على الإرجاع. [12]
إن كنتَ تفحص أسطولاً من الأجهزة، طابق الفرع مع الجدول أدناه بدل البحث عن السلسلة الحرفية "10.6p2" في أي مكان. جهاز على فرع 3.21-main يُشغِّل الإصدار المُرجَع 9.9_p2-r1 مُصلَح، حتى لو لم تطبع `sshd -V` أبداً 10.6.
| الفرع | إصدار الحزمة المُصلَح |
|---|---|
edge-main | 10.6_p2-r0 |
3.24-main | 10.3_p1-r2 |
3.23-main | 10.2_p1-r1 |
3.22-main | 10.0_p1-r11 |
3.21-main | 9.9_p2-r1 |
3.20-main | 9.7_p1-r6 |
3.19-main | 9.6_p1-r3 |
ما يجب فحصه في سجلاتك الآن
يُقدِّم تنبيه Alpine نفسه إرشاد كشف وتخفيف محدَّداً بما يكفي للتحرك الليلة، لا نصيحة عامة بـ"راجع سجلاتك".
منطق الكشف هو: حدِّد كل حساب حقله الخاص بكلمة المرور في shadow هو حرفياً "*"، ثم فتّش سجلات الخدمة الخاصة بحثاً عن تسجيل دخول ناجح بكلمة مرور لتلك الحسابات تحديداً -- السلسلة النصية "Accepted password for" هي العلامة التي يجب البحث عنها. إلى جانب ذلك، راجع استخدام doas، والمستخدمين المُنشَأين حديثاً، والملفات الجديدة، وتغييرات authorized_keys. إن لم يكن استبعاد تسجيل دخول بكلمة مرور ضد حساب بلا كلمة مرور خلال نافذة التعرُّض ممكناً، فموقف التنبيه نفسه هو معاملة الجهاز كمُخترَق. [5]
التخفيف هو إما ترقية الحزمة متبوعة بإعادة تشغيل الخدمة، لأن ترقية الحزمة وحدها لا تستبدل العملية التي تعمل مسبقاً، أو تعطيل مصادقة كلمة المرور بالكامل كحل مؤقت، مع التحقق قبل أن يسري أثر إعادة التشغيل. [5]
فائدة خاصة بـroot تستحق الذكر بدقة: الإعداد الافتراضي لـAlpine، PermitRootLogin prohibit-password، يحمي root فعلاً من هذا التجاوز إلا إذا ضبط المسؤول صراحةً PermitRootLogin yes، حتى مع أن root نفسه يُشحَن كـroot:* في الصورة الأساسية. لكن هذا الإعداد الافتراضي لا يُغطّي كل مسار إلى root: أي حساب آخر قادر على تسجيل الدخول بكلمة مرور ويمكنه تصعيد الصلاحيات، مثلاً عبر doas، لا يزال يُنشئ تعرُّضاً حقيقياً حتى بينما يبقى تسجيل دخول root المباشر بكلمة المرور محمياً. [5]
# Package upgrade, then restart -- upgrading alone does not
# replace the already-running daemon
apk upgrade -U -a
rc-service sshd restart
# Or: disable password authentication entirely
# PasswordAuthentication no (in sshd_config)
sshd -t
rc-service sshd restart
# Detection: look for a successful password login on an account
# that should never have one
grep "Accepted password for" /var/log/messages
# then cross-reference each username against /etc/shadow for a
# password field that is exactly "*"لماذا فشلت كلمة المرور نفسها في المحاولة الأولى ونجحت في الثانية؟
لماذا فشلت كلمة المرور المفرطة الطول نفسها تماماً في المرة الأولى ونجحت في المرة الثانية، ضد الحساب نفسه، في التشغيل نفسه؟
يذكر تنبيه Alpine نفسه أن الهجوم "يتطلب اتصالاً واحداً... ولا يحتاج إعادة محاولات". يستخدم كلا سكريبتي إثبات المفهوم المكتوبين بشكل مستقل والمُراجَعين لهذا الملف -- الواحد المُقدَّم لبناء المعمل، وآخر مستقل يُشار إليه مباشرة في مراجع CNA الخاصة بسجل CVE -- محاولتي مصادقة بكلمة مرور داخل اتصال واحد مع ذلك: كلمة مرور خاطئة قصيرة أولاً، ثم المفرطة الطول، وتُنسِب كلتاهما ذلك في تعليقات كودهما الخاصة إلى تمهيد ملح مُخزَّن مؤقتاً قبل المحاولة الحقيقية. هذا ليس تناقضاً بالضرورة. يستخدم كلاهما مع ذلك وسيلة نقل SSH واحدة، و"دون إعادة محاولات" يصف على الأرجح كون الهجوم حتمياً، لا يحتاج تخميناً احتمالياً مُتكرِّراً، لا أنه يؤكد أن رسالة مصادقة واحدة بالضبط كافية. لكنه أيضاً لا يحسم السؤال في الاتجاه الآخر. [5] [13] [14]
تتبُّع كود pick_salt() نفسه لا يحسم ذلك أيضاً. تمسح الدالة وتُعيد ملحاً قابلاً للاستخدام بشكل متزامن، داخل الاستدعاء نفسه، في أول تنفيذ لها. لا شيء في ذلك المنطق يتطلب بوضوح أن يكون استدعاء سابق قد حدث فعلاً قبل أن يُمكن مطابقة كلمة مرور طويلة مع ملح وُجد حديثاً. بحسب منطق الكود نفسه، ينبغي لكلمة مرور واحدة مفرطة الطول غير مُمهَّدة، مُرسَلة في أول طلب مصادقة لاتصال، أن تصل أيضاً إلى pick_salt()، وتحصل على ملح، وتُشغَّل crypt() ضده، تماماً كما تفعل المحاولة الثانية في كلا إثباتي المفهوم المعروفين.
ما أضافه التشغيل المعملي حقيقي، وهو نقطة بيانات واحدة بالضبط: فشلت المحاولة الواحدة (الحالة A)، ونجحت المُمهَّدة (الحالة B)، في هذا البناء المحدد الواحد. هذا دليل مُؤيَّد بالهاش على أن التمهيد كان مهماً في هذا التشغيل الواحد. إنه ليس تفسيراً لسبب ذلك. يبقى لغز pick_salt() أعلاه دون أن تلمسه هذه النتيجة. "التمهيد ينجح، المحاولة الواحدة لا تنجح" يتوافق مع عدة تفسيرات لم يُؤكِّدها هذا التحقيق بشكل مستقل: تفصيل في دورة حياة عمليات فصل الصلاحيات الخاصة بـOpenSSH نفسها (يُقسِّم sshd عمل مصادقة كل اتصال بين عملية مراقبة ذات صلاحيات وعملية عامل بدون صلاحيات؛ لم يتتبّع هذا التحقيق مدة بقاء كل منهما فعلياً)، وتحديداً أيّ من هاتين العمليتين يُنفِّذ pick_salt() وما إن كانت مدة حياتها تمتد لمحاولة مصادقة واحدة أو للاتصال بأكمله؛ أو سلوك ذو حالة آخر لم يحدده مؤلفو أيّ من إثباتي المفهوم ولا هذا التحقيق؛ أو شيء خاص بهذا البناء والتوقيت بالضبط قد لا يتعمَّم على الإطلاق.
إذن: يبقى هذا السؤال مفتوحاً على مستوى آلية الشيفرة المصدرية بعد هذا التشغيل المعملي، تماماً كما كان قبله. عامِل الانقسام بين "التمهيد ينجح، المحاولة الواحدة تفشل" كنقطة بيانات واحدة تستحق أخذها على محمل الجد، لا كتفسير مُستقِر في أيّ اتجاه. الخطوة المفيدة التالية، إن تابعها أحد، هي تتبُّع نموذج عمليات فصل الصلاحيات الدقيق الخاص بـOpenSSH لهذا البناء المحدد، أو إعادة تشغيل حالة المحاولة الواحدة بتوقيت مختلف لمعرفة ما إن كانت النتيجة تظل صحيحة.
خيطان أصغر لم يُحسَما، مُشار إليهما لا مُخمَّنان: لم يُقرأ المصدر الخاص بـmusl للفحص الدقيق للمفتاح المفرط الطول داخل crypt_sha256.c وcrypt_sha512.c بشكل مستقل أبداً في هذا التحقيق -- وُصف فقط عبر التنبيه وتقرير القائمة البريدية لعام 2024. وما إن كان اسم مستخدم GitHub وراء تدوينة sshhole.com هو الشخص نفسه الذي تنسب إليه ملاحظات إصدار OpenSSH الفضل بالاسم فهو معقول، بناءً على تطابق المحتوى التقني، لكن لا شيء في السجل يؤكده. لا أحدهما جوهريّ لأيّ ادعاء يُطرَح هنا.
الدرس يتعلق بعقود الواجهات، لا بـmusl أو OpenSSH
رقِّع musl عندما تُصدر إصلاحاً. رقِّع OpenSSH، الذي يملك إصلاحاً فعلاً. لا تمثّل أيّ من التعليمتين الدرس الهندسي الحقيقي هنا.
يعد عقد واجهة crypt() بـNULL عند الفشل. لا يحافظ تنفيذ musl الخاص لهذا العقد على هذا الوعد دائماً، والوعد الذي تعتمد عليه اتفاقية "*" في /etc/shadow بشكل منفصل -- أن هذا الرمز المحدد لن يظهر أبداً كنتيجة حقيقية لـcrypt() -- تبيّن أنه يعتمد كلياً على بقاء الوعد الأول. لا نظام فرعي خاطئ بمفرده: اتفاقية "*" في /etc/shadow تسبق musl ولا صلة لها بها، واختيار musl لإشارة الفشل لا صلة له بـOpenSSH أو ملفات shadow. تعيش الثغرة بالكامل في الفجوة بينهما، في افتراض لم يُسجِّله أحد لأنه كان دائماً صحيحاً ببساطة.
CWE-394، التصنيف الذي يحمله هذا CVE فعلياً، هو "Unexpected Status Code or Return Value" (قيمة إرجاع أو رمز حالة غير متوقَّع)، وهذا بالضبط ما هو عليه الأمر هنا. هو أيضاً خلل "يبدو غير ضار" بمعنيين منفصلين في الوقت نفسه: حقل shadow "*" يبدو خاملاً بالتصميم، وكان سلوك "*" عند الفشل في musl قد أُبلغ عنه مسبقاً، بشكل مستقل، في ديسمبر 2024، في سياق مختلف تماماً، مُسقِطاً pam_unix، قبل نحو عامين من ربط أحد إياه بتجاوز في OpenSSH. انتهاك العقد الأساسي نفسه، نتيجتان مختلفتان تماماً، يعتمدان كلياً على مَن المستدعي الذي صادف أن يلمسه.
النسخة العملية لأيّ من يُحافظ على مقارنة كهذه: عقد الواجهة قويّ بقدر استعداد كل مستدعٍ لفحصه، لا بقدر صياغة العقد نفسه. إن قارنتَ قيمة محسوبة بقيمة مُخزَّنة، وكان لدى أيّ من الجانبين إشارة موثَّقة بـ"حدث خطأ ما هنا"، فافحص إن كان من الممكن أن تنتهي هذه الإشارة أبداً عند قيمة الجانب الآخر "لا شيء يستحق الذكر هنا" أيضاً، قبل أن تثق بتلك المقارنة على الإطلاق. هذا ليس فحصاً خاصاً بـmusl أو بـOpenSSH. إنه المتغيّر الثابت الفعلي الذي تبيّن أن هذا CVE بأكمله كان يفتقده.
هذا تحديداً نوع الحدّ الذي صُمِّمت مراجعة الكود الأمنية لإيجاده: لا نقطة الحقن الواضحة، بل المقارنة التي لم يخطر لأحد أن يسألها، لأن كلا الجانبين بدا نظيفاً دائماً بمفرده. إن كان لديك في مكدَّسك الخاص مسار مصادقة أو مقارنة على مستوى C لم يُتتبَّع دالة بدالة بالطريقة التي تُتبِّع بها هذا المسار هنا، فتلك تحديداً الفجوة التي تُوجَد مراجعة الكود على هذا المستوى لإغلاقها.
المصادر
- [1] برنامج CVE / MITRE، CISA ADP: سجلّ CVE-2026-123158
- [2] داميان ميلر: إصدار OpenSSH 10.6p2 (قائمة openssh-unix-dev البريدية)
- [3] مشروع OpenSSH: ملاحظات إصدار 10.6p2
- [4] oz9un: *sshole (تدوينة الباحث المُبلِّغ)
- [5] Alpine Linux: تنبيه تجاوز مصادقة كلمة مرور OpenSSH
- [6] مشروع OpenSSH / OpenBSD: auth-passwd.c
- [7] مشروع OpenSSH: openbsd-compat/xcrypt.c
- [8] مرآة AOSP: auth-passwd.c (MAX_PASSWORD_LEN)
- [9] قائمة musl البريدية (Openwall): crypt() يُعيد "*" من ذاكرة للقراءة فقط، ديسمبر 2024
- [10] مشروع musl libc: src/crypt/crypt.c
- [11] مشروع OpenSSH: الالتزام 4353fdc، "avoid password authn bypass"
- [12] متتبّع أمان Alpine Linux: CVE-2026-123158
- [13] halilkirazkaya: CVE-2026-123158 (إثبات مفهوم / مستودع المعمل)
- [14] predkambrij: sshhole-repro (إثبات مفهوم مستقل)
هل تحتاج إلى هذا الفحص في منتجك؟
مراجعة أمان الشيفرةهل تريد أن تتعلم اكتشاف ثغرات كهذه بنفسك؟
دورة مراجعة الكود الأمني بتدريب فردي