본문 바로가기
CVE 취약점

CVE-2026-29057: Next.js Rewrites HTTP Request Smuggling 분석

by WhiteGuidance 2026. 10. 5.
반응형

개요


항목 내용
CVE ID CVE-2026-29057
대상 Next.js (rewrites()로 외부 백엔드에 프록시하는 구성)
취약 버전 >= 9.5.0, < 15.5.13 / >= 16.0.0-beta.0, < 16.1.7
패치 버전 15.5.13, 16.1.7
CVSS 4.0 6.3 (Moderate)
CWE CWE-444 (Inconsistent Interpretation of HTTP Requests — Request Smuggling)
근본 원인 Next.js가 내장한 http-proxy 라이브러리의 deleteLength() 로직 결함

 

Next.js는 next.config.js의 rewrites()로 특정 경로를 외부 백엔드에 프록시할 수 있습니다. 이때 내부적으로 vendoring된 http-proxy 라이브러리를 사용하는데, 이 라이브러리가 DELETE/OPTIONS 요청을 재전송하는 과정에서 헤더와 실제 바디 내용이 어긋나는 문제가 있었습니다. 그 결과, 공격자가 하나의 HTTP 요청 안에 두 번째 요청을 숨겨서 백엔드의 의도치 않은 경로(내부 API, 관리자 엔드포인트 등)에 도달시킬 수 있습니다.


1. 전체 공격 흐름

 

핵심은 헤더가 말하는 바디 길이와 "실제로 전송되는 바이트 수"가 어긋난다는 점입니다.

  1. 공격자는 DELETE /rewrites/poc 요청을 Transfer-Encoding: chunked로 보내면서, 청크 바디 안에 GET /secret HTTP/1.1... 이라는 두 번째 요청 전체를 텍스트로 숨겨 넣습니다.
  2. Next.js는 Transfer-Encoding을 정상적으로 이해하고 바디를 디코딩한 뒤, 백엔드로 재전송하기 위해 deleteLength()라는 내부 함수를 거칩니다.
  3. 이 함수가 버그 때문에 Content-Length: 0을 붙이면서 Transfer-Encoding 헤더는 지워버립니다. 그런데 바디 바이트는 그대로 pipe()로 흘려보냅니다.
  4. 중간 프록시나 로드밸런서는 Content-Length: 0만 보고 "이 요청은 여기서 끝"이라고 판단하고, 남은 바이트를 완전히 새로운 요청으로 파싱해버립니다.
  5. 결과적으로 백엔드는 DELETE /rewrites/poc와 GET /secret 두 개의 요청을 받게 됩니다.

2. 바이트 레벨로 보는 차이 — 취약 버전 vs 패치 버전

같은 입력을 넣었을 때 실제로 선로 위에 어떤 바이트가 흐르는지 비교하면 문제가 더 명확해집니다.

 

취약 버전은 헤더에 content-length: 0을 써놓고 그 뒤에 GET /secret ... 텍스트를 그대로 붙여 보냅니다. 다음 홉 입장에서는 "길이 0짜리 요청 하나 + 그 뒤에 독립적으로 도착한 요청 하나"로 보일 수밖에 없습니다.

 

패치 버전은 Transfer-Encoding: chunked를 그대로 유지합니다. 청크 인코딩은 청크 크기(2E 같은 16진수)와 종료 마커(0)로 바디의 끝을 스스로 표시하기 때문에, GET /secret ...이라는 문자열은 그냥 청크 바디 안의 데이터로만 취급되고 별도의 요청으로 분리되지 않습니다.


3. 소스코드 분석 — deleteLength()의 버그

이 취약점의 진짜 원인은 Next.js가 내장한 http-proxy@1.18.1의 lib/http-proxy/passes/web-incoming.js 안, 아래 함수 하나입니다.

패치 전 코드

 

패치 후 코드

 

쉽게 풀어보면 이렇습니다.

  • 원래 이 함수가 존재하는 이유는, DELETE나 OPTIONS처럼 보통 바디가 없는 요청에 대해 "바디 없음"을 명시적으로 표시(Content-Length: 0)해 주기 위한 것입니다.
  • 패치 전: 조건이 !req.headers['content-length'] 하나뿐입니다. 즉 "Content-Length 헤더가 없으면"이라는 뜻인데, Transfer-Encoding: chunked로 온 요청은 애초에 Content-Length가 없는 게 정상입니다. 그래서 이 조건이 그대로 참이 되어버리고, Content-Length: 0을 억지로 붙인 다음 Transfer-Encoding 헤더를 지워버립니다. 문제는 헤더만 지웠을 뿐, 실제 청크 바디 데이터는 이미 스트림에 흘러들어간 상태라 그대로 전송된다는 점입니다. 즉 "헤더상으로는 바디가 없다고 말해놓고, 실제로는 바디를 보내는" 모순이 생깁니다.
  • 패치 후: 조건이 typeof ... === 'undefined' && typeof ... === 'undefined'로, Content-Length와 Transfer-Encoding이 둘 다 없을 때만 동작하도록 좁혀졌습니다. Transfer-Encoding: chunked가 있으면 이 조건이 거짓이 되어 함수가 아무 것도 하지 않고, 헤더를 원래대로 유지합니다. 그러면 Node.js가 청크 인코딩 프레이밍을 그대로 살려서 전송하므로 바디의 경계가 명확하게 유지됩니다.

그림으로 정리하면 다음과 같습니다.

 

한 줄 요약: "Content-Length가 없다"는 것과 "바디가 없다"는 것은 다른 의미인데, 패치 전 코드는 이 둘을 같은 것으로 착각했습니다. Transfer-Encoding으로 바디 길이를 표현하는 요청도 Content-Length는 당연히 없기 때문입니다.


4. 추가 하드닝 — Connection 헤더 처리 (common.js)

이번 패치는 web-incoming.js 수정만으로 끝나지 않았습니다. 같은 커밋에서 lib/http-proxy/common.js의 setupOutgoing() 함수에도 방어 로직이 추가되었는데, 이는 연결(Connection) 재사용을 통한 우회를 막기 위한 것입니다.

 

왜 이게 필요했을까요? deleteLength()가 제대로 고쳐졌더라도, 만약 재전송되는 연결이 keep-alive로 계속 유지된다면 그 연결 위에서 다른 방식의 desync가 발생할 여지가 남습니다. 그래서 요청에 Transfer-Encoding 헤더가 있거나, Connection 헤더 값 자체에 transfer-encoding이라는 hop-by-hop 토큰이 지정되어 있으면, 무조건 연결을 닫도록(connection: close) 강제합니다.

 

기존 코드는 Connection 값에 upgrade 토큰이 있을 때만 예외적으로 연결을 유지시켜줬는데(WebSocket 지원 목적), 이 예외 규칙을 공격자가 Connection: Transfer-Encoding, upgrade처럼 악용할 수 있는 여지가 있었던 것으로 보입니다. 패치는 이 예외보다 먼저 Transfer-Encoding 관련 검사를 두어, upgrade 여부와 무관하게 연결을 닫아버리도록 순서를 조정했습니다.

 

실제 공식 패치에는 이 5가지 케이스에 대한 회귀 테스트가 함께 추가되어, 모든 경우에 GET /secret이 백엔드에 도달하지 않는 것을 검증합니다.


5. 영향 범위와 조건

  • 영향받지 않는 경우: Vercel처럼 rewrite를 CDN/엣지 레벨에서 처리하는 호스팅 환경은 이 문제의 영향을 받지 않습니다. next.config.js의 rewrites()로 자체 서버(Node.js 런타임)에서 외부 백엔드로 프록시하는 self-hosted 구성이 대상입니다.
  • 공격 요구 조건: 네트워크 접근만 있으면 되고 인증이나 사용자 상호작용은 필요 없습니다(CVSS PR:N, UI:N). 다만 rewrite 대상 경로가 알려져 있어야 하고, 중간에 요청을 파싱 방식이 다른 하위 시스템(프록시, 백엔드)이 존재해야 실질적 피해로 이어집니다(AT:P, Attack Requirements: Present).
  • 패치 전 임시 대응: 업그레이드가 당장 불가능하다면, 엣지/프록시 단에서 rewrite 대상 경로로 가는 청크 인코딩 DELETE/OPTIONS 요청을 차단하거나, 백엔드 라우트 자체에 별도의 인증/인가를 강제하는 것이 권장됩니다.

 

반응형