← All posts

توقيع SAML كان صحيحًا. لكن المشكلة لم تكن هناك أصلًا.

Authagonal·July 16, 2026
samlssosecurity

الدخول الموحّد عبر SAML يحمل مشكلة إعادة تشغيل مغروسة في بنيته نفسها، ومعظم مزوّدي الخدمة "يحلّونها" في المكان الوحيد الذي يستطيع المهاجم الوصول إليه.

إليك المشهد. أنت مزوّد الخدمة. يصل مستخدم إلى صفحة تسجيل الدخول لديك، فترسل AuthnRequest إلى مزوّد الهوية الخاص به، ويحمل هذا الطلب ID عشوائيًا. يصادق مزوّد الهوية على المستخدم ويعيد إرسال استجابة SAML تحمل InResponseTo="<that ID>". تبحث عن المعرّف، وتتأكد أنك أنت من أصدره فعلًا، ثم تستهلكه حتى لا يُستخدم مرتين أبدًا. طلبٌ مرتبط باستجابة، واستجابة تُستخدم مرة واحدة، وإعادة التشغيل مهزومة. من قلب الكتاب.

والآن السؤال الذي يهدم كل ذلك: ماذا وقّع مزوّد الهوية فعليًا؟

ليس الاستجابة <Response>، بل التأكيد <Assertion>. فـEntra وOkta ومعظم المزوّدين يوقّعون عنصر <Assertion> ويتركون غلاف <Response> المحيط به دون توقيع. هذا طبيعي، والمواصفة تسمح به. لكن InResponseTo يسكن داخل <Response>. أي أن الحقل الذي يرتكز عليه دفاعك بالكامل ضد إعادة التشغيل هو الحقل الوحيد الذي لا يشمله التوقيع. تستطيع تعديله أو حذفه أو فعل ما تشاء به، ويبقى توقيع التأكيد صحيحًا تمامًا، لأنك لم تمسّ التأكيد أصلًا.

فلننظر ماذا يحدث حين يفعل المهاجم الشيء البديهي.

لـSAML نكهتان. الاستجابات التي يبدأها مزوّد الخدمة تجيب على طلب أرسلته، فتحمل InResponseTo. أما الاستجابات التي يبدأها مزوّد الهوية (غير المطلوبة) فلم يُطلب منها شيء، فليس فيها InResponseTo على الإطلاق. وكودك، بشكل منطقي، يتفرّع بناءً على هذا: إن وُجد InResponseTo، طابِقه مع طلبٍ مخزّن واستهلكه؛ وإن لم يوجد، فلا طلب لمطابقته، فيتجاوز هذا الفحص. هذا التجاوز هو الثغرة كلها.

الهجوم من ثلاث خطوات وبلا أي تشفير:

  1. التقِط استجابة SAML حقيقية واحدة ناجحة. تسجيل دخول حقيقي، وتوقيع حقيقي، وتأكيد حقيقي.
  2. احذف السمة InResponseTo. التوقيع يشمل التأكيد لا الغلاف، فيبقى صحيحًا.
  3. أعِد إرسالها. بلا InResponseTo، يعاملها مزوّد الخدمة لديك على أنها بادئة من مزوّد الهوية، فيسلك المسار الذي لا فحص لإعادة التشغيل فيه، ويتحقق من التوقيع (صحيح)، ويسجّل دخول المستخدم.

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

إليك الفخّ في سطر واحد: لقد وقّعت التأكيد، لكنك بنيت البوابة على الغلاف.

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

وما دمت هناك، فعامِل ربط الطلب كطبقة دفاع في العمق لا كضابط أساسي. لا تزال تطابق InResponseTo حين يكون موجودًا. ولا تزال ترفض الاستجابات غير المطلوبة رفضًا تامًا إن كنت لا تقدّم أصلًا تسجيل دخول يبدأه مزوّد الهوية. ولا تزال تفرض Audience ونافذة Conditions. لكن فحص مكافحة إعادة التشغيل الحامل للثقل يجب أن يرتكز على بايتات موقّعة، ولا نقاش.

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

وقّع الحقل الذي تبني عليه بوابتك، أو ابنِ بوابتك على الحقل الموقّع. لا خيار ثالث ينجو من إعادة تشغيل.

إن كنت تفضّل ألا تصنع التحقق من SAML بنفسك وتخطئ في حالة إعادة التشغيل، فذلك سببٌ وجيه لتترك تشغيله لغيرك. يربط Authagonal حماية إعادة التشغيل بمعرّف التأكيد الموقّع، فحذف InResponseTo لا يوصل إلى شيء.