
OOB-верификатор для GHSA-c4j6-fc7j-m34r / CVE-2026-44578 (SSRF через WebSocket-upgrade в Next.js)
In-band верификатор для GHSA-c4j6-fc7j-m34r / CVE-2026-44578 — Server-Side Request Forgery (SSRF) в Next.js через WebSocket upgrade-запросы.
⚠️ Только для авторизованного тестирования безопасности. Вы несёте ответственность за то, чтобы у вас было разрешение на тестирование каждой цели, которую вы передаёте этому скрипту.
| Поле | Значение |
|---|---|
| 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 |
| Коммит с исправлением | c4f69086 |
| Не затронуты | Размещённые на Vercel; output: "export"; развёртывания за обратным прокси, который не передаёт Upgrade |
Атакующий открывает TCP-соединение к самостоятельно размещённому процессу Next.js и отправляет HTTP/1.1 WebSocket upgrade, чей request-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 обработчик upgrade до исправления игнорировал finished/statusCode и проверял только parsedUrl.protocol. Поскольку протокол переживает нормализацию, вызывался proxyRequest(...).
proxyRequest выполняет url.format(parsedUrl) над искажённым URL, получая http:/host:port/path. http-proxy разбирает этот target, не находит host (url.parse('http:/...').host === null) и откатывается к своему назначению по умолчанию: localhost:80 (или localhost:443 для https).
Таким образом, на практике SSRF позволяет заставить Next открыть WebSocket upgrade на собственные localhost:80 / localhost:443 хоста Next.js с путём, контролируемым атакующим.
Исправление (коммит c4f69086) заставило обработчик upgrade проверять finished && !statusCode перед проксированием. Случай с 308-нормализацией теперь не проходит проверку !statusCode, и сокет вместо этого закрывается.
Прокси никогда не достигает внешнего хоста. Если вы настроите interactsh / Burp Collaborator / webhook canary и ожидаете, что процесс Next «позвонит домой», этого не произойдёт — соединение уходит на localhost целевой машины. Поэтому данный верификатор использует in-band сигнал, читаемый из upgrade-сокета: уязвимый сервер возвращает распознаваемое тело ошибки, исправленный сервер не возвращает ничего.
Цель SSRF ограничена, но всё ещё значима в реальных развёртываниях:
127.0.0.1:80 или :443 и доверяют запросам с localhost.127.0.0.1:80 (редко, но встречается).Метаданные-эндпоинты AWS / GCP / Azure (169.254.169.254) не доступны напрямую, поскольку баг привязывает назначение к localhost.
Для каждой цели скрипт открывает сырой TCP (или TLS) сокет, отправляет сформированный upgrade, читает ответ и формирует два сигнала:
verdict — присутствует ли баг.impact_confirmed — действительно ли SSRF извлёк данные (т.е. совмещённый сервис на localhost:80/443 цели ответил, и мы получили его ответ).| Ответ | Вердикт | 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 равен true, JSON-вывод также включает upstream_status, upstream_server и upstream_content_type, извлечённые из утёкшего ответа (полезно для разбора инцидента / написания отчёта).
По умолчанию каждая цель также получает контрольный зонд с той же строкой запроса с абсолютным URI, но без заголовков Upgrade (Connection: close). Если фронт-енд возвращает одинаковый ответ на оба зонда (строка статуса + размер в пределах допуска), значит, собственный фронт-енд хоста отклоняет саму строку запроса с абсолютным URI — nginx 400, Apache 400, CDN edge — и 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, чтобы отказаться от проверки и увидеть сырые вердикты.
# single target
python3 verify_ghsa_c4j6.py --target https://app.example.com
# multiple targets via flag repetition
python3 verify_ghsa_c4j6.py \
--target https://app1.example.com \
--target app2.example.com:3000 \
--target 10.0.0.5:80
# from a file (one target per line; '#' for comments)
python3 verify_ghsa_c4j6.py --targets-file targets.txt
# from stdin
cat targets.txt | python3 verify_ghsa_c4j6.py
# JSON Lines output for downstream tooling
python3 verify_ghsa_c4j6.py --targets-file targets.txt --json
# Enumerate co-located services on the target's localhost:80/443 via the bug
python3 verify_ghsa_c4j6.py --target https://app.example.com --scan
# Same, with a custom path list
python3 verify_ghsa_c4j6.py --target ... --scan-paths-file my_paths.txt
--scan опрашивает встроенный список распространённых путей (модули статуса Apache/nginx, health- и metrics-эндпоинты, Spring Boot Actuator, Go pprof, эндпоинты Docker daemon, распространённые админ-панели, утёкшие конфигурационные файлы, маршруты Elasticsearch и т.д.) через SSRF-гаджет.
По умолчанию режим сканирования выполняет один дополнительный дифференциальный базовый зонд со случайным несуществующим путём для каждой цели. Последующие зонды помечаются DIFF только когда их сигнатура (status, длина тела) отклоняется от базовой — одинаковые 404 от апстрима, который «ничего не нашёл», помечаются как noise и не увеличивают счётчик попаданий. Передайте --no-differential, чтобы сообщать о каждом зонде, достигшем сервиса (прежнее поведение).
Вывод группируется по целям: