hyunsu blog

홈동향

Node.js Brotli RCE, WAF 로 못 막는 fetch 경로와 22·24·26 패치 확인

목차 · 7
  1. 01고친 10건 중 High 4건은 Brotli·TLS·DNS 에 있다
  2. 02Brotli 결함은 왜 방화벽만으로 다 막히지 않나
  3. 03TLS·DNS 결함은 검사한 문자열과 실제 접속 대상이 어긋난 틈이다
  4. 04패치 여부는 brotli 버전 대신 오류 상수 -17 로 가린다
  5. 05한국 개발자에게: Node 20 위의 SSR·BFF 서버가 가장 급하다
  6. 06올리기 전까지 막을 것과 올린 뒤 볼 것
  7. 07자주 묻는 질문

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월 기준)지원 종료
2626.12.1Current, 10월 28일 Active LTS 진입 예정2029년 4월 30일
2424.22.1LTS, 10월 20일 Maintenance 진입2028년 4월 30일
2222.23.4Maintenance LTS2027년 4월 30일
20 이하·홀수 라인없음지원 종료20 은 2026년 4월 30일

고친 결함 10건은 아래와 같다. 영향 라인이 건마다 다르니 자기 라인에 해당하는 줄만 보면 된다.

CVE등급내용영향 라인
CVE-2026-111497HighBrotli 해제기의 범위 밖 쓰기, 원격 코드 실행 가능22·24·26
CVE-2026-105483High전각 숫자로 쓴 IP 에서 인증서 호스트명 검사 우회24·26
CVE-2026-106069HighIP 로 느슨하게 해석되는 호스트에서 인증서 호스트명 검사 우회22·24·26
CVE-2026-105482Highdns.resolve 가 NUL 문자가 든 이름을 잘라 조회22·24·26
CVE-2026-105481Mediumkeep-alive 소켓이 요청 전에 써 둔 응답을 처리22·24·26
CVE-2026-106072Medium비정규 IPv4 표기에서 DNS 이름 인증서로 IP 상대를 인증22·24·26
CVE-2026-106074Medium시작 스냅숏·SEA 에 난수 캐시가 저장돼 프로세스마다 같은 값22·24·26
CVE-2026-106070LowZipFile 이 헤더 검증 실패 뒤 공격자가 넣은 앞부분 바이트를 반환26
CVE-2026-106071LowPermission Model 와일드카드 조회로 파일 권한을 넓게 허용26
CVE-2026-106073Lowfs.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 분석 글이 쓴 방법이고, 상수만 읽을 뿐 아무것도 풀지 않아 운영 서버에서 돌려도 안전하다.

bash
# 고친 빌드는 -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 올리기 전까지 막을 것과 올린 뒤 볼 것

  1. 서버·CI 러너·컨테이너·개발 PC 의 Node 를 위 명령으로 확인하고, 26.12.1·24.22.1·22.23.4 이상으로 올린다.
  2. 20 이하·홀수 라인은 24 나 26 으로 옮길 일정을 잡는다.
  3. 바로 못 올리는 서버는 앞단 프록시에서 Content-Encoding: br 요청을 거절한다. 받아 오는 응답은 이것으로 막지 못한다.
  4. 상대를 고를 수 없는 외부 호출은 axios·got 의 압축 해제를 잠시 꺼 두는 것도 임시책이다. 대신 대역폭이 늘고, 압축된 응답만 주는 상대와는 어긋날 수 있다.
  5. SEA·시작 스냅숏으로 배포하는 앱은 고친 Node 로 다시 만든다.
  6. node_shared_brotli 가 true 인 배포판 Node 는 배포판 brotli 보안 공지를 따라간다.
  7. IP 를 비정규 표기로 적어 TLS 로 붙는 설정이 있으면 정규 표기로 바꿔 둔다.

3번을 nginx 앞단에서 한다면 요청 헤더 변수 $http_content_encoding 을 보고 거절하면 된다.

nginx
# 고친 Node 로 올리기 전까지: Brotli 로 압축한 요청 본문을 앞단에서 거절
server {
    # ...기존 설정
    if ($http_content_encoding ~* "br") {
        return 415;
    }
}

07 자주 묻는 질문

내장 fetch() 만 쓰는데 Brotli 해제를 끌 수 있나

호출하는 쪽에서 켜고 끄는 옵션은 없다. DEV 분석 글은 직접 압축 해제를 거치도록 요청을 우회시키거나 클라이언트를 바꾸는 것만 빠져나갈 길로 든다. 결국 런타임을 올리는 것이 가장 짧은 길이다.

Node 20 을 당장 옮기지 못하면 무엇을 할 수 있나

Node 공식 공지는 우회책을 내놓지 않았다. 앞단 프록시에서 br 요청 본문을 거절하고 axios·got 의 압축 해제를 끄는 정도가 남는데, 둘 다 fetch() 로 받는 응답은 막지 못한다. 그러니 옮기기 전까지의 시간 벌기로만 보는 편이 맞다.