← All posts

فرضنا MFA. ومعامل استعلام واحد عطّله.

Authagonal·July 14, 2026
securitymfaoidcwar-story

أطلقنا المصادقة متعددة العوامل. بعد كلمة المرور، كانت صفحة تسجيل الدخول تطالب بعامل ثانٍ، كما تتوقع تمامًا. اختبرناها، فعملت، ومضينا قدمًا. ثم، في مراجعة سابقة للإطلاق لخادم المصادقة الخاص بنا، اكتشفنا أنه يمكنك تخطي الأمر كله بتعديل URL.

إليك شكل المشكلة. في تدفق OIDC يرسل تطبيقك المستخدم إلى /connect/authorize. إذا لم يكن قد سجّل الدخول بعد، يعيد الخادم توجيهه إلى صفحة تسجيل الدخول مع وجهته الأصلية مطوية داخل معامل: /login?returnUrl=/connect/authorize?.... تُدخل كلمة مرورك، وتجتاز MFA، ويعيدك تدفق تسجيل الدخول إلى ذلك الـ returnUrl، وعندها يُصدر /connect/authorize رمز تفويض وتتبعه الـ tokens.

كانت الثغرة تسكن في ترتيب العمليات. خطوة كلمة المرور سجّلت دخولك. أي أنها وضعت cookie مُصادَقًا عليه. وكان MFA هو الصفحة التالية في التسلسل. أما /connect/authorize، وهو الـ endpoint الذي يوزّع الـ tokens فعليًا، فكان يطرح سؤالًا واحدًا بالضبط قبل أن يفعل: هل هذا الطلب مُصادَق عليه؟ لم يسأل قط عما إذا كانت الجلسة قد اجتازت عاملًا ثانيًا. كان «مُصادَق عليه» و«مُصادَق عليه مع MFA» هما نفس الـ cookie.

لذا فإن التجاوز ليس بارعًا. تُرسل كلمة مرورك. تستلم الـ cookie المُصادَق عليه. ثم، بدلًا من متابعة التدفق إلى مطالبة MFA، تذهب مباشرة إلى الـ returnUrl بنفسك، /connect/authorize?...، وهو العنوان الذي سلّمك إياه الخادم في البداية تمامًا. يرى authorize كيانًا مُصادَقًا عليه، فيُجري فحصه الوحيد، ويُصدر الرمز. العامل الثاني لا يحدث أبدًا. كانت مطالبة MFA خطوة في ممر، ولم يمنعك شيء من الالتفاف حولها.

سبب نجاة هذا من الاختبار هو أن المسار السعيد محكم تمامًا. انقر عبر الواجهة كما يفعل إنسان وستُطالَب بالعامل الثاني في كل مرة. البوابة حقيقية. لكنها ببساطة في المكان الخطأ. الحد الأمني في خادم OIDC ليس شاشة تسجيل الدخول. إنه /connect/authorize، النقطة التي تتحول عندها الجلسة إلى tokens. أي متطلب يعيش فقط في الصفحات المؤدية إلى ذلك الحد هو متطلب استرشادي، لأن أي شخص يستطيع إجراء الطلب الأساسي يستطيع إجراءه مباشرة.

إعادة الصياغة تلك هي الإصلاح. MFA ليس خطوة تؤديها. إنه خاصية تملكها الجلسة أو لا تملكها. لذا توقفنا عن نمذجته كصفحة وبدأنا نمذجته كـ claim. عندما تجتاز العامل الثاني، يكتب تدفق تسجيل الدخول علامة mfa_authenticated في cookie المصادقة. لم يعد /connect/authorize يقبل «مُصادَق عليه». صار يتطلب «مُصادَق عليه ويحمل تلك العلامة». الـ cookie القائم على كلمة المرور وحدها صار خاملًا عند الـ endpoint الخاص بالـ tokens: يُعاد توجيهه لإكمال MFA أيًا كان الـ URL الذي توجهه إليه، لأن ما ينقصه هو claim، وليس زيارة صفحة.

والاتحاد الفيدرالي ينسجم مع هذا بسلاسة. عندما يسجّل مستخدم دخوله عبر موفر هوية خارجي أجرى MFA الخاص به، فإن ذلك الموفر يضمن العامل الثاني، ولذلك يضع تسجيل الدخول الفيدرالي العلامة نفسها. مستخدمو SSO لا يُطالَبون مرتين بشيء فرضه IdP الخاص بهم بالفعل. العلامة هي مصدر الحقيقة الوحيد، وكل مسار يمكنه استيفاء MFA بشكل مشروع يكتبها.

الدرس القابل للنقل لا علاقة له بـ MFA تحديدًا. إنه هذا: افرض الضابط عند الحد الذي يمنح الشيء الذي تحميه، لا عند خطوة الواجهة المفترض أن تقود إليه. كان الفحص موجودًا لدينا. كتبنا منطق MFA، واختبرناه، وأطلقناه. كان ببساطة مربوطًا بالجزء من النظام الذي لا يضطر المهاجم إلى استخدامه. شاشات الموافقة، وقبول الشروط، والتصعيد للـ scopes الحساسة: نمط الفشل نفسه. إذا لم يتحقق الـ endpoint الذي يُصدر بيانات الاعتماد من الشرط، فالشاشة التي تطلبه مجرد اقتراح.

أمسكنا بهذه الثغرة قبل أن يصطدم بها أي عميل، في التدقيق نفسه الذي كشف عددًا من أشقائها. هذه هي الحجة لمراجعة مصادقتك أنت كما لو كنت تهاجمها، وتحديدًا لأن تسأل، عند كل endpoint يوزّع شيئًا ثمينًا، ليس «هل سار المستخدم في التدفق» بل «ما الذي يثبته هذا الطلب فعلًا». طلبُنا كان يثبت كلمة مرور. وكنا نعامله كإثبات لعاملين.

فرض MFA عند الـ endpoint الذي يسك الـ tokens، لا عند الصفحة التي تطلبه، هو الفرق الكامل بين MFA حقيقي ومجرد اقتراح. Authagonal يفرضه حيث تُصدر بيانات الاعتماد، فلا يصل URL عودة معدّل يدويًا إلى أي مكان.