CVE-2026-111497 (node2shell) 분석
CVE-2026-111497 (node2shell) 분석
CVE · 웹·서버 · RCE · CVE-2026-111497 · [심층]
덱. Node.js가 자기 안에 품고 있던 Brotli 압축 해제기에서, 잘못 만든 압축 데이터 하나가 메모리 경계 밖에 쓰기를 일으킬 수 있었습니다. Node.js는 이를 “원격 코드 실행(RCE)을 허용”하는 High 취약점으로 분류했습니다. 이 글은 실제 패치 커밋을 열어, 패치가 정확히 무엇을 바꿨는지(그리고 공개된 범위에서 무엇이 아직 모르는 부분인지)를 정직하게 가려 봅니다.
“실패했다”와 “아직 모른다”는 전혀 다른 말입니다. 그런데 코드는 이 둘을 같은 값 하나로 돌려주는 일이 흔합니다. 이 글의 버그는 그 교과서적 사례로 읽힙니다 — 압축 해제기가 “입력이 더 필요하다”와 “이 스트림은 규격 위반이다”를 같은 신호로 돌려줬고, 호출하는 쪽은 상황에 따라 그 신호를 무시했습니다. 아래는 패치 diff가 직접 보여 주는 사실이고, 그것이 곧바로 메모리 손상으로 이어진 경로는 공개된 범위에서 확인되지 않아 추론으로 표시합니다.
개요
Node.js는 2026년 10월 21일 보안 릴리스(22.23.4 / 24.22.1 / 26.12.1)를 냈고, 그 안에 CVE-2026-111497 — “Brotli 디코더 경계 밖 쓰기(out-of-bounds write)가 원격 코드 실행을 허용”이 High로 들어 있었습니다. [확인] 제보자는 OpenAI Codex Security(Khai Tran, Sylvie Mayer), 수정은 mcollina와 aduh95로 공지에 적혀 있습니다. [확인]
- “node2shell”은 공식 이름이 아닙니다. 이 통칭은 외부 연구자·벤더 블로그가 붙인 별명이고, Node.js 공지에도 CVE 레코드에도 쓰이지 않습니다.
[확인=공식 미사용]이 글은 검색·식별을 위해서만 괄호에 병기합니다. - CVSS 점수는 Node.js 공지에 없습니다. 다만 CVE 레코드(CVE.org·CNA=Google)는 CVSS **4.0 기준 9.2(Critical)**로 매기며, 저희가 2026-10-25 레코드를 직접 열어 확인했습니다
[확인=CVE.org](벡터는 아래 “영향 범위” 절에 기재). CWE도 같은 레코드에서 CWE-787(경계 밖 쓰기)+CWE-252(반환값 미검사) 쌍을 직접 확인했습니다[확인=CVE.org]. 즉 CVE 기준은 Critical(9.2)이고 Node.js 공지는 이를 자체 기준 “High”로 분류한 것이라, 본문은 두 분류를 함께 적습니다. - 실제 악용(ITW)은 확인되지 않았습니다. 1차 공지는 악용 여부를 언급하지 않고, 2차 출처는 CISA KEV(실악용 목록) 미등재와 CVE 레코드의 “exploitation: none” 표기를 전합니다.
[2차 보고]일부 공개 PoC가 크래시를 낸다는 보고와 “RCE까지 재현했다”는 주장이 엇갈리지만, 후자는 2차 출처도 원문을 열지 못했다고 밝혔습니다.[미검증]이 글은 “RCE 가능성은 벤더가 인정, 실제 악용·완성 재현은 미확인”으로 씁니다.
영향받는 버전
| 라인 | 수정 버전 | 상태 |
|---|---|---|
| Node.js 22.x | 22.23.4 | [확인] 공지 |
| Node.js 24.x | 24.22.1 | [확인] 공지 |
| Node.js 26.x | 26.12.1 | [확인] 공지 |
| EOL 라인(20 이하 등) | 수정 없음 | 공지: EOL 버전은 보안 릴리스 때 항상 영향 [확인] |
EOL·배포판 가이드. Node 20.x 이하와 이미 지원이 끝난 홀수/비LTS 라인(21·23·25 등)은 업스트림이 수정 릴리스를 내지 않습니다(공지의 “EOL은 보안 릴리스 때 항상 영향”까지가 [확인]). 이런 라인을 쓴다면 (1) Ubuntu·Debian·RHEL 등 배포판의 보안 업데이트가 이 CVE를 백포트했는지 확인하고, (2) 상용 LTS 벤더의 백포트를 확인하며, (3) 둘 다 없으면 지원 라인으로 이전하는 것이 현실적인 해법입니다. 배포판 백포트는 버전 번호가 그대로일 수 있으니 위 “탐지” 절의 상수 핑거프린트로 실제 빌드를 확인하십시오. [2차·분석]
수정 릴리스 시각은 GitHub 릴리스 메타데이터로 확인했습니다: v22.23.4는 2026-10-21 10:38 UTC. [확인]
번들된 Brotli 쪽 수정 버전은 업스트림 릴리스 1.2.1 / 1.1.1 / 1.0.10(2026-10-21)입니다. [확인](google/brotli 자체 릴리스노트의 CVE-2026-111497 보안 항목 기준이며, nodejs.org 공지의 의존성 갱신 목록에는 Brotli가 없습니다) 영향 범위의 시작(“1.0.3부터”)은 2차 단독 주장이라 [미검증]입니다.
기술적 배경
Brotli는 구글이 만든 압축 형식으로, 웹에서 Content-Encoding: br로 흔히 쓰입니다. Node.js는 zlib 모듈에서 Brotli 압축·해제를 제공하는데, 이때 쓰는 C 구현을 별도 설치가 아니라 저장소 안(deps/brotli)에 통째로 복사해 함께 배포합니다(라이선스 MIT, Node.js도 MIT). 그래서 Brotli의 버그는 시스템 패키지를 갱신해서는 고쳐지지 않고, Node.js 자체를 올려야 고쳐집니다.
Brotli 데이터는 “메타블록”이라는 덩어리로 나뉘고, 덩어리 안에서는 블록 타입(literal·command·distance 세 종류 각각)을 중간에 바꿀 수 있습니다. 압축 스트림 헤더는 “이 덩어리에는 블록 타입이 몇 종류 있다”를 먼저 선언하고, 본문에서 “블록 타입을 바꾸라”는 신호가 나옵니다. 타입이 1종류뿐이라고 선언해 놓고 바꾸라고 하면 규격 위반입니다.
또 하나의 배경은 디코더의 두 실행 경로입니다. 디코더는 입력이 충분하다고 확신할 때 검사를 생략하는 빠른 경로(unsafe)와, 입력이 모자랄 수 있을 때 되감기 지점을 저장하며 진행하는 안전 경로(safe)를 둘 다 가집니다. 이 구조가 아래 원인의 무대입니다.
취약점 발생 원인 [패스 A — 구조/정적: 미흡점 열거]
아래 코드는 모두 nodejs/node 저장소의 실제 소스입니다. 패치 전 상태 = 패치 커밋 49dff6f57aa7의 부모 4d6990b33c445e85c3b6186697f5b6a2e7874eef, 파일 deps/brotli/c/dec/decode.c (MIT). 라인번호는 이 부모 SHA 기준입니다.
A-1. 규격 위반을 “입력 부족”과 같은 값으로 반환
// deps/brotli/c/dec/decode.c @ 4d6990b33c44 (Node.js 저장소, MIT) 1179-1191행
static BROTLI_INLINE BROTLI_BOOL DecodeBlockTypeAndLength( // 1179
int safe, BrotliDecoderState* s, int tree_type) {
brotli_reg_t max_block_type = s->num_block_types[tree_type];
...
if (max_block_type <= 1) { // 1189
return BROTLI_FALSE; // 1190
}
일부 줄을 생략(...)한 발췌입니다. 원문 그대로의 줄 텍스트이며 완성형 재현 입력은 아닙니다.
- 어디:
DecodeBlockTypeAndLength(decode.c:1179), 블록 전환 요청 처리 지점. - 없는 검증: 블록 타입 수가 1 이하인데 전환이 요청된 경우를 “스트림이 규격 위반”으로 구분해 알리는 에러 경로.
BROTLI_FALSE는 이 함수가 입력이 모자랄 때 돌려주는 값(같은 함수 1200행대에서SafeReadSymbol실패 시에도 같은 값)과 동일합니다. - 그래서 가능해지는 것: 호출하는 쪽은 “무효 스트림”과 “아직 더 읽을 입력이 필요함”을 구분할 수 없습니다.
[확인=코드]
A-2. 호출 매크로가 경로별로 FALSE를 다르게(또는 아예) 취급
// 같은 파일, 같은 SHA 1943-1953행
#define BROTLI_SAFE(METHOD) \
{ \
if (safe) { \
if (!Safe##METHOD) { \
result = BROTLI_DECODER_NEEDS_MORE_INPUT; \
goto saveStateAndReturn; \
} \
} else { \
METHOD; \
} \
}
- 어디:
BROTLI_SAFE(decode.c:1943–1953). 블록 전환 3곳이 이 매크로로 호출됩니다 — command 전환(1994행), distance 전환(2139행), literal 전환(2333행). - 없는 검증: safe 경로에서는 FALSE가
NEEDS_MORE_INPUT(= 에러가 아니라 “더 주세요”)으로 번역됩니다. unsafe 경로에서는METHOD;처럼 반환값을 아예 버립니다(1951행).[확인=코드] - 그래서 가능해지는 것: 규격 위반 신호가 에러로 올라가지 못하고, 한쪽 경로에서는 “더 기다림”, 다른 쪽에서는 “무시하고 계속”이 됩니다.
[확인=코드]
A-3. 미흡점 사이의 연결 — 어디까지가 사실인가
위 A-1, A-2는 diff와 소스로 확인되는 사실입니다. 그 다음 사슬, 즉 “무시하고 계속 진행한 디코더 상태가 어디서 경계 밖 쓰기가 되는가”는 공지·커밋 메시지 어디에도 서술돼 있지 않습니다. 업스트림 커밋 제목은 “Detect and report invalid stream earlier”(비정상 스트림을 더 일찍 감지해 보고)뿐이고, 공지는 “경계 밖 쓰기가 RCE를 허용”이라고만 적었습니다. [확인=1차 서술의 한계]
따라서 이 글의 인과 서술은 이렇게 구분합니다.
- 사실: 패치는 이 규격 위반 경로를 에러로 승격했고, 그 변경이 CVE-ID 트레일러와 함께 들어갔다.
- 추론: 규격 위반 스트림이 에러 없이 디코드 상태를 진행시키는 것이 경계 밖 쓰기의 전제 조건이었을 것이다.
[분석]어느 버퍼에 어떻게 쓰이는지는 공개 범위에서 모른다.
취약점 공격 과정 [패스 B — 흐름/동적: 호출 순서]
실제 악용 절차는 공지·커밋에 없고, 우리도 재현하지 않았습니다. 아래는 코드에서 읽히는 일반적 흐름이며 입력 바이트열은 싣지 않습니다.
[Brotli 입력] 블록 타입 1종 선언 + 블록 전환 요청이 섞인 비정상 스트림
│
▼
[BrotliDecoderDecompressStream] 메타블록 해석 -> ProcessCommandsInternal
│
▼
[블록 전환 처리] DecodeBlockTypeAndLength -> FALSE (A-1)
│
├─ safe 경로: NEEDS_MORE_INPUT 로 번역 -> 에러가 아닌 "대기" (A-2)
└─ unsafe 경로: 반환값 무시 -> 상태 계속 진행 (A-2)
│
▼
[이후 디코드 단계] 비정상 상태에서 진행 -> 경계 밖 쓰기? [분석·1차 미서술]
노출면 [2차·복수출처 일치]. 취약해지는 조건은 “신뢰할 수 없는 Brotli 데이터를 Node.js zlib로 푸는 모든 코드 경로가 있다”입니다. 이 트리거 조건 자체는 공지에 근거합니다. [확인] 구체적인 노출 경로는 서로 독립된 2차 출처 두 곳(벤더 블로그 둘 — 참고자료 6·7)의 설명이 일치하지만, 우리는 직접 재현하거나 코드로 확인하지 않았습니다.
- 요청 본문: Express 5가 쓰는 body-parser 2.0.0 이상은
express.json/urlencoded/text/raw에서inflate기본값(true)에 따라 본문의Content-Encoding: br을 해제한다고 합니다. - 반대로 Express 4(body-parser 1.x)는
br에 415를 돌려주므로 요청 본문 경로로는 노출되지 않는다고 합니다. - 전역
fetch()는 Node 18 이상에서 Brotli 응답을 자동 해제하므로, 별도 의존성 없이도 노출됩니다. - axios·got·node-fetch·undici·superagent 같은 HTTP 클라이언트 라이브러리도 응답을 자동으로 해제합니다.
- 외부 URL 응답을 읽는 모든 경로 — 웹훅 수신, 외부 API 연동, SSRF로 서버가 공격자 지정 주소를 읽게 되는 경로 — 가 해당됩니다.
즉 노출 여부는 “내 앱이 어디서 Brotli를 푸는가”의 문제이고, 의존성 트리 전체를 봐야 합니다. 이 글은 위 (1)~(5)를 점검 목록으로만 제시하며, 각 경로의 실제 도달성은 미검증입니다. [2차·분석]
미흡점 ↔ 흐름 매핑
| 미흡점 (패스 A) | 발화 지점 (패스 B) | 여는 것 | 근거 수준 |
|---|---|---|---|
| A-1 규격 위반이 FALSE로 반환 | 블록 전환 처리 | 무효 스트림이 에러로 분류되지 못함 | [확인=코드] |
| A-2 unsafe 경로가 반환값 무시 / safe 경로는 대기로 번역 | 호출 매크로 | 무효 상태에서 디코드 진행 | [확인=코드] |
| (A-3) 진행된 상태 -> 경계 밖 쓰기 | 이후 디코드 | 메모리 손상 | [분석] 1차 미서술 |
취약점 패치 + 패치 완전성 평가
실제 수정은 Node.js v24.22.1에 들어간 두 커밋으로 확인했습니다(v22·v26은 각 라인의 별도 백포트 커밋으로 들어가며 이 글에서 SHA를 개별 조회하지는 않았습니다). 둘 다 원본 PR은 비공개 저장소(nodejs-private/node-private#1002)에 있고, 공개된 것은 릴리스 시점에 풀린 커밋입니다. [확인]
| 커밋 | 내용 | 규모 |
|---|---|---|
49dff6f57aa7 | deps: brotli: cherry-pick 0ed4268a4401abcb44720963 — 업스트림 Brotli 수정 체리픽 | decode.c +58/-29, decode.h +1/-3 |
b5f41529df78 | src: expose BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH — 새 에러 코드를 JS에 노출 + 회귀 테스트 | node_zlib.cc +1, 테스트 +27 |
업스트림 원본은 google/brotli의 0ed4268a4401(2026-02-27, “Detect and report invalid stream earlier”)이고, 같은 수정이 brotli 1.2.1 / 1.1.1 / 1.0.10 릴리스 노트의 보안 항목으로 들어갔습니다. [확인] 눈여겨볼 점은 수정 커밋이 2월에 업스트림에 있었는데 보안 릴리스는 10월이라는 시차입니다 — 같은 수정이 처음엔 일반 버그 수정으로만 취급됐을 가능성을 시사하지만, 이유는 공개된 근거가 없어 추정입니다. [분석]
무엇이 바뀌었나 (diff 발췌, 파일/라인/왜)
- 함수가 bool 대신 에러 코드를 돌려준다 —
decode.c1179행대 (패치 전 기준 라인)
-static BROTLI_INLINE BROTLI_BOOL DecodeBlockTypeAndLength(
+static BROTLI_INLINE BrotliDecoderErrorCode DecodeBlockTypeAndLength(
int safe, BrotliDecoderState* s, int tree_type) {
...
if (max_block_type <= 1) {
- return BROTLI_FALSE;
+ return BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH;
}
- 왜: 규격 위반(A-1)이 이제 “입력 부족”과 다른 값으로 올라옵니다. 입력 부족은
BROTLI_DECODER_NEEDS_MORE_INPUT, 정상은BROTLI_DECODER_SUCCESS로 분리됩니다(같은 함수의 나머지 반환문들도 같은 방식으로 바뀜).
- 새 호출 매크로가 에러를 전파한다 —
BROTLI_SAFE바로 아래에 추가
+#define BROTLI_SAFE_WITH_STATUS(METHOD) \
+ { \
+ BrotliDecoderErrorCode status; \
+ if (safe) { \
+ status = Safe##METHOD; \
+ } else { \
+ status = METHOD; \
+ } \
+ if (status != BROTLI_DECODER_SUCCESS) { \
+ result = status; \
+ goto saveStateAndReturn; \
+ } \
+ }
- 왜: A-2의 “unsafe 경로가 결과를 버린다”를 구조적으로 없앱니다. 어느 경로든
SUCCESS가 아니면 상태를 저장하고 즉시 빠져나갑니다. 블록 전환 세 호출부(command/distance/literal)가BROTLI_SAFE(...)에서BROTLI_SAFE_WITH_STATUS(...)로 바뀝니다.
- 새 에러 코드와 디버그 검사 —
decode.h,decode.c
- /* -17 code is reserved */
+ BROTLI_ERROR_CODE(_ERROR_FORMAT_, BLOCK_SWITCH, -17) SEPARATOR
+ BROTLI_DCHECK(s->meta_block_remaining_len <=
+ (int)BROTLI_BLOCK_SIZE_CAP);
- 왜: 예약돼 있던 -17 번호에 새 에러가 배정됩니다. 마지막
DCHECK는 디버그 빌드에서만 동작하는 불변식 검사로, 메타블록 남은 길이가 상한을 넘지 않는지 봅니다 — 릴리스 빌드에서는 꺼지므로 방어선이 아닙니다.
Node.js 쪽 두 번째 커밋은 이 -17 에러를 zlib.constants로 내보내고, 잘못된 스트림이 동기·스트리밍 양쪽에서 ERR__ERROR_FORMAT_BLOCK_SWITCH 오류를 내는지 보는 회귀 테스트를 추가합니다. 테스트가 쓰는 입력 바이트열은 이 글에 싣지 않습니다(저장소 test/parallel/test-zlib-invalid-input.js에서 확인할 수 있습니다). [확인]
패치 완전성 평가 — 3질문
Q1 — 근본원인을 막았나, 증상만 막았나? 코드 구조로 보면 원인 쪽을 고친 변경으로 읽힙니다. 한 지점의 값만 막는 필터가 아니라, “규격 위반을 에러로 구분하고 두 실행 경로 모두에서 전파”하는 방향이기 때문입니다. 다만 이 판단은 diff에 대한 우리 해석이고, 경계 밖 쓰기의 정확한 지점이 비공개라 “그 쓰기가 오직 이 경로 하나로만 도달한다”는 것까지는 확인하지 못했습니다. [분석]
Q2 — 같은 뿌리의 다른 입구(형제 싱크)는? 확인하지 못했습니다. 이 diff가 다루는 것은 블록 전환 경로입니다. 같은 파일에서 반환값을 이렇게 무시하거나 “입력 부족”과 섞어 쓰는 호출이 더 있는지는 별도 감사가 필요합니다 — 이 글의 범위 밖이며 후보로만 남깁니다.
Q3 — 조용한/불완전한 재패치는? 후속 재패치 여부는 별도로 조사하지 않았습니다(조사일 2026-10-23, 릴리스 직후라 알려진 것이 없을 가능성이 높지만 근거는 없음). 다만 안전 버전을 정할 때는 후속 권고가 나오는지 계속 봐야 합니다.
선순환 후보(가설). 같은 Brotli 코드를 번들하는 다른 런타임·패키지(다른 언어 바인딩, 컨테이너 베이스 이미지)가 업스트림 수정 이후에도 옛 버전을 들고 있는지 점검하는 “형제 점검”이 가치 있어 보입니다. 동적 확증 전에는 가설입니다.
영향 범위 — Node만이 아니다 (멀티 생태계)
위에서 다룬 Brotli 디코더 결함은 Node.js 안에서만 사는 코드가 아닙니다. 업스트림은 google/brotli C 라이브러리이고, 이 C 구현은 여러 런타임·라이브러리가 그대로 복사하거나 정적으로 링크해 쓰는 구조입니다 — 앞서 “기술적 배경”에서 설명한 Node.js의 deps/brotli 번들 방식과 같은 패턴이 다른 프로젝트에도 반복됩니다. 그래서 같은 바이트 단위 결함이 Node.js 바깥에도 그대로 들어가 있을 수 있습니다. 바로 위 “선순환 후보” 가설이 실제로 확인된 사례라고 볼 수 있습니다. [2차 단독·Aikido Security]
업스트림 기준 영향 버전은 1.0.3 ~ 1.2.0(CVE.org 레코드가 “1.0.3→1.0.10 미만”, “1.1.0→1.1.1 미만”, “1.2.0→1.2.1 미만” 세 분기 구간으로 명시 — 저희가 레코드를 직접 열어 독립 확인 [확인=CVE.org]), 수정은 1.0.10 / 1.1.1(구 브랜치) / 1.2.1로, 위 “패치” 절에서 다룬 google/brotli 커밋 0ed4268(참고자료 4, [확인])과 같은 수정입니다. 이 커밋이 bool 대신 에러 코드를 돌려주게 바꾸고 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH(-17)를 신설한 내용은 위에서 이미 다뤘으므로 여기서 다시 펴지 않습니다.
아래 번들 현황은 벤더 블로그(Aikido Security, 2026-10-23)가 처음 정리한 것을, 저희가 2026-10-25에 각 프로젝트의 자체 보안공지·릴리스·CVE 레코드를 독립적으로 열어 byte-level 대조한 결과입니다. 행별 배지가 확인 근거이며([확인]=1차/권위출처 직접 대조), Aikido 단독 relay로만 남은 행은 없습니다.
| 프로젝트 | 영향 | 수정 버전 (확인 근거) |
|---|---|---|
| google/brotli (업스트림 C) | 1.0.3–1.2.0 (3개 분기 구간) | 1.0.10 / 1.1.1 / 1.2.1 [확인=CVE.org+google/brotli 릴리스] |
| Node.js | 번들 Brotli | 22.23.4 / 24.22.1 / 26.12.1 [확인] |
| Bun | 번들 Brotli | 1.4.4 이상 (번들 실행파일 재빌드 필요) [확인=Bun v1.4.4 공지 Security·CVE-ID 명시구간 미대조] |
| Envoy | 번들 Brotli | 1.40.0 / 1.39.3 / 1.38.6 / 1.37.8 (1.36.12도 수정·1.36 라인 EOL) [확인=Envoy GHSA-3wvj-j6qm-62mr] |
| Istio | Envoy 경유 | 1.29.9 / 1.30.6 / 1.31.2 (1.29 라인 EOL) [확인=Istio 보안공지 2026-007] |
| curl / libcurl | Brotli 의존 (curl 자체 CVE 없음) | Brotli 1.2.1로 교체·정적 링크면 앱 재빌드 [확인=curl.se 보안목록 부재 직접확인] |
Python Brotli(PyPI) | ≥1.0.3, <1.2.1 | 1.2.1 [확인=GHSA-9v8h-qqgm-4m35] |
Python brotlicffi(PyPI) | ≥1.0.3, <1.2.1.0 | 1.2.1.0 [확인=GHSA-9v8h-qqgm-4m35] |
Python 쪽이 특히 눈에 걸리는 이유는 결함이 “import 하는 코드”가 아니라 “설치돼 있는 패키지”를 통해 들어온다는 점입니다. (이 자동로드 동작 자체는 벤더 설명에 기댄 서술입니다 [2차 단독·미검증]) Python HTTP 클라이언트는 Brotli/brotlicffi가 설치돼 있으면 별도로 import하지 않아도 자동으로 불러 쓴다고 벤더는 전합니다 — urllib3/requests, httpx(brotli extra 설치 시), aiohttp, Scrapy가 그런 사례로 꼽힙니다. 즉 “내 코드에 brotli import가 없다”는 점검만으로는 노출 여부를 알 수 없고, 설치된 패키지 목록을 봐야 합니다.
CVE 레코드(CVE.org)는 이 결함에 **CWE-252(반환값 미검사) + CWE-787(경계 밖 쓰기)**을 함께 매핑하며, 저희가 레코드를 직접 열어 확인했습니다 [확인=CVE.org] — 앞서 “개요”에서 밝힌 CWE 두 개와 같은 쌍입니다. CVSS도 CVE.org 레코드 기준 v4.0 9.2(Critical)(벡터 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N)로 독립 확인했고 [확인], Node.js 자체 공지는 High로 분류합니다. 단 “실제 악용(ITW) 없음”은 CVE 레코드 스냅샷 기준 2026-10-22까지의 상태이므로, 발행 직전 CISA KEV 현황을 다시 확인하십시오 [확인=레코드 시점·갱신주의].
영향을 안 받는 곳도 명시해 둡니다 — 공황을 좁히기 위해서입니다. Apache httpd의 mod_brotli는 압축 해제를 하지 않는 모듈(응답 쪽 압축만 담당)이라 이 결함과 무관하다고 벤더는 전합니다. Nginx도 기본 설정에서는 Brotli 응답을 디코드하지 않으므로 기본값 상태로는 해당하지 않습니다(압축 해제는 기본 아님인 별도 서드파티 모듈 몫). [확인=Apache mod_brotli 문서·ngx_brotli 소스 직접확인] 이 두 가지는 “Brotli라는 이름만 보고 전부 취약하다고 넘겨짚지 말라”는 뜻으로 읽어야 합니다.
노출면 일반화. 앞서 “공격 과정” 절에서 다룬 Node.js 노출 경로(요청 본문 해제·fetch()·HTTP 클라이언트 자동 해제 등)는 결국 두 방향으로 요약됩니다 — (1) 아웃바운드: 내가 통제하지 않는 서버로 나가는 요청의 응답을 해제하는 경로(웹훅 수신, URL 미리보기 생성기, 크롤러), (2) 인바운드: Content-Encoding: br이 붙은 요청 본문을 받는 경로. 이 두 방향 구분은 Node.js뿐 아니라 번들·정적 링크된 Brotli를 쓰는 모든 런타임에 똑같이 적용됩니다. [분석]
대응방안
1) 안전 버전으로 업데이트 (최우선). 22.x는 22.23.4 이상, 24.x는 24.22.1 이상, 26.x는 26.12.1 이상으로 올리십시오. EOL 라인(20 이하 등)은 수정이 나오지 않으므로 지원 라인으로의 이전이 사실상 유일한 해법입니다. [확인]
2) 즉시 업데이트가 어려울 때의 차선 (모두 우리 권고이며 공지에 명시된 우회책은 아닙니다 [분석])
- 신뢰할 수 없는 상대가 보내는 요청 본문의
Content-Encoding: br을 앞단(리버스 프록시·게이트웨이)에서 대소문자 구분 없이 거부하거나 해제 전에 차단(인바운드 방향). - 서버 측
fetch()·HTTP 클라이언트에서 외부 응답을 받을 때Accept-Encoding에서br을 제외 — 다만 이것만으로는 불충분합니다. 상대가Accept-Encoding을 무시하고Content-Encoding: br응답을 그냥 보낼 수 있으므로, 해제 전에 응답의Content-Encoding값을 검사해br이면 거부하는 로직을 같이 둬야 합니다(아웃바운드 방향).[분석] - 압축 해제를 맡는 프로세스의 권한·자원을 줄여 OOB 쓰기가 전체 서비스로 번지는 영향을 제한.
- (Python 환경 한정)
Brotli·brotlicffi패키지를 둘 다 제거하고 해당 프로세스를 재시작 — 다만 이 조치는 Node.js·Envoy 등 다른 생태계에 번들된 사본까지는 없애지 못합니다. 생태계별로 따로 조치해야 합니다.[2차 단독·분석]
3) 탐지 — 런타임·호스트 지표.
- 자산 인벤토리.
node -v와process.versions.brotli로 버전 확인, EOL 라인 식별. 신뢰불가 입력을 Brotli로 푸는 지점을 코드·프록시 설정에서 찾아 목록화. 컨테이너 이미지는 실행하지 않고도 내부 Node 버전을 점검할 수 있습니다.node:22-alpine같은 가변 태그는 신뢰하지 말고, 재빌드 후 이미지 안의 버전을 직접 검증하십시오. PM2·systemd 같은 프로세스 관리자는 업그레이드 후 재시작해야 새 바이너리가 적용됩니다.[2차·운영]
(a) 런타임 패치 핑거프린트. 패치는 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH(-17)를 신설해 zlib.constants로 노출했습니다(위 커밋 b5f41529df78). 그러므로 이 상수가 zlib.constants에 있으면 패치가 들어간 빌드, 없으면(undefined/ABSENT) 미패치 빌드로 읽을 수 있습니다. 버전 문자열이 아니라 실제 바이너리의 동작 쪽 증거라서 백포트·재패키징된 빌드를 가릴 때 유용합니다. [분석=패치-diff 근거] 패치 전 부모 SHA의 decode.h에서 -17은 “예약됨” 주석뿐이고 상수 정의가 없음을 직접 열어 확인했습니다. [확인=코드]
(b) --shared-brotli 예외. 시스템 Brotli를 동적으로 불러 쓰도록 빌드한 Node.js에서는 위 상수만으로는 불충분합니다. 이 경우 번들 사본이 아니라 시스템 라이브러리가 디코더이므로, process.versions.brotli와 실제 로드되는 디코더 라이브러리(예: libbrotlidec.so.1)의 버전을 직접 확인해야 합니다. [2차·분석]
(c) 에러코드 급증 관측. 패치 후 빌드에서는 비정상 br 스트림이 ERR__ERROR_FORMAT_BLOCK_SWITCH(-17)로 올라오므로, 이 오류의 급증은 시도의 흔적일 수 있습니다. 반대로 미패치 라인은 이 신호 자체가 없으므로 “신호 없음 = 안전”으로 읽거나 단독 확정 근거로 쓰지 마십시오. [분석] 한편 zlib의 Z_BUF_ERROR(-5)는 정상적인 연결 끊김·잘린 응답에서도 나므로, 동일 IP의 반복 요청과 교차 확인해야 의미가 있습니다. [2차·분석] 참고로 같은 파일에서 Brotli 포맷 에러 번호는 BLOCK_LENGTH_1=-9, BLOCK_LENGTH_2=-10, DISTANCE=-16이며 모두 패치 전 부모 SHA의 decode.h에서 확인했습니다. [확인=코드] (Z_BUF_ERROR는 zlib 상수이며 decode.h에 없어 이 값은 2차 기준입니다.)
(d) 호스트측 신호. 아래는 운영 현장의 관찰 기반 지표이며 확증이 아닙니다. 우리는 재현하지 않았고, 다른 원인으로도 똑같이 나타납니다. [2차·운영관찰]
Content-Encoding: br요청 직후의 워커 프로세스 재시작, OOM 종료(dmesg, 쿠버네티스 파드의 OOMKilled/재시작 횟수), 응답 지연.- 비동기 zlib 작업은 libuv 스레드풀을 쓰므로, 압축 해제가 막히거나 늘어지면 파일 I/O·DNS 조회처럼 무관해 보이는 작업까지 함께 느려질 수 있습니다.
- 이런 신호가 보이면 해당 시각의 앞단 접근 로그에서
br요청 출처를 거꾸로 추적하십시오.
(e) Python 패키지 탐지 (멀티 생태계 확장). 위 (a)~(d)는 Node.js 런타임·호스트 지표입니다. 바로 앞 “영향 범위” 절에서 다뤘듯 Python 환경은 설치된 패키지로 노출되므로 점검 방식이 다릅니다. [2차 단독·분석]
pip show Brotli brotlicffi로 환경·컨테이너 이미지별 설치 여부 확인.pipdeptree -r -p Brotli로 역의존성(어느 패키지가 이걸 끌어왔는지) 추적.- 락파일(
requirements.txt,poetry.lock,Pipfile.lock등) 전체에grep -i brotli. - 탐지 공백 — 갱신(2026-10-25 재확인). 2026-10-23 벤더 블로그의 “
pip-audit·Dependabot·Safety 모두 미플래그” 주장은, 저희가 각 도구의 소스 DB를 직접 열어 대조한 결과 더 이상 전부 맞지 않습니다. Safety는 이미SFTY-20261022-29378(2026-10-22·Critical)로 반영했습니다[확인]. **GitHub AdvisoryGHSA-9v8h-qqgm-4m35**가 2026-10-23 ‘GitHub Reviewed’ 완료 상태라 Dependabot 경고는 이미 뜨고 있을 가능성이 높습니다(직접 알림 리포지토리로 재현하진 않음[분석]). **pip-audit**는 기본 소스(PyPA advisory-database)에 2026-10-25 현재 해당 파일이 아직 없어 기본 모드에서는 놓칠 수 있으나[확인=저장소 직접확인],--vulnerability-service osv옵션이면 OSV.dev에 이미 반영돼(2026-10-24 갱신) 잡힙니다[확인]. 교훈은 그대로지만 더 날카롭게 — “스캐너가 조용하다 ≠ 안전”, 특히 기본 모드만 믿지 말고 osv 소스나 위 수동 점검으로 보완하십시오.
왜 이게 반복되는가 — 논평
이 버그의 뿌리는 메모리 안전이라는 큰 말 이전에, **“오류를 값 하나에 뭉개는 설계”**에 있어 보입니다. 입력 부족과 규격 위반은 호출자가 다르게 대응해야 하는데(전자는 기다리고 후자는 중단), 둘을 같은 FALSE로 돌려주면 호출자는 구분할 방법이 없습니다. 거기에 더해 빠른 경로가 반환값을 버리면, 규격 위반이 조용히 통과합니다. 이건 우리가 여러 번 본 계열과 닿아 있습니다. 앞서 다룬 정규식 컴파일 정수 오버플로(CVE-2026-110264 GitLab 사례)도 “작은 칸 하나가 넘쳐도 컴파일은 성공한 것처럼 끝난” 구조였고, 둘 다 실패를 실패로 선언하지 못해 뒤 단계가 망가진 이야기입니다.
둘째는 번들(vendoring) 의존성의 패치 갭입니다. 수정은 2월에 업스트림에 있었지만, Node.js를 쓰는 사람에게 닿은 것은 Node.js 보안 릴리스가 나온 10월입니다. 시스템 라이브러리를 갱신하면 되는 줄 알았던 사람은, 번들 사본이 따로 산다는 점을 놓칩니다. 앞서 “영향 범위” 절에서 봤듯 이 패턴은 Node.js 하나의 일이 아니라 같은 C 디코더를 번들·정적 링크하는 Bun·Envoy·Istio·curl·Python 패키지 전체에 반복됩니다 [2차 단독] — 번들 의존성은 “한 번 고치면 끝”이 아니라 번들하는 쪽마다 각자 패치 릴리스를 내야 끝나는 구조라는 뜻입니다. 비슷하게 같은 보안 릴리스에 포함된 **ca-cert 갱신(eMudhra 중간 CA 침해)**은 CVE-2026-91403(OpenJDK 쪽 사례, 우리 별도 분석)에서 본 것과 같은 사건이 Node.js 신뢰저장소에도 번진 것입니다. 한 릴리스가 코드 결함(Brotli)과 데이터 결함(CA 신뢰저장소)을 함께 고쳤다는 점에서, “패치 = 코드만”이라는 생각을 버려야 함을 보여 줍니다.
셋째, 이 취약점의 발견자가 AI 보안 분석 도구(OpenAI Codex Security)라는 점은 공지에 적힌 사실입니다. [확인] 그 이상(어떤 방식으로 찾았는지, 얼마나 사람이 개입했는지)은 공개된 근거가 없어 평가하지 않습니다.
긴급도 갱신 (2026-10-23)
2026-10-23 기준, dimasma0305라는 계정이 이 CVE에 대한 공개 PoC 저장소를 공개했다는 제보가 있습니다(사용자 제공 URL 기준 — 저희가 저장소 내용을 열어보지 않았고, 이 글에도 그 내용을 싣지 않습니다). 이는 위협모델이 “패치 전 또는 비공개 역량이 있어야 가능한 단계”에서 공개 아티팩트로 악용이 실증·대중화되는 단계로 이동했다는 신호이므로, 긴급도를 한 단계 높여 읽어야 합니다. 대응은 그대로입니다 — 즉시 업데이트(22.23.4 / 24.22.1 / 26.12.1, 지원 종료 라인은 벤더 백포트 필요 시 적용)가 유일한 완전 해법이며, 위 대응방안의 차선책(앞단 br 차단 등)은 패치 적용 전 노출을 줄이는 임시 수단일 뿐입니다. 공개 PoC의 실제 효능(성공률·대상 버전·RCE 도달 여부)은 저희가 검증하지 않았습니다. 실행하거나 재현하지 않았으므로 작성자의 주장은 미검증으로 취급해 주십시오 [미확정].
쉬운 정리
일상 비유. 택배 상자를 푸는 담당자를 떠올려 보십시오. 이 담당자는 “상자 안에 물건이 몇 종류 들어 있다”는 송장을 먼저 읽고, 그 약속대로 포장을 풉니다. 그런데 송장에 “한 종류”라고 써 놓고 안에서는 “다른 칸으로 바꿔 담으라”는 쪽지가 나오면 원래는 “이 상자는 불량입니다”라고 선언하고 멈춰야 합니다. 이 버그에서는 담당자가 그 상황을 “물건이 더 와야 하나?”와 똑같은 손짓으로 처리했고, 어떤 작업 방식에서는 아예 못 본 척 계속 풀었습니다. 그 결과 상자 밖 선반에까지 물건을 쌓아 버릴 수 있게 된 것으로 읽힙니다(마지막 연결은 우리의 추론입니다). 패치는 담당자에게 “불량” 전용 신호를 쥐여 주고, 어느 작업 방식에서든 그 신호가 나오면 즉시 멈추게 한 것입니다.
핵심 용어
- Brotli: 구글이 만든 압축 형식. 웹에서
Content-Encoding: br로 보내는 데이터가 이것입니다. - OOB(경계 밖) 쓰기: 정해진 메모리 칸을 벗어나 옆 칸을 덮어쓰는 것. 심하면 프로그램 흐름을 바꾸는 데까지 이어질 수 있어 “원격 코드 실행”으로 분류됩니다.
- 디코더의 safe/unsafe 경로: 입력이 충분한지 매번 확인하며 천천히 가는 길(safe)과, 충분하다고 믿고 검사를 줄인 빠른 길(unsafe). 이 버그는 두 길이 같은 신호를 서로 다르게 취급한 데서 드러났습니다.
BROTLI_FALSE뭉개기: “입력이 모자람”과 “규격 위반”을 같은 거짓 값 하나로 돌려주는 설계. 호출하는 쪽이 둘을 구분할 수 없습니다.- 번들(vendoring) 의존성: 외부 라이브러리를 설치하지 않고 프로그램 소스 안에 사본으로 같이 배포하는 방식. 시스템 패키지를 갱신해도 그 사본은 안 고쳐지고, Node.js 자체를 올려야 합니다.
결론
압축 해제기가 “더 필요하다”와 “규격 위반이다”를 한 값으로 돌려준 결과, 비정상 데이터가 에러 없이 흘러 경계 밖 쓰기에 닿았을 가능성이 높습니다 — 이 마지막 연결은 추론이고, 패치 diff가 보여 주는 사실은 그 신호를 구분해 모든 경로로 전파하도록 고쳤다는 것입니다. 한 틈은 닫혔지만 “실패를 값 하나에 뭉개는” 습관은 다른 자리에서 또 나옵니다. 그래서 도구가 아니라 방법을 남깁니다: 실패의 종류를 호출자가 구분할 수 있게 반환하고, 어떤 실행 경로에서도 그 반환값을 버리지 않는 것.
비공개로 남은 쓰기 지점의 세부가 풀리거나 후속 권고가 나오면, Q1·Q2를 다시 열어 확인할 값어치가 있습니다.
References / 참고자료
- (재구성한 원 분석) Node.js, “October 2026 Security Releases” — 이 글이 재구성한 공식 공지(CVE 목록·심각도·수정 버전·제보/수정자·의존성 갱신). https://nodejs.org/en/blog/vulnerability/october-2026-security-releases
- nodejs/node 커밋
49dff6f57aa72cbbe13e480cd41ff4e46837c8fc(brotli 체리픽, MIT) — https://github.com/nodejs/node/commit/49dff6f57aa72cbbe13e480cd41ff4e46837c8fc - nodejs/node 커밋
b5f41529df78a8af7b395abd6a97d2dcb6c52c61(에러 코드 노출·회귀 테스트) — https://github.com/nodejs/node/commit/b5f41529df78a8af7b395abd6a97d2dcb6c52c61 - google/brotli 커밋
0ed4268a4401abcb4472096340477cf4af7d1a2b(“Detect and report invalid stream earlier”, MIT) 및 v1.2.1 / v1.1.1 / v1.0.10 릴리스 노트 — https://github.com/google/brotli - Node.js v24.22.1 릴리스 노트(변경 목록·릴리스 시각) — https://github.com/nodejs/node/releases/tag/v24.22.1
- Brandefense, “node2shell” 분석 — 통칭의 출처이며 CVSS·CWE·노출면 주장은 2차 단독으로 취급(미검증). https://brandefense.io/blog/node2shell-nodejs-brotli-rce-cve-2026-111497/
- Theori/Xint, node2shell(CVE-2026-111497) 대응 가이드 — 노출면(Express 5 요청본문 br 해제·전역 fetch·HTTP 클라이언트 응답 자동해제)을 독립적으로 서술한 2차 출처. 본문 노출면의 ‘복수출처 일치’ 근거 중 하나(다른 하나는 참고자료 6). CVSS·세부 주장은 2차로 취급. https://xint.io/ko/blog/390470
- Aikido Security, “Python packages vulnerable to node2shell / Brotli CVE-2026-111497” — “영향 범위 — Node만이 아니다” 절의 유일한 출처. Bun·Envoy·Istio·curl·Python(Brotli/brotlicffi) 번들 현황, pip-audit/Dependabot/Safety 탐지 공백 주장은 전부 이 2차 단독 출처에 의존하며 우리는 직접 대조하지 않았습니다. https://www.aikido.dev/blog/python-packages-vulnerable-node2shell-brotli-cve-2026-111497
- GitHub Security Advisory GHSA-9v8h-qqgm-4m35 (Python
Brotli패키지) — Python 쪽 어드바이저리 식별자. 참고자료 8이 인용하는 번호이며 본문 수치를 우리가 직접 열어 대조하지는 않았습니다. https://github.com/advisories/GHSA-9v8h-qqgm-4m35 - GitHub Security Advisory GHSA-3wvj-j6qm-62mr (Envoy) — Envoy 쪽 어드바이저리 식별자. 마찬가지로 참고자료 8이 인용하는 번호이며 우리가 직접 대조하지는 않았습니다. https://github.com/advisories/GHSA-3wvj-j6qm-62mr
이 글은 위 원 분석들을 우리 관점에서 다시 쓰고 논평을 더한 것입니다. 특정 문장·코드를 그대로 옮기지 않았으며(코드 발췌는 MIT 라이선스 오픈소스 저장소의 고정 SHA 기준 인용), 회귀 테스트 입력 등 재현용 바이트열이나 완성형 페이로드는 싣지 않습니다(랩 공개 윤리).
배지: [확인] = 1차출처(공식 공지·GitHub 커밋·업스트림 릴리스)로 확인된 사실 · [2차 단독·미검증] = 벤더 블로그만의 주장 · [2차·복수출처 일치] = 독립된 2차 출처 둘 이상이 일치하나 우리는 직접 확인·재현 안 함 · [2차·운영관찰] = 현장 관찰 기반, 확증 아님 · [분석] = 우리 추론([분석=패치-diff 근거]는 패치 diff의 직접 따름정리). 이 글은 우리 랩에서 재현하지 않았습니다(재현 배지 없음).