Node.js Brotli RCE, WAF 로 못 막는 fetch 경로와 22·24·26 패치 확인
Node.js 서버는 10월 21일 나온 보안 패치판(26.12.1·24.22.1·22.23.4)으로 올려야 한다. 지원이 끝난 20 이하 라인은 고칠 판이 없어 다른 라인으로 옮기는 수밖에 없다. 고친 10건 중 가장 급한 CVE-2026-111497 은 Brotli 압축을 풀다가 버퍼 밖에 쓰는 결함이다. 들어오는 요청 본문뿐 아니라 fetch() 로 받아 온 응답에서도 터지므로, 입구 방화벽만으로는 막히지 않는다.
01 고친 10건 중 High 4건은 Brotli·TLS·DNS 에 있다
Node.js 는 지원 중인 세 라인에 같은 날 보안 릴리스를 냈다. 공식 공지는 등급만 매기고 CVSS 점수는 싣지 않았으며, 가장 높은 등급은 High 다. 같은 계열이라도 이 패치보다 앞선 판은 취약하다. 이를테면 24.22.0 은 고쳐지지 않았고 24.22.1 부터 고쳐졌다. 번호를 맞춰 본 뒤에는 뒤에 나오는 확인 명령으로 실제 반영 여부까지 보는 편이 확실하다.
| 라인 | 고친 판 | 상태(2026년 10월 기준) | 지원 종료 |
|---|---|---|---|
| 26 | 26.12.1 | Current, 10월 28일 Active LTS 진입 예정 | 2029년 4월 30일 |
| 24 | 24.22.1 | LTS, 10월 20일 Maintenance 진입 | 2028년 4월 30일 |
| 22 | 22.23.4 | Maintenance LTS | 2027년 4월 30일 |
| 20 이하·홀수 라인 | 없음 | 지원 종료 | 20 은 2026년 4월 30일 |
고친 결함 10건은 아래와 같다. 영향 라인이 건마다 다르니 자기 라인에 해당하는 줄만 보면 된다.
| CVE | 등급 | 내용 | 영향 라인 |
|---|---|---|---|
| CVE-2026-111497 | High | Brotli 해제기의 범위 밖 쓰기, 원격 코드 실행 가능 | 22·24·26 |
| CVE-2026-105483 | High | 전각 숫자로 쓴 IP 에서 인증서 호스트명 검사 우회 | 24·26 |
| CVE-2026-106069 | High | IP 로 느슨하게 해석되는 호스트에서 인증서 호스트명 검사 우회 | 22·24·26 |
| CVE-2026-105482 | High | dns.resolve 가 NUL 문자가 든 이름을 잘라 조회 | 22·24·26 |
| CVE-2026-105481 | Medium | keep-alive 소켓이 요청 전에 써 둔 응답을 처리 | 22·24·26 |
| CVE-2026-106072 | Medium | 비정규 IPv4 표기에서 DNS 이름 인증서로 IP 상대를 인증 | 22·24·26 |
| CVE-2026-106074 | Medium | 시작 스냅숏·SEA 에 난수 캐시가 저장돼 프로세스마다 같은 값 | 22·24·26 |
| CVE-2026-106070 | Low | ZipFile 이 헤더 검증 실패 뒤 공격자가 넣은 앞부분 바이트를 반환 | 26 |
| CVE-2026-106071 | Low | Permission Model 와일드카드 조회로 파일 권한을 넓게 허용 | 26 |
| CVE-2026-106073 | Low | fs.realpathSync 로 Permission Model 우회 | 22·24·26 |
GeekNews 요약에는 9건만 실렸다. 공식 공지와 맞춰 보면 빠진 것은 Medium 인 CVE-2026-106072 다. 의존성도 함께 바뀌었다. c-ares·undici·CA 인증서 저장소는 세 라인 모두 갱신됐고, OpenSSL 은 22·24, SQLite 는 22 에서 올라갔다. CA 저장소 갱신에는 eMudhra 중간 CA 침해 대응이 들어 있다.
02 Brotli 결함은 왜 방화벽만으로 다 막히지 않나
내 서버가 밖에서 받아 오는 응답도 같은 해제기를 거치기 때문이다. Node.js 의 node:zlib 은 Google 의 C 라이브러리 brotli 를 소스째 묶어 빌드한다. 결함은 이 해제기가 블록 전환(block switch) 정보를 처리하는 부분에 있었다. 잘못 만든 스트림을 받으면 버퍼 밖에 써서 메모리가 새거나 코드가 실행될 수 있다. 업스트림 수정 커밋은 이런 스트림을 더 일찍 알아채고, 새 오류 코드 -17(BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH)을 내며 처리를 끊는다. google/brotli 도 10월 21일 이 CVE 를 적은 패치판 1.0.10·1.1.1·1.2.1 을 냈다.
Vercel 공지가 짚었듯 Node 가 알아서 Brotli 를 푸는 일은 없다. 위험해지는 때는 앱 코드나 라이브러리가 남이 만든 Brotli 데이터를 풀 때다. 그런데 아래처럼 기본값으로 풀어 주는 자리가 생각보다 많다.
| 경로 | 기본으로 푸나 | 끄는 방법 |
|---|---|---|
내장 fetch()(undici) | 예, 응답의 Content-Encoding: br | 끄는 옵션 없음(DEV 분석) |
| axios 1 | 예 | decompress: false |
| got | 예 | decompress: false |
| node-fetch 3 | 예 | compress: false |
| Express 5 파서(body-parser 2) | 예, 요청 본문 | inflate: false |
| Express 4 파서(body-parser 1) | 아니오, 415 로 거절 | — |
node:http 서버만 쓸 때 | 아니오 | — |
zlib.brotliDecompress·createBrotliDecompress 직접 호출 | 호출한 만큼 | 입력의 출처를 따진다 |
갈림길은 데이터가 흐르는 방향이다. 들어오는 요청 본문은 앞단 프록시에서 Content-Encoding: br 을 거절하거나 WAF 로 걸러 위험을 줄일 수 있다. Vercel 은 10월 21일 모든 프로젝트에 차단 규칙을 깔았지만, 함수가 다른 서버에서 받아 오는 응답은 그 규칙이 보지 못한다고 밝혔다. DEV 의 분석 글은 평문 HTTP 시험 결과를 적었는데, 요청이 br 을 내세우지 않았는데도 응답에 br 이 붙어 있으면 fetch() 가 그대로 풀었다.
웹훅 전송, 사용자가 넣은 URL 미리보기, 원격 이미지 처리처럼 상대 서버를 내가 고르지 못하는 기능이 있다면 남는 답은 런타임 패치뿐이다. Aikido 는 The Hacker News 를 인용해 10월 22일 기준으로 실제 악용 보고가 없다고 적었다. 다만 연구자들이 패치 직전 판인 공식 24.22.0 이미지(Linux arm64)에서 매번은 아니어도 코드 실행에 성공했고, 24.22.1 은 같은 입력을 거절했다고 한다.
03 TLS·DNS 결함은 검사한 문자열과 실제 접속 대상이 어긋난 틈이다
IP 표기를 비틀면 인증서 이름 검사를 비켜 갔다
TLS 세 건(CVE-2026-105483·106069·106072)은 IP 로 접속할 때 인증서를 맞추는 방식에서 나왔다. IP 를 전각 숫자로 쓰거나, 점으로 나눈 십진 네 칸이 아닌 비정규 표기로 쓰면 접속 단계는 그 문자열을 IP 로 풀어 연결했다. 그런데 인증서 검사는 같은 문자열을 호스트 이름으로 보고 DNS 이름용 SAN(Subject Alternative Name, 인증서가 보증하는 이름 목록)과 맞췄다. 그 결과 IP 상대를 DNS 이름 인증서로 인증해 버리는 틈이 생겼다.
릴리스 노트에 실린 수정은 TLS 가 비정규 IP 표기를 아예 거부하는 쪽이다. 설정 파일이나 환경 변수에 IP 를 줄임 표기로 적어 TLS 로 붙는 곳이 있으면, 업그레이드 뒤 그 연결이 거절될 수 있다. 미리 정규 표기로 바꿔 두면 걸릴 일이 없다.
NUL 문자 하나로 DNS 조회 대상이 바뀌었다
dns.resolve 계열에 NUL 문자(\0)가 든 이름을 넘기면 NUL 뒤가 잘린 이름으로 조회가 나갔다(CVE-2026-105482). 자바스크립트 쪽에서 문자열 전체를 보고 허용 도메인인지 검사했다면, 검사한 이름과 실제로 조회한 이름이 달라진다. 사용자가 넣은 호스트 이름을 조회해 내부망 접근을 막는 SSRF(서버 측 요청 위조) 방어 코드가 이런 구조다. 고친 판은 NUL 이 든 입력을 받지 않는다.
나머지 네 건은 그 기능을 쓸 때만 해당한다
- keep-alive(CVE-2026-105481, Medium): HTTP 클라이언트가 재사용하는 소켓에서, 요청을 보내기 전에 이미 써 둔 응답을 그 요청의 응답으로 처리할 수 있었다.
- 난수 캐시(CVE-2026-106074, Medium):
crypto.randomUUID()·randomInt()가 미리 뽑아 둔 난수 캐시가 사용자 시작 스냅숏과 SEA(단일 실행 파일)에 함께 저장됐다. 그래서 그 파일로 뜬 프로세스마다 같은 값을 되풀이했다. - Permission Model(CVE-2026-106071·106073, Low):
--permission으로 파일 접근을 제한할 때만 해당하고, 와일드카드 조회 건은 26 라인에만 있다. ZipFile(CVE-2026-106070, Low): 26 라인에만 있는 결함이다.
04 패치 여부는 brotli 버전 대신 오류 상수 -17 로 가린다
Node 는 이번에 내장 brotli 판을 통째로 올리지 않고 업스트림 수정 커밋만 골라 넣었다(cherry-pick). 그래서 process.versions.brotli 에 찍히는 brotli 버전만으로는 패치 여부를 가리기 어렵다. 대신 같은 릴리스에서 zlib.constants 에 새 오류 상수가 노출됐으니, 이 값이 있는지 보면 된다. DEV 분석 글이 쓴 방법이고, 상수만 읽을 뿐 아무것도 풀지 않아 운영 서버에서 돌려도 안전하다.
# 고친 빌드는 -17, 고치기 전 빌드는 missing
node -v
node -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH ?? 'missing'"
# 시스템 brotli 를 링크한 빌드인지(true 면 위 결과와 상관없이 배포판 brotli 보안 공지로 확인)
node -p "process.config.variables.node_shared_brotli"
# 컨테이너 이미지 안의 Node
docker run --rm "$IMAGE" node -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH ?? 'missing'"Node 의 기본 빌드는 brotli 를 정적으로 묶는다. 반면 --shared-brotli 로 빌드하면 시스템의 libbrotli 를 링크한다. DEV 글에 따르면 Debian·Alpine·Fedora·RHEL 의 nodejs 패키지가 이렇게 빌드된다. 이런 설치본에서는 실제 해제를 배포판 brotli 가 맡으므로 node -v 도, 위 상수도 패치 여부를 증명하지 못한다. 배포판 brotli 패키지의 보안 갱신을 따로 따라가야 한다. 같은 Alpine 이라도 apk add nodejs 로 깐 Node 와 공식 node:22-alpine 이미지는 패치가 오는 경로가 다르다.
컨테이너는 패치 번호까지 고정한 태그나 다이제스트로 묶은 이미지라면 다시 빌드해도 그대로다. 메이저 태그를 쓴다면 docker build --pull 로 베이스를 새로 받아야 반영된다. CI 러너, .nvmrc·.node-version, engines.node 에 박아 둔 버전도 같이 훑는다.
Brotli 를 묶어 넣는 런타임이 Node 만은 아니다. Bun 저장소에는 자체 빌드에 넣은 brotli 에 패치가 없어 모든 판이 영향을 받는다는 이슈가 올라왔다. Bun 측은 당일 수정판을 내겠다고 답했고, 수정을 합친 뒤 10월 22일 이슈를 닫았다. Aikido 는 고친 판을 Bun 1.4.4 로 적었으니 Bun 을 쓰면 그 이상인지 확인한다. libbrotli 를 직접 링크하는 다른 소프트웨어는 google/brotli 패치판(1.0.10·1.1.1·1.2.1)이 들어갔는지 본다.
05 한국 개발자에게: Node 20 위의 SSR·BFF 서버가 가장 급하다
Node 로 서버 렌더링(SSR)이나 BFF(Backend for Frontend, 화면 전용 API 서버)를 운영하는 팀이 바로 대상이다. Next.js 를 직접 띄우는 팀은 Vercel 공지가 콕 집은 경우다. 이미지 최적화가 원격 이미지를 받아 올 때 Brotli 응답을 막지 않으므로, 외부 이미지 호스트를 허용해 둔 서비스라면 그 호스트의 응답이 해제기에 그대로 닿는다. 외부 API 를 fetch() 로 부르는 BFF 도 같은 경로 위에 있다.
Express 버전에 따라서도 노출면이 다르다. Express 5 로 옮긴 서버는 요청 본문 경로까지 열려 있다. Express 4 에 머문 서버는 들어오는 쪽은 닫혀 있지만, 내보내는 fetch() 쪽은 똑같이 열려 있다. "Express 4 라 괜찮다" 는 판단은 절반만 맞는 셈이다.
가장 급한 쪽은 Node 20 에 남은 서비스다. 20 은 2026년 4월 30일 지원이 끝나 이번 수정을 받지 못한다. 옮길 곳으로 22 를 고르면 2027년 4월에 또 옮겨야 하니, 지금 옮긴다면 24 나 26 이 낫다. 26 은 10월 28일 Active LTS 로 올라가 2029년 4월까지 가고, 24 는 막 Maintenance 로 넘어갔지만 2028년 4월까지 지원된다.
관리형 런타임은 업체마다 사정이 다르다. Vercel 에서 22·24 를 쓰는 프로젝트는 Vercel 이 패치 버전을 관리하므로 설정을 바꿀 필요가 없다. 다만 Vercel 은 패치판을 관리형 런타임에 차례로 까는 중이라 끝나면 공지를 고치겠다고 했다. 20 프로젝트는 Node 버전을 22 나 24 로 바꿔 다시 배포해야 한다. 다른 서버리스·PaaS 는 패치판을 반영하는 시점이 제각각이다. 업체 공지를 보되, 함수 안에서 위 상수를 한 번 찍어 실제 반영 여부를 확인하는 편이 확실하다.
06 올리기 전까지 막을 것과 올린 뒤 볼 것
- 서버·CI 러너·컨테이너·개발 PC 의 Node 를 위 명령으로 확인하고, 26.12.1·24.22.1·22.23.4 이상으로 올린다.
- 20 이하·홀수 라인은 24 나 26 으로 옮길 일정을 잡는다.
- 바로 못 올리는 서버는 앞단 프록시에서
Content-Encoding: br요청을 거절한다. 받아 오는 응답은 이것으로 막지 못한다. - 상대를 고를 수 없는 외부 호출은 axios·got 의 압축 해제를 잠시 꺼 두는 것도 임시책이다. 대신 대역폭이 늘고, 압축된 응답만 주는 상대와는 어긋날 수 있다.
- SEA·시작 스냅숏으로 배포하는 앱은 고친 Node 로 다시 만든다.
node_shared_brotli가 true 인 배포판 Node 는 배포판 brotli 보안 공지를 따라간다.- IP 를 비정규 표기로 적어 TLS 로 붙는 설정이 있으면 정규 표기로 바꿔 둔다.
3번을 nginx 앞단에서 한다면 요청 헤더 변수 $http_content_encoding 을 보고 거절하면 된다.
# 고친 Node 로 올리기 전까지: Brotli 로 압축한 요청 본문을 앞단에서 거절
server {
# ...기존 설정
if ($http_content_encoding ~* "br") {
return 415;
}
}07 자주 묻는 질문
내장 fetch() 만 쓰는데 Brotli 해제를 끌 수 있나
호출하는 쪽에서 켜고 끄는 옵션은 없다. DEV 분석 글은 직접 압축 해제를 거치도록 요청을 우회시키거나 클라이언트를 바꾸는 것만 빠져나갈 길로 든다. 결국 런타임을 올리는 것이 가장 짧은 길이다.
Node 20 을 당장 옮기지 못하면 무엇을 할 수 있나
Node 공식 공지는 우회책을 내놓지 않았다. 앞단 프록시에서 br 요청 본문을 거절하고 axios·got 의 압축 해제를 끄는 정도가 남는데, 둘 다 fetch() 로 받는 응답은 막지 못한다. 그러니 옮기기 전까지의 시간 벌기로만 보는 편이 맞다.