الأمن السيبراني الأدلة التقنية

SAML للمبتدئين: متى ما زالت المؤسسات تحتاجه؟

نشرح SAML بتجربة حقيقية نرى فيها الـ Assertion كما يحمله المتصفح، ثم نعدله ونسرقه ونؤخره لنرى كيف يرفضه التطبيق.

SAML للمبتدئين: متى ما زالت المؤسسات تحتاجه؟

تشترك مؤسستك في خدمة سحابية جديدة، وفي صفحة إعداد الدخول الموحد تجد خياراً واحداً هو SAML، ويطلب منك المورد «ملف Metadata الخاص بمزود الهوية»، ويعطيك هو «Entity ID» و«ACS URL». وكما ذكرنا في شرح الدخول الموحد SSO ومصطلحاته فإن الدخول الموحد عبر الويب له لغتان اليوم: OpenID Connect الأحدث، وSAML الأقدم الذي ما زال يحكم جزءاً كبيراً من تطبيقات المؤسسات. وفي هذا المقال سوف نفهم SAML من الداخل، بتجربة حقيقية نرى فيها الرسالة التي يحملها المتصفح كما هي، ثم نعبث بها ونرى كيف يرفضها التطبيق.

وسوف نناقش في هذا المقال ما يلي:

  • لماذا ما زالت المؤسسات تحتاج SAML في 2026، ومتى تختاره ومتى تختار OpenID Connect.
  • المصطلحات التي تظهر في كل شاشة إعداد: Entity ID وMetadata وACS والـ Bindings وNameID.
  • خطوات الدخول بالترتيب، من الطلب AuthnRequest إلى الرد SAMLResponse.
  • تجربة عملية بمزود هوية صغير وتطبيق تجريبي على SimpleSAMLphp في Docker، مع الـ Assertion الحقيقي وتشريحه.
  • أربعة أعطال نجربها عن قصد: رسالة معدلة، ورسالة مسروقة، ورسالة منتهية، ورسالة مكررة.
  • ثغرات SAML المعروفة، وقائمة تحقق قبل أن تربط تطبيقاً حقيقياً.

لماذا ما زالت المؤسسات تحتاج SAML؟

صدر SAML 2.0 من منظمة OASIS في مارس 2005، أي قبل OpenID Connect بتسع سنوات، وخلال هذه السنوات بنت عليه الشركات الكبيرة والجامعات والجهات الحكومية أنظمة الدخول الموحد كلها. لذلك ما زلت تجده في الأماكن التالية:

  • الخدمات السحابية للمؤسسات SaaS: أغلبها يدعم SAML لأنه ما يطلبه قسم تقنية المعلومات في الشركات الكبيرة، وبعضها لا يدعم غيره. وفي توثيق Microsoft Entra ID مثلاً يأتي SAML كأول طريقة لربط تطبيق خارجي بالدخول الموحد.
  • التطبيقات الداخلية القديمة: أنظمة الموارد البشرية والمحاسبة والبوابات التي بنيت قبل 2015 تتكلم SAML فقط، واستبدالها مكلف.
  • الاتحادات Federations بين المؤسسات: شبكات الجامعات البحثية مثلاً تتبادل ملفات Metadata لمئات المؤسسات، ويدخل الباحث بحساب جامعته إلى خدمات جامعة أخرى.

وقد يتساءل البعض: إذا كان OpenID Connect أحدث وأبسط، فلماذا لا نتخلى عن SAML؟ والإجابة أن القرار ليس بيدك غالباً، فالتطبيق هو الذي يحدد ما يدعمه. والقاعدة العملية بسيطة: إذا دعم التطبيق الاثنين فاختر OpenID Connect، والسبب أن مكتباته أبسط وأقل عرضة لأخطاء التحقق من XML كما سيأتي، وإذا لم يدعم إلا SAML فاستخدمه، فكل مزودي الهوية مفتوحي المصدر المعروفين مثل authentik وKeycloak يعملون كمزود هوية SAML.

SAML 2.0OpenID Connect
صيغة الرسالةXML موقع بمعيار XML SignatureJSON داخل JWT موقع
طريقة الوصولالمتصفح يحمل الرسالة كاملة في نموذج POSTالمتصفح يحمل كوداً قصيراً، والتوكنات تطلب من الخلف
تطبيقات الجوال وواجهات APIغير مناسبمصمم لها
حجم الرسالة في تجربتنا10877 بايت base64توكن JWT ببضع مئات من البايتات
أين تجدهتطبيقات المؤسسات والاتحادات الجامعيةالتطبيقات الحديثة وأغلب الأدوات مفتوحة المصدر

المصطلحات التي سوف تجدها في كل شاشة إعداد

معيار SAML نفسه عدة وثائق، أهمها SAML Core الذي يعرف الرسائل، وSAML Bindings الذي يعرف طريقة نقلها، وSAML Profiles الذي يعرف كيف تستخدم في الدخول عبر المتصفح. ومن هذه الوثائق تأتي المصطلحات التالية:

  • المعرف Entity ID: اسم فريد لكل طرف، يكتب غالباً على شكل رابط مثل https://wiki.example.com، ولكنه مجرد اسم ولا يشترط أن يفتح في المتصفح. والتطبيق يعرف نفسه به في الطلب، والمزود يضعه في الرد كجمهور Audience.
  • ملف التعريف Metadata: ملف XML يصف الطرف: معرفه، وعناوينه، وشهادة التوقيع Signing Certificate. وتبادل الملفين هو كل ما تحتاجه الثقة بين الطرفين.
  • عنوان استقبال الرد ACS: اختصار Assertion Consumer Service، وهو العنوان في التطبيق الذي يستقبل الرد من المتصفح، والمزود يرسل إليه فقط العنوان المسجل عنده.
  • عنوان الدخول SSO Service: العنوان في المزود الذي يستقبل طلب الدخول.
  • طرق النقل Bindings: كيف تحمل الرسالة: HTTP-Redirect يضعها مضغوطة داخل الرابط، وهو مناسب للطلب الصغير، وHTTP-POST يضعها في نموذج HTML يرسل نفسه، وهو المستخدم للرد الكبير، وHTTP-Artifact يرسل مرجعاً قصيراً ويجلب الطرف الآخر الرسالة من الخلف، وهو نادر.
  • الطلب AuthnRequest والرد Response: رسالة التطبيق التي يطلب بها الدخول، ورسالة المزود التي تحمل الـ Assertion.
  • الـ Assertion: الجزء الموقع الذي يقول من المستخدم ومتى تحقق منه وما صفاته، وهو قلب SAML كله.
  • المعرف NameID وصيغته Format: هوية المستخدم داخل الـ Assertion، وقد تكون البريد emailAddress، أو معرفاً ثابتاً لا يتغير persistent، أو معرفاً عشوائياً لكل دخول transient.
  • الصفات Attributes: بقية المعلومات مثل الاسم والبريد والمجموعات، ويتفق الطرفان على أسمائها.
  • RelayState: قيمة يرسلها التطبيق مع الطلب ويعيدها المزود كما هي، ويستخدمها التطبيق ليعيد المستخدم إلى الصفحة التي طلبها أولاً.

خطوات الدخول بالترتيب

في الطريقة المعتادة يبدأ التطبيق الدخول SP-initiated، والصورة التالية تبين الخطوات كما حدثت في تجربتنا، ولاحظ أن التطبيق والمزود لا يتصلان ببعضهما مباشرة أبداً في هذا التدفق، فالمتصفح هو الذي يحمل الرسالتين:

مخطط تسلسلي بثلاثة أعمدة: التطبيق SP والمتصفح ومزود الهوية IdP. تفتح سارة الصفحة، فيرد التطبيق بالتحويل 303 ومعه AuthnRequest داخل الرابط بطريقة HTTP-Redirect، فيطلب المتصفح SingleSignOnService من المزود، وتدخل سارة كلمة المرور، فيرد المزود بنموذج HTML يرسل SAMLResponse وRelayState، فيرسله المتصفح بطريقة HTTP-POST إلى ACS، ثم يرد التطبيق بالتحويل 303 ومعه جلسته
الطلب صغير فيمر داخل الرابط، والرد كبير فيمر في نموذج POST

تجربة عملية: مزود هوية وتطبيق على SimpleSAMLphp

سوف نستخدم SimpleSAMLphp 2.5.3.1 لأنه يعمل كمزود هوية وكتطبيق في نفس الوقت، وهو مشروع قديم ونشط بترخيص LGPL-2.1، وصدر منه في أغسطس 2026 الإصداران 2.5.3.1 و2.4.10. وسوف نشغل منه نسختين في حاويتين منفصلتين: مزود هوية على المنفذ 18803 باسم https://idp.example.com، وتطبيقاً تجريبياً على المنفذ 18804 باسم https://wiki.example.com، وكل منهما احتاج في تجربتنا إلى قرابة 55 ميجابايت من الذاكرة. ولاحظ أن هذا الإعداد للتعلم فقط، فالتجربة تعمل عبر HTTP، وفي الإنتاج يجب أن يعمل الطرفان عبر HTTPS.

الخطوة 1: الـ Image والمفاتيح

نحمل النسخة الكاملة من صفحة الإصدار، والتي تأتي ومعها مكتبات PHP، ونفكها في مجلد dl، ثم نبني Image فوق Image PHP الرسمية مع Apache بهذا الملف Dockerfile:

FROM php:8.4.27-apache-trixie
COPY dl/simplesamlphp-2.5.3.1 /var/simplesamlphp
RUN printf '%s\n' 'SetEnv SIMPLESAMLPHP_CONFIG_DIR /var/simplesamlphp/config' 'Alias /simplesaml /var/simplesamlphp/public' '<Directory /var/simplesamlphp/public>' '  Require all granted' '</Directory>' > /etc/apache2/conf-enabled/simplesamlphp.conf \
 && mkdir -p /var/cache/simplesamlphp && chown -R www-data /var/simplesamlphp /var/cache/simplesamlphp

ثم نولد زوجاً من المفاتيح لكل طرف، فمفتاح المزود يوقع به الردود، ومفتاح التطبيق يوقع به طلباته إذا أردنا ذلك:

curl -sLO https://github.com/simplesamlphp/simplesamlphp/releases/download/v2.5.3.1/simplesamlphp-2.5.3.1-full.tar.gz
mkdir -p dl idp/{config,metadata,cert} sp/{config,metadata,cert,app}
tar xzf simplesamlphp-2.5.3.1-full.tar.gz -C dl
openssl req -x509 -newkey rsa:3072 -nodes -days 365 -subj "/CN=idp.example.com" -keyout idp/cert/server.pem -out idp/cert/server.crt
openssl req -x509 -newkey rsa:3072 -nodes -days 365 -subj "/CN=sp.example.com" -keyout sp/cert/server.pem -out sp/cert/server.crt
cp idp/cert/server.crt sp/cert/idp.crt
docker build -t saml-lab:2.5.3.1 .

لاحظ السطر الأخير قبل البناء: التطبيق يحصل على شهادة المزود العامة فقط idp.crt، ولا يرى مفتاحه الخاص أبداً، وهذه الشهادة هي ما سيتحقق به من كل رد.

الخطوة 2: إعداد الطرفين

نسخنا ملف الإعداد الافتراضي config.php.dist من المشروع إلى base.php في مجلد الإعداد لكل طرف، ثم كتبنا config.php صغيراً يغير ما نحتاجه فقط. وهذا ملف المزود، والفرق في ملف التطبيق هو المنفذ 18804 وأسماء ملفات تعريف الارتباط بالبادئة SP بدلاً من IDP، والقيمة false في السطرين الأخيرين:

<?php
require __DIR__ . '/base.php';
$config['baseurlpath'] = 'http://localhost:18803/simplesaml/';
$config['secretsalt'] = 'output of: openssl rand -hex 24';
$config['auth.adminpassword'] = 'output of: openssl rand -hex 12';
$config['technicalcontact_email'] = '[email protected]';
$config['timezone'] = 'UTC';
$config['logging.handler'] = 'errorlog';
$config['logging.level'] = SimpleSAML\Logger::INFO;
$config['session.cookie.name'] = 'IDPSESSID';
$config['session.phpsession.cookiename'] = 'IDPPHPSESS';
$config['session.authtoken.cookiename'] = 'IDPAuthToken';
$config['enable.saml20-idp'] = true;
$config['module.enable']['exampleauth'] = true;

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

بعد ذلك نعرف في المزود مصدر المستخدمين، وهو هنا قائمة ثابتة فيها سارة وصفاتها، وفي الواقع يكون LDAP أو قاعدة بيانات، في الملف idp/config/authsources.php:

<?php
$config = [
    'admin' => ['core:AdminPassword'],
    'example-userpass' => [
        'exampleauth:UserPass',
        'users' => [
            'sara:Sara-Lab-2026' => [
                'uid' => ['sara'],
                'mail' => ['[email protected]'],
                'displayName' => ['Sara Ali'],
                'groups' => ['finance', 'wiki-editors'],
            ],
        ],
    ],
];

ثم نعرف المزود نفسه في idp/metadata/saml20-idp-hosted.php: معرفه ومفتاحه، ومن أين يتحقق من المستخدمين، وأن NameID هو البريد:

<?php
$metadata['https://idp.example.com'] = [
    'host' => '__DEFAULT__',
    'privatekey' => 'server.pem',
    'certificate' => 'server.crt',
    'auth' => 'example-userpass',
    'authproc' => [
        10 => [
            'class' => 'saml:AttributeNameID',
            'identifyingAttribute' => 'mail',
            'Format' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
        ],
    ],
];

ونسجل التطبيق عند المزود في idp/metadata/saml20-sp-remote.php، وهذا ما تفعله في أي مزود هوية عندما تضيف تطبيق SAML:

<?php
$metadata['https://wiki.example.com'] = [
    'AssertionConsumerService' => [[
        'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
        'Location' => 'http://localhost:18804/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp',
    ]],
    'SingleLogoutService' => [[
        'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
        'Location' => 'http://localhost:18804/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp',
    ]],
    'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
];

ومن جهة التطبيق نعرفه في sp/config/authsources.php مع معرف المزود الذي يثق به:

<?php
$config = [
    'admin' => ['core:AdminPassword'],
    'default-sp' => [
        'saml:SP',
        'entityID' => 'https://wiki.example.com',
        'idp' => 'https://idp.example.com',
        'privatekey' => 'server.pem',
        'certificate' => 'server.crt',
        'NameIDPolicy' => ['Format' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', 'AllowCreate' => true],
    ],
];

ونسجل المزود عند التطبيق في sp/metadata/saml20-idp-remote.php، ولاحظ الشهادة idp.crt:

<?php
$metadata['https://idp.example.com'] = [
    'SingleSignOnService' => [[
        'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
        'Location' => 'http://localhost:18803/simplesaml/module.php/saml/idp/singleSignOnService',
    ]],
    'SingleLogoutService' => [[
        'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
        'Location' => 'http://localhost:18803/simplesaml/module.php/saml/idp/singleLogout',
    ]],
    'certificate' => 'idp.crt',
];

وأخيراً صفحة التطبيق نفسها sp/app/index.php، وهي تطلب مستخدماً مسجلاً وتطبع ما وصلها عنه:

<?php
// The "wiki": a page that needs a logged-in user. SimpleSAMLphp's SP library does the SAML work.
require_once '/var/simplesamlphp/src/_autoload.php';
$as = new \SimpleSAML\Auth\Simple('default-sp');
$as->requireAuth();
header('Content-Type: text/plain; charset=utf-8');
echo "Hello " . $as->getAttributes()['displayName'][0] . "\n";
echo "NameID: " . $as->getAuthData('saml:sp:NameID')->getValue() . "\n";
foreach ($as->getAttributes() as $k => $v) echo "  $k = " . implode(', ', $v) . "\n";

في الإعداد أعلاه لاحظ التالي:

  • عنوان ACS في saml20-sp-remote.php هو العنوان الوحيد الذي سيقبل المزود أن يرسل إليه الرد، فإذا جاءه طلب يطلب عنواناً آخر رفضه.
  • التطبيق لا يحتاج إلى أي شيء عن المستخدمين، فكل ما يعرفه هو معرف المزود وعنوانه وشهادته.
  • في الإنتاج لا تكتب هذه الملفات بيدك عادة، وإنما تعطي كل طرف رابط Metadata الطرف الآخر، والمزود في تجربتنا ينشر ملفه على /simplesaml/module.php/saml/idp/metadata.

الخطوة 3: التشغيل

docker network create saml-lab
docker run -d --name saml-idp --network saml-lab -p 18803:80 \
  -v "$PWD/idp/config:/var/simplesamlphp/config" -v "$PWD/idp/metadata:/var/simplesamlphp/metadata" -v "$PWD/idp/cert:/var/simplesamlphp/cert" \
  saml-lab:2.5.3.1
docker run -d --name saml-sp --network saml-lab -p 18804:80 \
  -v "$PWD/sp/config:/var/simplesamlphp/config" -v "$PWD/sp/metadata:/var/simplesamlphp/metadata" -v "$PWD/sp/cert:/var/simplesamlphp/cert" \
  -v "$PWD/sp/app/index.php:/var/www/html/index.php" \
  saml-lab:2.5.3.1

إذا فتحت http://localhost:18804/ في المتصفح فسوف يحولك التطبيق إلى صفحة دخول المزود:

صفحة دخول SimpleSAMLphp بعنوان Enter your username and password وفيها حقلا Username وPassword وزر Login
صفحة الدخول في المزود على المنفذ 18803، وكلمة المرور تكتب هنا وليس في التطبيق
⚠️
إذا ظهرت صفحة خطأ وفي السجل Unable to create the "cache" directory (/var/cache/simplesamlphp/saml) فالسبب أن المجلد غير موجود أو لا يملكه مستخدم Apache، وهو سبب السطر mkdir -p /var/cache/simplesamlphp في الـ Dockerfile. وإذا ظهر Endpoint of type AssertionConsumerService is not an array فالإصدار 2 يطلب أن يكون ACS مصفوفة فيها Binding وLocation وليس نصاً.

الخطوة 4: نمشي الدخول بأنفسنا مع curl

حتى نرى الرسائل نفسها نستخدم curl مكان المتصفح، مع ملف يحفظ ملفات تعريف الارتباط بين الطلبات. ونحتاج أولاً إلى سكربت صغير يفك رسائل SAML، وهو decode.py:

# decode.py: print a SAML message readable. Redirect binding = deflate+base64+urlencode; POST binding = base64 only.
import sys, base64, zlib, urllib.parse, xml.dom.minidom
v = urllib.parse.unquote(sys.argv[1]) if '%' in sys.argv[1] else sys.argv[1]
raw = base64.b64decode(v)
try: raw = zlib.decompress(raw, -15)
except zlib.error: pass
print(xml.dom.minidom.parseString(raw).toprettyxml(indent='  ').split('\n', 1)[1].strip())

ثم نكتب الخطوات في flow.sh: نفتح التطبيق، ونتبع التحويل إلى المزود، ونرسل كلمة المرور، ونحفظ الرد:

#!/usr/bin/env bash
# One SP-initiated SAML login with curl as the browser. Prints each hop, saves request.xml and response.xml.
SP=http://localhost:18804; J=${JAR:-jar.txt}; rm -f $J
echo "== 1. open the wiki"
curl -s -c $J -b $J -D r1.txt -o /dev/null $SP/; grep -iE '^(HTTP|location)' r1.txt | cut -c1-120
LOC=$(grep -i '^location' r1.txt | cut -d' ' -f2 | tr -d '\r')
python -I decode.py "$(echo "$LOC" | sed 's/.*SAMLRequest=\([^&]*\).*/\1/')" > request.xml
echo "== 2. the IdP asks for a password"
curl -s -c $J -b $J -L -D r2.txt -o login.html "$LOC"; grep -iE '^(HTTP|location)' r2.txt | cut -c1-120
ACT=$(grep -o 'id="f" class="[^"]*" action="[^"]*"' login.html | sed 's/.*action="//;s/"$//;s/&amp;/\&/g')
echo "== 3. sara logs in, the IdP answers with an auto-post form"
curl -s -c $J -b $J -L -o resp.html -w 'HTTP %{http_code}\n' --data-urlencode username=sara --data-urlencode password=Sara-Lab-2026 "$ACT"
grep -o '<form[^>]*>' resp.html
grep -o 'name="SAMLResponse" value="[^"]*"' resp.html | sed 's/.*value="//;s/"$//' > samlresponse.b64
grep -o 'name="RelayState" value="[^"]*"' resp.html | sed 's/.*value="//;s/"$//' > relaystate.txt
echo "SAMLResponse: $(wc -c < samlresponse.b64) bytes of base64"
python -I decode.py "$(cat samlresponse.b64)" > response.xml

والخطوة الأخيرة هي ما يفعله نموذج HTML في المتصفح تلقائياً، أي إرسال الرد إلى ACS، ونضعها في post.sh لأننا سوف نستخدمها في تجارب الأعطال أيضاً:

#!/usr/bin/env bash
# post.sh <file-with-base64-response>: what the browser's auto-post form does
J=${JAR:-jar.txt}
curl -s -c $J -b $J -D p.txt -o p.html --data-urlencode "SAMLResponse=$(cat $1)" --data-urlencode "RelayState=$(cat relaystate.txt | sed 's/&amp;/\&/g')" http://localhost:18804/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp
grep -iE '^(HTTP|location)' p.txt | cut -c1-120

ونشغل الخطوات ثم نفتح الصفحة:

bash flow.sh
bash post.sh samlresponse.b64
curl -s -c jar.txt -b jar.txt http://localhost:18804/

والمخرج سوف يكون كما يلي:

== 1. open the wiki
HTTP/1.1 303 See Other
Location: http://localhost:18803/simplesaml/module.php/saml/idp/singleSignOnService?SAMLRequest=fVLJbgIxDP2VUe6zspSJAImC
== 2. the IdP asks for a password
HTTP/1.1 303 See Other
Location: http://localhost:18803/simplesaml/module.php/core/loginuserpass?AuthState=_7ceaa9974269d290f845308d180188f70bd
HTTP/1.1 200 OK
== 3. sara logs in, the IdP answers with an auto-post form
HTTP 200
<form method="post" action="http://localhost:18804/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp">
SAMLResponse: 10877 bytes of base64
HTTP/1.1 303 See Other
Location: http://localhost:18804/
Hello Sara Ali
NameID: [email protected]
  uid = sara
  mail = [email protected]
  displayName = Sara Ali
  groups = finance, wiki-editors

وهذا هو الطلب الذي حمله الرابط في الخطوة الأولى بعد فكه (cat request.xml):

<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_ca6fca80d8036e1c039dcee05e99ce3bb7b925f12b" Version="2.0" IssueInstant="2026-10-25T19:36:14Z" Destination="http://localhost:18803/simplesaml/module.php/saml/idp/singleSignOnService" AssertionConsumerServiceURL="http://localhost:18804/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
  <saml:Issuer>https://wiki.example.com</saml:Issuer>
  <samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
</samlp:AuthnRequest>

في الطلب أعلاه لاحظ التالي:

  • ID معرف عشوائي يحفظه التطبيق في جلسة المتصفح، وسوف يعود في الرد باسم InResponseTo.
  • AssertionConsumerServiceURL هو المكان الذي يريد التطبيق أن يصله الرد، والمزود يقارنه بما سجلناه عنده.
  • الطلب غير موقع في تجربتنا، وهذا مقبول لأن المزود لا يرسل الرد إلا إلى ACS المسجل، ولكن بعض المزودين يطلبون توقيع الطلب أيضاً.

الـ Assertion الحقيقي، وماذا يفحص التطبيق فيه

والآن أهم ما في المقال: الرد الذي حمله المتصفح في الخطوة الثالثة، كما فكه decode.py، بعد أن اختصرنا قيم التوقيع والشهادات فقط لأنها مئات الأحرف:

<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_3b42faa8727002116dba4db606d0227477de9fce5d" Version="2.0" IssueInstant="2026-10-25T19:36:16Z" Destination="http://localhost:18804/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp" InResponseTo="_ca6fca80d8036e1c039dcee05e99ce3bb7b925f12b">
  <saml:Issuer>https://idp.example.com</saml:Issuer>
  <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
    <ds:SignedInfo>
      <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
      <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
      <ds:Reference URI="#_3b42faa8727002116dba4db606d0227477de9fce5d">
        <ds:Transforms>
          <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
          <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
        </ds:Transforms>
        <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
        <ds:DigestValue>Pe0pvOqbZ62Wa6wNtotNOHb0pdHDkmPg2/KPJufgGaI=</ds:DigestValue>
      </ds:Reference>
    </ds:SignedInfo>
    <ds:SignatureValue>…</ds:SignatureValue>
    <ds:KeyInfo>
      <ds:X509Data>
        <ds:X509Certificate>MIIE…</ds:X509Certificate>
      </ds:X509Data>
    </ds:KeyInfo>
  </ds:Signature>
  <samlp:Status>
    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </samlp:Status>
  <saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="_be660efdab841f6ba2bf82e3dab20fb2d3c38371f5" Version="2.0" IssueInstant="2026-10-25T19:36:16Z">
    <saml:Issuer>https://idp.example.com</saml:Issuer>
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
      <ds:SignedInfo>
        <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
        <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
        <ds:Reference URI="#_be660efdab841f6ba2bf82e3dab20fb2d3c38371f5">
          <ds:Transforms>
            <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
            <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
          </ds:Transforms>
          <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
          <ds:DigestValue>m8UcPcGlwXFs37IDHlwO4Y45vBEVLJIh4l37nbrqvRY=</ds:DigestValue>
        </ds:Reference>
      </ds:SignedInfo>
      <ds:SignatureValue>…</ds:SignatureValue>
      <ds:KeyInfo>
        <ds:X509Data>
          <ds:X509Certificate>MIIE…</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </ds:Signature>
    <saml:Subject>
      <saml:NameID SPNameQualifier="https://wiki.example.com" Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">[email protected]</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData NotOnOrAfter="2026-10-25T19:41:16Z" Recipient="http://localhost:18804/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp" InResponseTo="_ca6fca80d8036e1c039dcee05e99ce3bb7b925f12b"/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="2026-10-25T19:35:46Z" NotOnOrAfter="2026-10-25T19:41:16Z">
      <saml:AudienceRestriction>
        <saml:Audience>https://wiki.example.com</saml:Audience>
      </saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="2026-10-25T19:36:16Z" SessionNotOnOrAfter="2026-10-26T03:36:16Z" SessionIndex="_a79c4196de2c7218e09cd89073d831187756757200">
      <saml:AuthnContext>
        <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
      </saml:AuthnContext>
    </saml:AuthnStatement>
    <saml:AttributeStatement>
      <saml:Attribute Name="uid" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
        <saml:AttributeValue xsi:type="xs:string">sara</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
        <saml:AttributeValue xsi:type="xs:string">[email protected]</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="displayName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
        <saml:AttributeValue xsi:type="xs:string">Sara Ali</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="groups" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
        <saml:AttributeValue xsi:type="xs:string">finance</saml:AttributeValue>
        <saml:AttributeValue xsi:type="xs:string">wiki-editors</saml:AttributeValue>
      </saml:Attribute>
    </saml:AttributeStatement>
  </saml:Assertion>
</samlp:Response>

والصورة التالية تضع كل جزء من هذا الرد بجانب الفحص الذي يجريه التطبيق عليه:

خمسة أجزاء من الـ Assertion الحقيقي بجانب ما يفحصه التطبيق: Issuer والتوقيع يتحقق منهما بالشهادة المحفوظة، وNameID مع Recipient وInResponseTo، وConditions مع NotBefore وNotOnOrAfter وAudience، وAuthnStatement مع AuthnInstant وSessionIndex، والصفات mail وgroups
كل حقل في الـ Assertion يقابله فحص، وإذا سقط فحص واحد يجب أن يرفض التطبيق الرسالة

في الرد أعلاه لاحظ التالي:

  • توقيعان وليس توقيعاً واحداً: المزود وقع الرد كله Response، ووقع الـ Assertion داخله أيضاً، وكل توقيع يشير بالحقل Reference URI إلى معرف الجزء الذي يغطيه. وهذا التفصيل هو أصل أخطر ثغرات SAML كما سيأتي.
  • الشهادة داخل الرسالة لا تعني شيئاً: الرسالة تحمل شهادة المزود في X509Certificate، ولكن التطبيق الصحيح يتحقق بالشهادة التي سجلتها عنده مسبقاً، وإلا استطاع أي شخص أن يوقع رسالة بمفتاحه ويرفق شهادته.
  • مدة قصيرة جداً: بين NotBefore وNotOnOrAfter في Conditions خمس دقائق ونصف، فالمزود أصدر الرد في 19:36:16 وجعل بدايته قبل ذلك بثلاثين ثانية حتى يتحمل فرق الساعة بين السيرفرين، وجعل نهايته بعد خمس دقائق. لذلك يجب أن تكون ساعة السيرفرين مضبوطة عبر NTP، وإلا رفضت ردود صحيحة.
  • Audience وRecipient: الرد صادر لـ https://wiki.example.com فقط وإلى عنوان ACS الخاص به فقط، فلا يصلح لتطبيق آخر حتى لو كان يثق بنفس المزود.
  • SessionNotOnOrAfter: المزود يقترح على التطبيق ألا تزيد جلسته عن ثماني ساعات، والتطبيق حر في الالتزام بذلك.
  • الصفات تمر كما هي: groups تحمل finance وwiki-editors، والتطبيق يبني عليها الصلاحيات، ولذلك يجب أن يرفض أي تعديل عليها.

نجرب الأعطال عن قصد

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

ماذا يحدث إذا غير أحد المجموعة داخل الرد؟

نسجل الدخول من جديد ونحتفظ بالرد قبل إرساله، ثم نغير المجموعة finance إلى admins بهذا السكربت tamper.py:

# tamper.py <in.b64> <out.b64>: change sara's group inside the signed assertion
import sys, base64
xml = base64.b64decode(open(sys.argv[1]).read())
print('finance found:', xml.count(b'>finance<'))
open(sys.argv[2], 'w').write(base64.b64encode(xml.replace(b'>finance<', b'>admins<')).decode())
bash flow.sh > /dev/null
python tamper.py samlresponse.b64 tampered.b64
bash post.sh tampered.b64
docker logs saml-sp 2>&1 | grep 'Caused by' | tail -1
finance found: 1
HTTP/1.0 500 Internal Server Error
simplesamlphp ERR [b0b3165b7a] Caused by: Exception: Reference validation failed

الهاش المحسوب للـ Assertion بعد التعديل لم يعد يطابق DigestValue الموقع، فرفض التطبيق الرد بالكامل. ثم أرسلنا الرد الأصلي غير المعدل من نفس المتصفح فقبله التطبيق وظهرت صفحة Hello Sara Ali، أي أن الرفض كان بسبب التعديل فقط.

ماذا يحدث إذا سرق أحدهم الرد وأرسله من متصفحه؟

لنفرض أن المهاجم حصل على الرد الذي استخدمته سارة، من سجل Proxy أو من ملف HAR أرسلته للدعم الفني، فأرسله من متصفح آخر بملف ملفات تعريف ارتباط جديد:

rm -f attacker.txt
JAR=attacker.txt bash post.sh main.b64
HTTP/1.0 500 Internal Server Error
simplesamlphp ERR [ff7493b1c9] Caused by: SimpleSAML\Error\Exception: Unsolicited Response MUST NOT contain InResponseTo. Got: '_ca6fca80d8036e1c039dcee05e99ce3bb7b925f12b'

هنا يظهر دور InResponseTo: الرد يقول إنه جواب على الطلب _ca6f…، ولكن جلسة متصفح المهاجم لم ترسل هذا الطلب، فرفضه التطبيق. وهذا الفحص هو ما يجعل الدخول الذي يبدؤه التطبيق أقوى من الدخول الذي يبدؤه المزود IdP-initiated، والسبب أن الرد في الحالة الثانية لا يحمل InResponseTo أصلاً، فلا يبقى للتطبيق إلا المدة القصيرة وفحص التكرار.

ماذا يحدث إذا وصل الرد بعد انتهاء مدته؟

سجلنا الدخول في 19:37:14 فكان NotOnOrAfter هو 19:42:13، ثم انتظرنا حتى 19:43:55 وأرسلنا الرد من نفس المتصفح:

HTTP/1.0 500 Internal Server Error
simplesamlphp ERR [84f5f540ca] Caused by: SimpleSAML\Error\Exception: Error validating SubjectConfirmation in Assertion: NotOnOrAfter in SubjectConfirmationData is in the past: 1792957333

وماذا عن إرسال الرد نفسه مرتين؟

عندما أرسلنا الرد الأصلي مرة ثانية من متصفح سارة نفسه لم يظهر خطأ، وإنما أعاد التطبيق التحويل 303 إلى الصفحة، وكتب في سجله:

simplesamlphp INFO [33c207836b] Duplicate SAML 2 response detected - ignoring the response and redirecting the user to the correct page.

أي أن التطبيق يحفظ معرفات الردود التي استخدمها، ويتجاهل الرد المكرر دون أن ينشئ جلسة جديدة منه. وهذا الفحص مطلوب في SAML Profiles لأن الـ Assertion من نوع Bearer، أي أن من يحمله يدخل به، تماماً مثل التذكرة.

ثغرات SAML المعروفة: لماذا لا تكتب التحقق بنفسك

التحقق من توقيع XML أصعب بكثير مما يبدو، والسبب أن معيار XML Signature يوقع جزءاً من الوثيقة يشار إليه بمعرف، وليس الوثيقة كلها، ثم تقرأ المكتبة البيانات من الوثيقة. فإذا كانت الطريقة التي تحدد بها المكتبة الجزء الموقع تختلف عن الطريقة التي تقرأ بها البيانات، استطاع المهاجم أن يضع Assertion موقعاً حقيقياً في مكان، وAssertion مزوراً في مكان آخر، فيتحقق التوقيع من الأول ويقرأ التطبيق الثاني. وهذا ما يسمى تغليف التوقيع XML Signature Wrapping، وتكرر في مكتبات كثيرة.

📌
في سبتمبر 2024 نشرت مكتبة ruby-saml الثغرة CVE-2024-45409 بدرجة 9.9 من 10، وهي أن المكتبة لا تتحقق من توقيع الرد بشكل صحيح، فمن يملك أي وثيقة موقعة من مزود الهوية يستطيع أن يعدلها ويدخل باسم مستخدم آخر. وكانت GitLab من أشهر من يستخدم المكتبة، فأصدرت في 17 سبتمبر 2024 إصدارات تصحيحية لخمسة فروع، ونصحت حتى التحديث بتفعيل المصادقة الثنائية في GitLab نفسه لكل الحسابات، ونبهت إلى أن تفعيل MFA في مزود الهوية لا يحمي من هذه الثغرة، والسبب أن المهاجم لا يمر على المزود أصلاً. ثم وجد فريق GitHub Security Lab في 2025 طريقة أخرى في نفس المكتبة من خلال الفرق بين محللي XML. والدرس أن التطبيق الذي يستقبل SAML يجب أن يستخدم مكتبة مشهورة، وأن يحدثها فور صدور التحديث الأمني.

قائمة تحقق قبل أن تربط تطبيقاً حقيقياً

  • اشترط توقيع الـ Assertion: وليس الرد الخارجي فقط، فإذا كان التطبيق يسمح بخيار «لا تتحقق من التوقيع» فلا تستخدمه حتى في الاختبار.
  • الشهادة من الـ Metadata فقط: تأكد أن التطبيق يتحقق بالشهادة المسجلة عنده، وليس بالشهادة المرفقة في الرسالة.
  • استخدم معرفاً ثابتاً: اربط الحساب في التطبيق بـ NameID من نوع persistent أو بمعرف ثابت من الدليل، وتجنب البريد إذا كان قد يتغير أو يعاد استخدامه لموظف جديد.
  • ضبط الساعة: شغل NTP على سيرفر المزود وسيرفر التطبيق، فالفرق الكبير يرفض ردوداً صحيحة.
  • دوران الشهادات Certificate Rotation: شهادة التوقيع لها تاريخ انتهاء، والتطبيقات التي تقرأ الشهادة يدوياً سوف تتوقف في يوم انتهائها. لذلك ضع الشهادة الجديدة في الـ Metadata بجانب القديمة قبل التبديل بأسابيع، أو اجعل التطبيق يقرأ Metadata المزود من رابطها دورياً.
  • التشفير Encryption اختياري: يمكن أن يشفر المزود الـ Assertion بمفتاح التطبيق العام، وهذا يفيد عندما تمر الردود عبر أطراف لا تثق بها، ولكنه لا يغني عن HTTPS ولا عن التحقق من التوقيع.
  • الخروج الموحد Single Logout: SAML يدعمه، ولكن دعمه في التطبيقات ضعيف، فاجعل مدة جلسة التطبيق قصيرة كما ذكرنا في شرح الخروج الموحد.
  • لا ترسل ملفات HAR وفيها ردود SAML: الرد صالح لدقائق فقط ومربوط بالجلسة، ولكن نفس الملف يحمل ملفات تعريف الارتباط التي تعمل ساعات.

رسائل خطأ شائعة وما تعنيه

ما تراهالسبب الأرجح
Reference validation failed أو Invalid signatureالرد معدل، أو الشهادة المسجلة في التطبيق ليست شهادة المزود الحالية بعد تجديدها
NotOnOrAfter … is in the pastالرد وصل متأخراً، أو ساعة أحد السيرفرين غير مضبوطة
Unsolicited Response MUST NOT contain InResponseToالرد وصل إلى متصفح أو جلسة غير التي طلبته، وأشهر سبب بريء له اختلاط ملفات تعريف الارتباط بين نطاقين أو انتهاء جلسة التطبيق قبل عودة المستخدم
المزود يرفض الطلب بسبب ACSعنوان ACS في الطلب لا يطابق المسجل عند المزود، وغالباً بسبب http بدلاً من https خلف Reverse Proxy
Audience غير صحيحEntity ID المكتوب في المزود يختلف حرفاً عن الذي يعرف به التطبيق نفسه، مثل شرطة مائلة في النهاية

إزالة التجربة

docker rm -f saml-idp saml-sp
docker network rm saml-lab
docker rmi saml-lab:2.5.3.1
دليلما هو الدخول الموحد SSO؟ المصطلحات التي تحتاجها أولاًخريطة لمصطلحات الدخول الموحد SSO مع تجربة حقيقية تبين كيف تفتح جلسة واحدة في مزود الهوية تطبيقين، وماذا يبقى بعد الخروج.دليلشرح OAuth وOpenID Connect للمبتدئينلماذا لا يجب أن يعرف أي تطبيق كلمة مرورك، وكيف يحل OAuth ذلك بتوكنات محدودة، وكيف يضيف OpenID Connect الدخول الموحد.دليلتثبيت authentik للدخول الموحد SSO إلى تطبيقاتككيف تثبت authentik حتى يدخل كل موظف إلى تطبيقات الفريق بحساب واحد، مع فرض المصادقة الثنائية على الجميع.دليلLDAP ودليل المستخدمين: كيف تعرف التطبيقات من هو المستخدم؟نبني دليل مستخدمين على OpenLDAP ونرى بمخرجات ldapsearch الحقيقية كيف تتحقق التطبيقات من كلمة المرور والمجموعات، وأين يخطئ أغلب من يبدأ.دليلما هو SCIM؟ إدارة حسابات الموظفين تلقائياً في كل تطبيقاتكالدخول الموحد SSO يمنع الموظف المغادر من الدخول لكن حساباته تبقى نشطة، وSCIM ينشئ الحسابات ويعطلها في كل تطبيق تلقائياً.

الخلاصة

  • SAML 2.0 معيار من 2005 ما زالت تعتمد عليه تطبيقات المؤسسات والخدمات السحابية والاتحادات الجامعية، فإذا دعم التطبيق OpenID Connect فاختره، وإذا لم يدعم إلا SAML فاستخدمه دون تردد.
  • الثقة تبنى بتبادل ملفات Metadata: معرف كل طرف Entity ID، وعنوان ACS في التطبيق، وشهادة التوقيع في المزود.
  • الطلب صغير فيمر داخل الرابط HTTP-Redirect، والرد كبير فيمر في نموذج HTTP-POST، وفي تجربتنا كان الرد 10877 بايت base64 فيه توقيعان.
  • التطبيق يفحص التوقيع بالشهادة المسجلة، والجمهور Audience، والمستلم Recipient، والمدة، وInResponseTo، والتكرار. ورأينا في التجربة رفض الرد المعدل والرد المسروق والرد المنتهي، وتجاهل الرد المكرر.
  • لا تكتب التحقق من XML بنفسك، فثغرات Signature Wrapping أصابت مكتبات معروفة مثل ruby-saml، واستخدم مكتبة مشهورة وحدثها فوراً.
  • ضبط الساعة عبر NTP ودوران الشهادات قبل انتهائها يمنعان أغلب الأعطال المفاجئة في الإنتاج.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ تجربة الدخول والأعطال الأربعة على SimpleSAMLphp 2.5.3.1 مع PHP 8.4.27.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.