دقّقنا خادم المصادقة الخاص بنا قبل الإطلاق. وكان للثغرات شكل.
قبل أن نطلب من أحد أن يأتمننا على تسجيلات دخوله، مررنا على خادم المصادقة الخاص بنا كما يفعل مهاجم: لا بتشغيله، بل بقراءته. وما خرج كان مراجعة مكتوبة للسطح كله: ستة عشر قسماً مرقّماً، 261 سطراً، من SAML إلى اتحاد OIDC وSCIM وواجهة الإدارة وMFA وأدوات النسخ الاحتياطي، وكل نتيجة تحمل درجة خطورة وموضعاً في الشيفرة المصدرية. ثمانٍ منها وُسمت بـ «تُصلح أولاً». أصلحنا تلك الثماني في مساء واحد وبقية القائمة بعد نصف ساعة. وبعد أسبوعين ونصف من ذلك أجرينا التمرين نفسه على مستوى التحكم متعدد المستأجرين وخرجنا بخمس أخرى، أُغلقت في المساء السابق لانتقالنا إلى الإنتاج.
وإليك الرقم الذي علق في الذهن. أبلغت مجموعة الاختبارات عن نجاح 367 من 367 قبل ذلك المساء، و367 من 367 بعده. لم يحمرّ شيء، لأن لا شيء في المجموعة كان موجَّهاً إلى أي من ذلك. الاختبارات تثبت ما خطر لك أن تختبره، ولم يخطر لأحد أن يختبر ما إذا كان نزع سمة XML لا يغطيها التوقيع سيوجّه تأكيداً مُعاداً إلى فرع أكثر ودّاً.
إن قُرئت قائمة كهذه بوصفها ثماني ثغرات غير مترابطة، فهي عديمة الفائدة لأي شخص آخر. وإن قُرئت بوصفها أشكالاً، فهي ثلاث عادات، مكرّرة عبر الأنظمة الفرعية، في شيفرة كتبها من قرأوا المواصفات.
الـ client_id، والترويسة المُمرَّرة، وInResponseTo، وادعاء البريد الإلكتروني
العادة الأولى هي معاملة قيمة يكتبها المُنادي بوصفها حقيقة عن المُنادي. والنتيجتان اللتان وسمتهما المراجعة بـ Critical كانت كل منهما مدخلاً واحداً: معامل استعلام، وادعاء بريد إلكتروني.
كانت سياسة MFA تُشتقّ من الـ client_id المستخرَج من الـ returnUrl في نموذج تسجيل الدخول، ولم يكن /connect/authorize يعيد الفحص أبداً. وجّه تسجيل الدخول إلى عميل معطَّل عنده MFA فتخرج الجلسة بلا عامل ثانٍ، ثم تُصرف بلا مشكلة عند العميل الذي كان يشترطه. لتلك مقالتها الخاصة.
الحدود لكل IP كان مفتاحها العنوان البعيد، والترويسات المُمرَّرة كانت موثوقة من 0.0.0.0/0. قيمة X-Forwarded-For واحدة لكل طلب جعلت كل ضابط لكل IP مجرد نصيحة، وأعادت كتابة عمود الـ IP في سجل التدقيق كمكافأة. التفاصيل هنا.
كان مستهلك تأكيدات SAML يختار بين المسار الذي يبدأه SP (استهلاك معرّف الطلب، استخدام واحد) والمسار الذي يبدأه IdP (فحص ذاكرة معرّفات التأكيدات) بناءً فقط على ما إذا كانت الاستجابة تحمل سمة InResponseTo. تلك السمة تجلس على عنصر <Response>، وهو غير مشمول حين يكون التوقيع على التأكيد وحده، وهي الحالة الشائعة. انزعها فتسلك الاستجابة الملتقَطة الفرع الذي لم يملأ تسجيل الدخول الأصلي ذاكرة إعادته قط، مع بقاء كل توقيع صحيحاً. لماذا لم يكن التوقيع هو المشكلة قط.
والمستخدمون الاتحاديون العائدون كانوا يُحلّون بالبريد الإلكتروني الوارد في التأكيد، فأي وصلة كانت تستطيع أن تؤكّد عنوان شخص آخر وتحطّ في حسابه. تلك مكتوبة هي الأخرى.
واجهة الإدارة كانت مصنعاً لبيانات اعتمادها الخاصة
كان تخويل الإدارة يعتمد على شيء واحد بالضبط: رمز يحمل ادعاء scope فيه نطاق الإدارة. ونقطتا نهاية إداريتان كانتا تضعان ذلك النطاق في رمز جديد.
POST /api/v1/token، وهي نقطة نهاية انتحال الهوية التي تستخدمها أدوات الدعم، كانت تقسم معامل الاستعلام scopes وتنسخ النتيجة حرفياً إلى رمز وصول ورمز تحديث، دون أي فحص مقابل AllowedScopes المسجّلة للعميل. لذا كانت بيانات اعتماد إدارية واحدة، مهما قصر عمرها، تسكّ رمز تحديث إدارياً طويل العمر لأي مستخدم وأي عميل: امتياز يعمّر أطول من تدوير بيانات الاعتماد التي كنت قلقاً بشأنها. أما إنشاء العميل فكان يربط كائن عميل خاماً من جسم الطلب مباشرة، بما فيه ClientSecretHashes، فيستطيع مدير أن يسجّل عميل client_credentials يحمل نطاق الإدارة وبصمة سرّ يعرفها سلفاً. والسرد والجلب كانا يعيدان كل بصمة مخزَّنة.
نقطة نهاية انتحال الهوية ترفض الآن نطاق الإدارة وتحدّ الباقي بـ AllowedScopes الخاصة بالعميل، والإنشاء والتحديث يربطان DTO بلا حقل لبصمة السرّ، ولا استجابة تحمل بصمة. الصلاحية التي تستطيع إصدار نفسها ليست صلاحية.
شَرطة، ومجموعة بلا مالك، وبريد إلكتروني يؤدي عمل الهوية
العادة الثانية هي السماح لشيء يحمل اسماً بأن ينوب عن الهوية.
كان البريد الإلكتروني مفتاح الربط لتسجيل الدخول الاتحادي: عالمي، وموجود عند مزوّدين كثيرين، وهو ما تقول الوصلة المؤكِّدة إنه هو. الإصلاح كان الحلّ على زوج المزوّد والموضوع، وهو ما لا تستطيع وصلة أخرى سكّه.
في مستوى التحكم كان الاسم هو الـ slug الخاص بالمستأجر. الموجّه الذي يحوّل slug إلى بادئة جدول تخزين كان ينزع الشَّرطات، فينتج acme-corp وacmecorp البادئة نفسها، وهو ما يعني مجموعة الجداول نفسها ومفتاح التوقيع نفسه في Vault. مستأجران يمكن تسجيل كل منهما على حدة ويتشاركان مجموعة جداول واحدة ومفتاح توقيع واحد. سجّل الثاني وستكون تنظر إلى مستخدمي الأول. الإصلاح لم يكن دالة بادئة أذكى، بل منع الشَّرطات في الـ slugs كي يصير التطابق أحادياً، وهو يستحق النسخة الأطول.
SCIM كانت الأكثر إيلاماً، لأن نصفها كان صحيحاً. كل قراءة وكتابة لمستخدم كانت تعيد فحص أن المورد قد جُهِّز من قِبل العميل المُنادي. أما المجموعات، في الملف المجاور، فلم يكن لها مالك إطلاقاً: الإنشاء لم يكن يختم مالكاً قط، والسرد كان يمرّر null كمرشّح للمالك ويعيد كل مجموعة في البيئة، والجلب والاستبدال والترقيع والحذف كانت تقبل أي معرّف. معرفة معرّف مجموعة كانت هي التخويل بأكمله، وحيث يتشارك عدة عملاء تجهيز بيئةً واحدة فذلك قراءة وكتابة عابرتان للعملاء لعضوية المجموعات، وهي الطريقة التي تُمنح بها الأدوار. المجموعات تحمل الآن العميل المُنشِئ، وكل قراءة وكتابة محصورتان به.
الكوكي وُقِّع قبل أن يجيب الـ webhook
العادة الثالثة هي فعل الشيء الذي لا رجعة فيه قبل تشغيل الفحص.
أوضح مثال ميزة يشتريها العملاء: يسجّل المستأجر webhook يُسأل «هل تسمح بتسجيل الدخول هذا؟» وقت المصادقة، ويمكنه ضبطه ليفرض الجواب. كان الخطّاف يعمل على كل مسار يؤسّس جلسة، على نحو صحيح. غير أنه كان يعمل بعد SignInAsync. فكان الرفض يرمي استثناءً، ويعود الطلب بـ 500، والمتصفح يحمل أصلاً كوكي جلسة موقَّعاً. وأي عميل تجاهل الخطأ كان مسجَّل الدخول، حُجب أم لم يُحجب. الـ webhook الحاجب لتسجيل الدخول لم يكن يحجب.
الإصلاح سطر واحد نُقل، ست مرات: شغّل الخطّاف قبل SignInAsync في كلمة المرور وOIDC وSAML والتحقق من MFA وإعداد MFA، وقبل سكّ الرموز عند نقطة نهاية الرموز. تغيير المكتبة وتغيير مستوى التحكم حطّا بفارق دقيقتين. الرفض الآن 403 نظيف بلا Set-Cookie، واختبار من طرف إلى طرف يؤكّد أن تسجيل دخول مرفوضاً لا يصدر .AspNetCore.Cookies.
الترتيب نفسه ظهر في ثلاثة مواضع أهدأ. رموز التخويل كانت تُقرأ ثم تُحذف في عمليتين، فيمكن لاستبدالين متزامنين لرمز واحد أن يجتازا القراءة كلاهما ويحصلا على رموز كلاهما؛ ومخزن المنح يعرض الآن TryConsumeAsync، وهو حذف شرطي لا يعيد true إلا للمُنادي الذي أزال الصف فعلاً. والاستعادة كانت تُدخل الكائنات قبل التحقق من أي شيء: حقل FileHashes كان موثَّقاً بوصفه يُتحقَّق منه أثناء الاستعادة بينما لم يكن يُملأ ولا يُفحص قط، وهو ما يجعل من نسخة احتياطية معبوث بها بدائية كتابة على الإنتاج. أما webhook الخاص بـ Stripe فكان يتصرف بناءً على الأحداث قبل تسجيل أنه رآها، فتستطيع إعادة التسليم مرة واحدة على الأقل أن تُرجرِج خطة المستأجر وحالته أو تعيد إرسال مطالبات السداد. الثلاثة كلها تفحص أولاً الآن.
التحقق من عنوان URL عند حفظه لا يثبت شيئاً عند طلبه
العملاء يسلّموننا عناوين URL: webhooks المصادقة، وأهداف النسخ الاحتياطي، ونداءات الاحتفاظ العائدة. ورفض عنوان يُحَلّ إلى عنوان خاص في لحظة حفظه هو الحارس البديهي، وهو وحده يكاد لا يساوي شيئاً، لأن DNS ليس وعداً. الاسم الذي يُحَلّ علنياً حين تتحقق منه قد يُحَلّ إلى 169.254.169.254 حين تتصل.
لذا فالفحص الحامل للثقل لا يسكن في معالج الإعدادات. إنه نداء اتصال عائد على معالج HTTP نفسه. كل نداء صادر إلى عنوان URL يقدّمه العميل يحلّ المضيف، ويرفض loopback وRFC1918 والمحلي الفريد ونطاق 169.254.0.0/16 المحلي للوصلة كله الذي تسكن فيه خدمة بيانات السحابة الوصفية، ثم يفتح المقبس إلى ذلك العنوان المتحقَّق منه بعينه بدلاً من اسم المضيف، فلا يبقى حلّ ثانٍ لإعادة الربط. وإعادات التوجيه التلقائية معطّلة، لأن 302 فرصة أخرى لتسمية هدف داخلي. ويبقى فحص وقت الحفظ رسالة خطأ للعميل، وهو كل ما كان صالحاً له يوماً.
نقطتا نهاية وملف نسخ احتياطي لم تحمها إلا الشبكة
/_internal/cluster/gossip كانت مُخطَّطة كمجهولة، وفحص السرّ المشترك الخاص بها كان موجوداً كتعليق لا كشيفرة. و/_internal/backchannel-logout كانت مُخطَّطة كمجهولة مع تعطيل مكافحة التزوير على المستمع العام، وتأخذ جسماً من {"SubjectId": "..."}، وتُبطل كل منحة يحملها ذلك الموضوع. ولم يكن لأي منهما أي حارس داخل العملية. الشيء الوحيد أمامهما كان إعداد الـ ingress، وبالنظر إلى نتيجة الترويسة المُمرَّرة أعلاه، كان أي دفاع قائم على الـ IP قابلاً للانتحال على أي حال. كلتاهما تشترطان الآن سرّاً مشتركاً يُقارَن في زمن ثابت، أو عنوان مصدر داخلي حين لا يكون أي سرّ مضبوطاً.
النسخ الاحتياطي كان له الطابع نفسه. على المضيفات التي تستخدم مصدر المفاتيح المحلي، يحمل جدول مفاتيح التوقيع العددَ الخاصّ لمنحنى EC، وكان النسخ الاحتياطي يُسلسل كل عمود حرفياً إلى JSONL مضغوط بصيغة نصية مكشوفة. وأي شخص يستطيع قراءة ملف نسخ احتياطي كان يستطيع تزوير رموز للمُصدِر الذي جاء منه. ذلك الجدول مستثنى الآن افتراضياً مع اشتراك صريح للأهداف المشفَّرة، وكل ملف بيانات يُبصَم بـ SHA-256 في البيان ويُتحقَّق منه قبل أن تكتب الاستعادة صفاً واحداً.
موضع تشغيل الفحص هو معظم ما يساويه الفحص
لم يكن أي من هذه فحصاً غائباً بمعنى أن أحداً لم يفكّر فيه. MFA كانت منفَّذة ومختبَرة. وخطّاف الـ webhook كان يعمل على كل مسار. والتحقق من SSRF كان موجوداً. ومستخدمو SCIM كانوا محصورين بشكل صحيح في كل فعل، في الملف المجاور للملف الذي لم تكن المجموعات فيه كذلك. في كل حالة تقريباً كان المنطق مكتوباً وصحيحاً ومركَّباً في موضع لا يستطيع فيه أداء عمله: على قيمة قدّمها المُنادي، أو عند خطوة يستطيع المُنادي تخطّيها، أو بعد الحالة التي كان يُفترض به منعها، أو على المورد المجاور.
لذا فالسؤال المفيد عند كل نقطة نهاية ليس «هل نتحقق من هذا». بل ثلاثة أسئلة أخرى. ماذا يثبت هذا الطلب فعلاً، في مقابل ما يدّعيه؟ وهل هذا المعرّف شيء يستطيع أي مُنادٍ تسميته، أم شيء لا يستطيع حمله إلا المُنادي الصحيح؟ وهل يحدث أي شيء لا رجعة فيه قبل وصول الجواب؟ المراجعة المكتوبة، المُشتغَل عليها قسماً قسماً مع درجة خطورة لكل نتيجة، هي في معظمها أداة لدفع تلك الثلاثة عبر الشيفرة التي أنت واثق منها. وشيفرتنا كانت واثقة. كانت ستة عشر قسماً من الثقة، زائد ثمانية أشياء كانت ستصير حادثة أمنية لأحدهم.
لم يكن أي من ذلك في متناول عميل، لأنه لم يكن هناك عملاء بعد. وتلك هي الحجة لفعل هذا قبل الإطلاق لا بعد أول بلاغ: القائمة نفسها، مكتوبة بعد ستة أشهر، تصير جدولاً زمنياً للإفصاح بدل عمل مساء واحد. وإن أردت النسخة الحالية من الأجوبة، فإن صفحة الأمان لدينا تبيّن كيف يُعزَل المستأجرون، وما المشفَّر أثناء السكون، وما الذي تحتويه فعلاً نسخة احتياطية من بياناتك.