Node.js를 올렸는데 Brotli는 왜 그대로일까
Node.js 22.23.4로 올렸다는 말이 무엇을 증명하는지, 일곱 개 주장으로 쪼개 공식 문서와 편집부 실험으로 하나씩 맞춰 봤습니다. 사본마다 고치는 길이 다릅니다.
아닙니다. “Node.js를 올렸으니 끝”이라는 문장은 일곱 가지 뜻으로 읽히는데, 증거로 맞다고 말할 수 있는 것은 하나뿐입니다. 내가 올린 그 Node.js 바이너리 안의 Brotli 복호기가 고쳐졌다는 것입니다. 나머지 여섯은 그 서버에 Node.js 말고 어떤 사본이 있는지, 압축된 바이트가 어느 길로 들어오는지, 파일을 바꾼 뒤 프로세스를 다시 띄웠는지에 따라 맞기도 하고 틀리기도 합니다. 이 글의 날짜와 시각은 모두 UTC입니다.
결함은 Google의 압축 라이브러리 Brotli의 복호기에 있고, 번호는 CVE-2026-111497, 별명은 Node2Shell입니다. 이름에 Node가 들어갔을 뿐 Node.js만의 문제가 아닙니다. 그래서 “Node.js 올렸음”이라는 보고가 올라오면 그 한 줄이 실제로 무엇을 확인해 주는지부터 따져 볼 필요가 있습니다. 이 글은 사건 경과를 처음부터 늘어놓지 않고, 그 한 줄을 주장 일곱 개로 쪼개 하나씩 증거와 맞대어 봅니다. 주장마다 무엇이 근거로 쓰이는지, 편집부가 직접 재 본 값이 무엇인지, 그래서 맞는지 틀리는지를 적습니다.
증거는 세 종류로 나눠 표시합니다. 사건의 사실(날짜, 버전, 점수, 영향 범위)은 Node.js, Microsoft, GitHub, google/brotli 저장소, Debian 보안 트래커, 악용 확인 목록(KEV)의 공식 기록에서만 가져왔습니다. 형식과 API의 동작은 RFC 7932와 각 프로젝트 문서에서 읽었습니다. 그리고 편집부 실험은 일회용 컨테이너에서 직접 돌린 결과입니다. 실험에는 정상 입력만 썼고, 결함을 건드리는 입력은 만들지도 돌리지도 않았습니다. 해설은 모두 편집부 해설이라고 밝혔습니다.
한 문장을 일곱 주장으로 쪼갠다
보고서에 “Node.js 업그레이드 완료”라고 적는 사람은 대개 위험이 사라졌다는 뜻으로 씁니다. 그러나 읽는 쪽은 그 문장에서 서로 다른 일곱 가지를 꺼내 갑니다. 아래 표는 그 일곱 가지를 주장으로 적고, 각각이 맞다고 말하려면 어떤 증거가 있어야 하는지, 아래 절들이 내린 판정이 무엇인지를 한 줄씩 모은 것입니다.
| # | 문장이 암시하는 주장 | 맞다고 하려면 필요한 증거 | 판정 |
|---|---|---|---|
| 1 | 22.23.4 이상이면 그 Node.js는 고쳐졌다 | Node.js 공지와 릴리스 노트 | 맞다 (그 바이너리에 한해) |
| 2 | 버전 문자열을 보면 고쳐졌는지 안다 | 고친 빌드와 안 고친 빌드가 다르게 답하는 값 | 틀리다 (값에 따라) |
| 3 | 평소 요청이 멀쩡히 돌면 고쳐진 것이다 | 두 버전이 정상 입력에 다르게 반응함 | 틀리다 |
| 4 | Node.js를 올리면 Brotli도 같이 오른다 | 사본이 어디에 들어 있는지 | 배포 방식에 따라 갈린다 |
| 5 | 서버로 들어오는 요청만 막으면 된다 | 복호기가 호출되는 모든 길 | 틀리다 |
| 6 | 파일을 바꿨으니 돌고 있는 프로세스도 새것이다 | 실행 중인 프로세스의 상태 | 틀리다 |
| 7 | 악용이 확인됐으니 우리도 뚫렸다 | 악용 확인 기록이 담은 범위 | 이 기록만으로는 말할 수 없다 |
판정을 먼저 보여 드린 이유는 이 글이 결말에서 반전을 만들려는 글이 아니기 때문입니다. 절마다 같은 순서로, 이 주장을 뒷받침하려고 흔히 드는 근거, 편집부가 잰 값, 판정을 적습니다.
주장 1: 22.23.4면 고쳐졌다
Node.js의 10월 21일 보안 릴리스 공지는 이 결함을 “Node.js에 번들된 Brotli 복호기가 잘못된 압축 입력에 대해 내부 복호기 버퍼의 끝을 넘어 쓰게 한다”고 설명합니다. 원인으로 적힌 것은 잘못된 블록 전환이 영구적인 형식 오류가 아니라 일시적인 입력 부족으로 보고되었다는 점이고, 그래서 스트리밍 복호기가 8바이트짜리 내부 버퍼에 입력 바이트를 계속 덧붙였다고 합니다. 응용이 node:zlib로 풀어 주는 Brotli 데이터를 공격자가 줄 수 있으면 프로세스 메모리가 손상되어 충돌, 프로세스 메모리 노출, 원격 코드 실행에 이를 수 있다는 것이 공지의 평가이고, 등급은 High입니다. 영향 범위는 26.x, 24.x, 22.x이고 수정 버전은 각각 26.12.1, 24.22.1, 22.23.4입니다. 보고자는 OpenAI Codex Security의 Khai Tran과 Sylvie Mayer, 수정은 mcollina와 aduh95라고 공지가 밝혔습니다.
공지에는 한 줄이 더 있습니다. 수명이 끝난 계열은 보안 릴리스가 나올 때마다 항상 영향을 받는다는 것입니다. 22, 24, 26을 쓰지 않는 서버는 “올렸다”가 아니라 “계열을 바꿨다”가 되어야 맞습니다.
22.23.4 릴리스 노트의 주요 변경 목록에는 이 결함 관련 항목이 두 개 있습니다. 하나는 “deps: brotli: cherry-pick 0ed4268a4401abcb44720963”, 다른 하나는 “src: expose BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH“입니다. 앞의 항목이 말하는 것은 Node.js가 Brotli를 새 버전으로 갈아 끼운 것이 아니라, 구글 저장소의 수정 커밋 하나를 자기 사본에 얹었다는 점입니다. 뒤의 항목은 새 오류 코드를 zlib.constants로 내보냈다는 뜻입니다.
타임스탬프를 맞춰 보면 이 둘의 선후가 보입니다. 구글 저장소에서 이 수정 커밋(0ed4268, 제목 “Detect and report invalid stream earlier.”)이 들어간 때는 2월 27일 13시 10분이고, 릴리스 가지로 옮겨진 커밋은 10월 21일 10시 5분과 17시 16분 등입니다. Node.js 22.23.4 릴리스는 10월 21일 10시 38분이고, Brotli 프로젝트 자신의 1.0.10, 1.1.1, 1.2.1은 같은 날 19시 27분 무렵에 게시되었습니다. 편집부 해설: Node.js가 자기 사본에 커밋을 얹어 먼저 낸 덕분에 Node.js 사용자는 라이브러리 릴리스를 기다릴 필요가 없었습니다. 반대로 이는 “Brotli 1.2.1이 나왔다”는 문장과 “Node.js 22.23.4가 나왔다”는 문장이 서로 다른 사건이라는 뜻이기도 합니다. 둘은 같은 수정을 각자의 사본에 넣은 별개의 배포입니다.
날짜를 세어 보면 한 가지가 더 보입니다. 2월 27일의 수정 커밋부터 10월 21일의 릴리스까지는 236일입니다. 그 사이 구글 저장소의 기본 가지에는 고친 코드가 있었고 커밋 설명은 “잘못된 스트림을 더 일찍 알아내 보고한다”는 한 줄이었습니다. 릴리스 노트나 권고 번호가 붙은 것은 10월 21일 이후입니다. 편집부 해설: 이 기간에 릴리스 번호만 보고 판정하는 도구는 고쳐진 코드가 이미 공개되어 있다는 사실을 알 수 없었습니다. 반대로 같은 이유로 “1.2.1이 아니니까 고쳐지지 않았다”는 판정도 소스에서 직접 빌드한 사본에는 맞지 않을 수 있습니다. 2월 27일 이후의 커밋을 바로 받아 쓴 사본은 번호가 1.2.0이어도 수정이 들어 있을 수 있기 때문입니다. 이 점이 아래 “버전 문자열” 주장과 이어집니다.
Node.js의 zlib 문서는 이 복호기가 어떤 모양으로 불리는지도 알려 줍니다. Brotli 복호 클래스(BrotliDecompress)는 스트림이고, 마지막 조각을 처리할 때 기본으로 BROTLI_OPERATION_FINISH를 쓰며, 문서가 내놓는 예제는 응답 스트림을 pipeline으로 복호 스트림에 이어 붙이는 방식입니다. 편집부 해설: 스트림 방식은 입력이 여러 조각으로 도착하는 것을 전제로 하므로 “입력이 더 필요하다”는 상태가 정상 흐름의 한가운데에 있습니다. 복호기가 이 상태를 돌려주면 Node.js는 다음 조각을 기다리고, 오류를 돌려주면 스트림을 오류로 끝냅니다. 결함이 하필 이 두 상태를 가르는 곳에서 났다는 점이 이 결함이 스트림 API 쪽에서 드러난 이유와 맞물립니다.
판정: 맞다. 다만 그 판정이 적용되는 범위는 정확히 하나입니다. 22.23.4(또는 24.22.1, 26.12.1) 이상인 공식 Node.js 바이너리에 번들된 사본입니다. 아래 절들은 이 문장에서 “그 바이너리에 한해”라는 단서를 하나씩 풀어 갑니다.
고친 한 줄이 가른 두 갈래
뒤의 주장들을 따져 보려면 무엇이 고쳐졌는지부터 알아야 합니다. 공식 문서만으로 어디까지 설명할 수 있는지 보겠습니다.
RFC 7932는 Brotli 데이터를 메타 블록의 연속으로 정의하고, 한 메타 블록 안의 문자, 삽입·복사 길이, 거리가 각각 “블록 종류”를 갖는다고 설명합니다. 블록 종류가 바뀌는 지점은 블록 전환 명령으로 표현됩니다. 규격은 첫 블록 전환 명령의 블록 종류가 데이터에 인코딩되지 않는다고 적고, 어떤 범주에 블록 종류가 하나뿐이면 첫 블록 전환 명령의 블록 수도 생략된다고 적습니다. 헤더에는 범주별 블록 종류 수(NBLTYPESL, NBLTYPESI, NBLTYPESD)가 들어 있고, 이 수가 최대 블록 종류에 1을 더한 값과 같아야 한다고 규격은 요구합니다. 편집부 해설: 이 설명에서 읽을 수 있는 것은, 종류가 하나뿐인 범주에서 전환 명령이 나타나는 입력은 규격이 예정한 정상 입력이 아니라는 점입니다. 복호기는 그런 입력을 “형식 위반”으로 단정해야 합니다.
RFC가 블록 전환을 어떻게 그리는지 조금 더 따라가 보겠습니다. 한 메타 블록에는 삽입·복사 길이, 문자, 거리라는 세 범주의 요소가 섞여 있고, 범주마다 따로 “지금 쓰는 블록 종류”와 “그 종류로 몇 개 더 읽어야 하는지”를 셉니다. 개수가 다 떨어지면 다음 요소를 읽기 직전에 새 블록 종류와 새 개수를 스트림에서 읽는데, 이것이 블록 전환 명령입니다. RFC의 예에서 문자 범주는 [L0, L1][L2, L3, L4][L5]처럼 종류 0, 1, 0의 세 블록으로 나뉘고, 전환 명령은 LBlockSwitch(1, 3)처럼 쌍으로 적힙니다. 규격은 연속한 블록의 종류가 서로 달라야 한다고 못 박고, 첫 블록은 항상 종류 0이며 그 개수는 메타 블록 헤더에 들어 있다고 설명합니다. 블록 종류 번호는 0부터 최대 255까지이고, 종류를 가리키는 부호는 “직전 종류로 돌아간다”, “현재 종류 더하기 1”, 그리고 각 종류 번호를 뜻하는 기호들로 이루어집니다. 헤더에는 범주별 종류 수가 들어 있습니다.
편집부 해설: 이 규격을 이어 읽으면 “종류가 하나뿐인 범주에서 블록 전환이 요구되는 입력”이 왜 형식 위반인지 설명됩니다. 연속한 블록은 서로 다른 종류여야 하는데 종류가 하나뿐이면 다음 블록이 될 수 있는 종류가 없기 때문입니다. 그러므로 복호기는 그 지점에서 데이터를 더 기다리는 것이 아니라 거절해야 합니다. 거절과 기다림은 호출자에게 정반대의 행동을 요구하는 신호이고, 수정은 정확히 이 구분을 코드에 새겼습니다.
구글 저장소의 v1.2.0과 v1.2.1 소스를 내려받아 비교한 결과가 이 해석과 맞습니다. 편집부 실험: 두 태그의 c/ 아래 파일을 비교하니 다른 파일은 c/dec/decode.c, c/include/brotli/decode.h, c/common/version.h 세 개와 저장소 루트의 CHANGELOG.md 하나뿐이었습니다. 헤더에서는 한 줄이 바뀌었습니다. 1.2.0 헤더에는 /* -17 code is reserved */라는 주석이 있던 자리에, 1.2.1에는 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH(-17)라는 오류 코드가 정의되어 있습니다. decode.c에서는 블록 전환을 읽는 DecodeBlockTypeAndLength가 “성공했는가”만 알리던 불리언 값 대신 BrotliDecoderErrorCode를 돌려주도록 바뀌었습니다. 블록 종류가 하나뿐인 범주(max_block_type이 1 이하)에서는 새 오류 -17을, 입력이 모자란 경우에는 BROTLI_DECODER_NEEDS_MORE_INPUT을 돌려줍니다. 이전 코드에서는 이 둘이 같은 거짓 값이었습니다. 그리고 이 반환값을 호출 쪽에서 검사하도록 BROTLI_SAFE_WITH_STATUS라는 새 매크로가 세 군데(명령, 거리, 문자 블록 전환)에 들어갔습니다. 반환값 검사가 빠졌다는 점은 악용 확인 목록 항목이 이 결함을 CWE-252(반환값 미검사)와 CWE-787(범위 밖 쓰기)로 분류한 것과 일치합니다.
복호기의 API 문서가 “모름”과 “틀림”을 어떻게 나누는지도 봐 둘 필요가 있습니다. decode.h는 스트리밍 복호 함수의 결과를 넷으로 정의합니다. 오류(예: 손상된 입력이나 메모리 할당 문제), 성공, “일부만 처리했으니 입력을 더 달라”는 NEEDS_MORE_INPUT, “출력 공간을 더 달라”는 NEEDS_MORE_OUTPUT입니다. 이 헤더는 오류 코드 목록도 같이 갖고 있고, 입력이 잘못되어 생기는 오류는 모두 FORMAT_이 붙은 음수 코드로 모아 둡니다. 호출하는 쪽은 NEEDS_MORE_INPUT을 받으면 정상적으로 다음 조각을 먹이고, 오류를 받으면 멈춥니다. 따라서 어떤 입력이 “틀림”인데 “모름”으로 보고되면 호출자는 계속 먹이게 되고, 복호기 쪽 상태는 그 입력을 처리하다 만 채로 이어집니다.
그림 1. 같은 종류의 입력에 대한 두 갈래. 왼쪽 위 화살표 두 개가 Node.js 공지가 설명한 8바이트 버퍼 경로입니다
그림에서 봐야 할 것은 호출자가 한 일은 두 경우 모두 똑같다는 점입니다. 호출자는 API가 시키는 대로 했고, 복호기가 무엇을 알려 주느냐에 따라 결과가 갈렸습니다. 이 절의 결론은 두 가지입니다. 고쳐진 것은 호출자가 아니라 복호기 안의 반환 값이므로 호출 코드를 아무리 조심스럽게 써도 이 결함은 못 막습니다. 그리고 고침의 흔적은 새 오류 코드 -17이라는 이름 하나로 요약됩니다. 이 이름이 다음 주장의 열쇠가 됩니다.
주장 2: 버전 문자열로 안다
“버전만 보면 알 수 있다”는 말은 자연스럽게 나옵니다. Node.js에는 process.versions 객체가 있고 거기에 brotli가 들어 있기 때문입니다. 이 값이 고침을 가리키는지 편집부가 직접 재 봤습니다.
편집부 실험: 같은 호스트에서 Node.js 22.13.1(수정 전)과 공식 이미지의 22.23.4(수정 후)를 각각 띄워 같은 명령 두 개를 돌렸습니다. process.versions.brotli는 두 빌드 모두 1.1.0입니다. 앞 절에서 본 대로 Node.js가 Brotli를 올리지 않고 커밋만 얹었기 때문에 예상된 결과입니다. 반면 zlib.constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH는 22.13.1에서 undefined, 22.23.4에서 -17이었습니다. 22.23.4 바이너리에서 이 문자열을 grep -c -a로 세면 3번 나옵니다. 공식 Alpine 기반 22.23.4 이미지도 번들 버전 1.1.0에 상수 -17이었고, ldd로 보면 Node 바이너리는 시스템의 libbrotli에 링크하지 않습니다(출력 0줄).
같은 질문을 구글의 소스를 직접 빌드해서도 던졌습니다. 편집부 실험: v1.2.0과 v1.2.1 소스를 일회용 컨테이너에서 gcc로 각각 빌드해 BrotliDecoderVersion()을 불렀더니 0x1002000과 0x1002001이 나왔습니다. 오류 번호 -17의 이름을 돌려주는 BrotliDecoderErrorString(-17)은 1.2.0에서 INVALID, 1.2.1에서 _ERROR_FORMAT_BLOCK_SWITCH였습니다. 이 함수는 라이브러리가 아는 오류 이름만 돌려주므로 모르는 번호에는 INVALID로 답합니다. 그래서 이 한 번의 호출이 고친 빌드와 안 고친 빌드를 가려냅니다. PyPI의 Brotli 패키지는 달랐습니다. 파이썬 모듈의 __version__이 1.2.0과 1.2.1로 패키지 버전과 같게 나왔고, 확장 모듈 파일(_brotli*.so) 안에서 FORMAT_BLOCK_SWITCH 문자열을 grep -c -a로 세면 1.2.0은 0, 1.2.1은 2였습니다. 두 확장 모듈은 크기가 같고(1,920,096바이트) 시스템 라이브러리로는 libSystem만 링크하며, libbrotli에는 의존하지 않습니다. 복호기가 확장 모듈 안에 정적으로 들어 있다는 뜻입니다.
그림 2. 편집부 실험 출력. 노란 줄은 고쳐지지 않은 쪽, 초록 줄은 고쳐진 쪽입니다
해석은 이렇습니다. Node.js에서는 번들 Brotli의 버전 문자열이 아무것도 말해 주지 않고, Node.js 자체의 버전(22.23.4 이상인가)이나 새 오류 상수가 답을 줍니다. 파이썬 패키지에서는 패키지 버전이 곧 라이브러리 버전이라서 버전 문자열이 답이 됩니다. 소스를 직접 빌드한 C 프로젝트에서는 BrotliDecoderVersion()이 답을 줍니다. 편집부 해설: 같은 “버전 확인”이라는 행위가 사본 종류에 따라 믿을 수 있는 값이 달라지므로, 점검 스크립트를 쓴다면 사본 종류마다 따로 정해 둔 값을 읽어야 합니다. 한 가지 값으로 전부 판정하는 스크립트는 오탐이나 놓침이 생깁니다.
이 값은 로그를 읽을 때도 쓸모가 있습니다. 편집부 실험: 22.23.4 바이너리 안에서 오류 이름 문자열을 찾으면 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH가 2번, 오류 코드를 문자열로 만드는 _ERROR_FORMAT_BLOCK_SWITCH가 1번 나옵니다. 앞서 0xff 한 바이트 입력에서 본 ERR__ERROR_FORMAT_PADDING_2가 같은 규칙(ERR_ 뒤에 _ERROR_FORMAT_ 이름)으로 만들어진 것이므로, 편집부 해설(추정): 고쳐진 Node.js에서 잘못된 블록 전환이 들어오면 응용 로그에는 ERR__ERROR_FORMAT_BLOCK_SWITCH라는 오류 코드가 남을 것입니다. 수정 전 빌드는 이 입력에서 오류를 내지 않고 입력 부족으로 처리하므로 같은 로그를 남기지 못합니다. 그래서 이 이름이 로그에 보인다면 그 서비스가 고쳐진 빌드라는 뜻이면서 동시에 누군가 형식에 맞지 않는 Brotli 데이터를 보냈다는 신호가 되므로, 알림 규칙을 걸어 둘 만한 문자열입니다.
판정: 틀리다. “버전 문자열을 보면 안다”는 문장은 파이썬 패키지에서는 맞고, Node.js에서는 번들 Brotli 버전을 보면 틀리고, 시스템 라이브러리에서는 배포판이 고친 버전 번호를 매기는 방식(다음 절)을 알아야 맞습니다.
주장 3: 평소 요청이 멀쩡하면 끝
“운영 중인 서비스가 잘 돌고 압축된 요청도 잘 풀리니 문제없다”는 확인 방식도 흔합니다. 고친 쪽과 안 고친 쪽이 평소 입력에서 다르게 반응한다면 이 방식이 맞겠지만, 앞 절의 diff는 정상 경로에서 바뀐 것이 없다고 말합니다. 직접 쟀습니다.
편집부 실험: 앞에서 빌드한 두 라이브러리(v1.2.0, v1.2.1)에 같은 입력을 스트리밍 복호 API로 먹이고 결과 분류를 셌습니다. 먼저 정상 문장 반복(2만 바이트)을 압축한 36바이트 스트림은 두 버전 모두 SUCCESS로 2만 바이트를 냈습니다. 같은 스트림을 중간에서 자르거나(절반, 3바이트) 빈 입력을 주면 두 버전 모두 NEEDS_MORE_INPUT이었습니다. 이어서 1바이트짜리 입력 256개 전부, 2바이트짜리 입력 65,536개 전부를 하나씩 넣었습니다. 이 입력들은 길이가 너무 짧아 블록 전환 명령까지 도달하지 못하므로 결함 경로를 건드리지 않습니다. 1바이트 입력의 분류는 오류 74, 입력 부족 173, 성공 9로 두 버전이 같았고, 2바이트 입력의 분류도 오류 22,140, 입력 부족 41,069, 성공 2,327로 두 버전이 같았습니다. 오류로 분류된 2바이트 입력의 오류 코드 분포(-2, -13, -14, -15)까지 같았습니다.
그림 3. 2바이트 입력 전수 시험의 분류별 개수(편집부 실험). 두 빌드의 막대가 같은 길이입니다
그림은 막대가 위아래로 같은 길이로 붙어 있다는 것이 전부입니다. 정상 입력, 잘린 입력, 의미 없이 짧은 입력은 두 버전이 구별되지 않습니다. 같은 일이 Node.js 쪽 API에서도 보였습니다. 편집부 실험: zlib.brotliDecompressSync에 정상 스트림은 풀리고, 절반으로 자른 스트림과 빈 입력은 Z_BUF_ERROR(오류 번호 -5, “unexpected end of file”)를 던지고, 정상 스트림 뒤에 쓰레기 3바이트를 붙인 입력은 그대로 풀렸고, 1바이트 0xff는 ERR__ERROR_FORMAT_PADDING_2(-15, “Decompression failed”)를 던졌습니다. 이 반응은 22.13.1과 22.23.4에서 같았습니다. Python의 Brotli 1.2.0과 1.2.1도 같았습니다. 정상 입력은 풀리고, 자르거나 비운 입력과 0xff 한 바이트는 모두 “brotli: decoder failed”였습니다.
같은 “틀림”과 “모자람”이 사용하는 언어와 API에 따라 다른 이름으로 보인다는 점도 정리해 둘 만합니다. 편집부 실험으로 읽은 Node.js와 Python의 반응에, Microsoft 문서가 적은 .NET의 반응을 합친 표입니다.
| 입력 | Node.js zlib (편집부 실험) |
Python brotli (편집부 실험) |
.NET (Microsoft 문서) |
|---|---|---|---|
| 정상 스트림 | 복호 성공 | 복호 성공 | 복호 성공 |
| 중간에서 잘린 스트림, 빈 입력 | Z_BUF_ERROR, 오류 번호 -5, “unexpected end of file” |
brotli: decoder failed |
문서는 이 경우를 따로 적지 않음 |
형식에 맞지 않는 짧은 입력(0xff 1바이트) |
ERR__ERROR_FORMAT_PADDING_2, 오류 번호 -15, “Decompression failed” |
brotli: decoder failed |
Brotli는 InvalidOperationException: Decoder ran into invalid data를 던질 수 있음 |
| 정상 스트림 뒤에 붙은 쓰레기 3바이트 | 그대로 풀림 (20,800바이트) | 일괄·스트리밍 모두 brotli: decoder failed |
문서는 이 경우를 따로 적지 않음 |
편집부 해설: Node.js는 “모자람”과 “틀림”을 오류 이름 단계에서 구분해 보여 주고(Z_BUF_ERROR 대 ERR__ERROR_FORMAT_*), Python은 둘을 한 메시지로 합쳐 버리고, .NET 문서는 틀린 데이터에 대해 하나의 예외 이름을 적습니다. 로그만 보고 사고를 가리려는 사람에게 이 차이는 중요합니다. 같은 “압축 해제 실패”가 어느 언어에서는 “입력이 덜 왔다”는 뜻이고 어느 언어에서는 “입력이 틀렸다”는 뜻일 수 있어서, 해제 오류를 공격 징후로 세는 규칙을 만들려면 언어마다 오류 이름을 따로 맞춰야 합니다. 표에서 맨 아래 줄은 “정상 스트림 뒤의 남는 바이트”를 Node.js의 zlib은 받아 주고 Python은 오류로 처리한다는 뜻입니다. 같은 입력이 한 서비스에서는 통과하고 다른 서비스에서는 거절되므로, 입력 검증을 한쪽 언어 기준으로 만든 규칙은 다른 쪽에 그대로 옮길 수 없습니다. 이 표의 Node.js 줄에서 Z_BUF_ERROR는 전송이 끊어진 정상 사용에서도 나올 수 있는 이름이므로 공격 징후의 근거로 삼기에는 약하고, ERR__ERROR_FORMAT_* 쪽이 형식 위반을 직접 가리킵니다.
조각 경계도 따로 흔들어 봤습니다. 스트리밍 복호기는 입력을 어디서 끊어 주느냐에 따라 내부 상태가 달라질 수 있어서, 정상 스트림을 36개의 모든 위치에서 두 조각으로 나눠 먹이고, 한 바이트씩도 먹여 봤습니다. 편집부 실험: 두 빌드 모두 모든 위치에서 두 조각으로 나눠도 완전히 복호했고(37번 중 37번), 한 바이트씩 먹이면 입력 부족이 35번 나온 뒤 성공 한 번으로 2만 바이트를 냈습니다. Node.js의 createBrotliDecompress에 한 바이트씩 쓰기를 반복해도 정상 스트림은 20,800바이트로 끝났고, 절반만 쓰고 닫은 스트림은 닫을 때 Z_BUF_ERROR(“unexpected end of file”)로 끝났습니다. 두 Node.js 빌드에서 같았습니다. 수정 전후의 차이는 “입력이 모자란 것”과 “입력이 틀린 것” 사이의 경계 한 군데에만 있고, 정상 스트림은 그 경계를 지나지 않는다는 뜻입니다.
편집부 해설: 이 실험이 보여 주는 것은 정상 트래픽 기반의 “잘 돌아간다” 확인이 취약성 판정에 쓸모없다는 점입니다. 수정이 정상 입력의 동작을 바꾸지 않도록 만들어졌기 때문입니다. 결함 경로를 열어 비교해 보는 방법만이 두 빌드를 가를 텐데, 그것은 이 글이 하지 않는 일입니다. 따라서 확인은 앞 절의 지문(상수, 이름 문자열, 버전)으로 해야 합니다.
판정: 틀리다. 평소 요청이 멀쩡히 돌았다는 사실은 고쳐진 쪽에서도, 안 고쳐진 쪽에서도 똑같이 관찰됩니다.
주장 4: Node와 Brotli는 함께 오른다
서버가 Node.js 하나로만 도는 일은 드뭅니다. 같은 서버에 curl, git, 파이썬이 있고, 컨테이너라면 베이스 이미지가 따로 있습니다. 각 도구의 Brotli가 어디 들어 있는지는 도구마다 다릅니다. 편집부가 확인한 사본 구성을 한 번에 보겠습니다.
편집부 실험: Debian 12 기반 컨테이너에서 ldd로 링크 관계를 읽었습니다. curl 7.88.1은 libbrotlidec.so.1을 동적으로 링크했고, curl --version의 기능 줄에도 brotli/1.0.9가 나옵니다. git 본체(/usr/bin/git)는 Brotli를 직접 링크하지 않지만, HTTP를 처리하는 git-remote-http는 libcurl-gnutls를 거쳐 같은 libbrotlidec.so.1을 씁니다. 이 컨테이너의 dpkg에는 libbrotli1 1.0.9-2+b6이 깔려 있었습니다. 공식 Node.js 바이너리는 앞서 본 대로 ldd에서 Brotli 줄이 0이고, 바이너리 안에 ERROR_FORMAT_BLOCK_SWITCH 문자열이 3군데 있습니다. 파이썬 PyPI 패키지는 확장 모듈 안에 복호기가 정적으로 들어 있었습니다. .NET은 Microsoft 공지가 이렇게 정리합니다. 런타임을 공유하는 앱은 새 런타임을 설치하고 재시작하면 되고, 자체 포함(self-contained) 앱은 새 런타임으로 다시 빌드해 배포해야 하며 호스트에 새 런타임을 깔아도 앱에 묶인 런타임은 그대로입니다.
그림 4. 사본별로 고치는 업그레이드는 하나뿐입니다. 해당 열이 아닌 업그레이드를 해도 그 줄은 그대로입니다
이 격자를 읽는 법은 행마다 초록 칸이 하나뿐이라는 점입니다. 새 Node.js는 공식 Node.js 바이너리만 고칩니다. apt로 시스템 libbrotli를 올리는 일은 curl과 git(Debian에서 시스템 라이브러리를 쓰는 경우)을 고칩니다. pip install은 파이썬 휠 안의 사본을 고치고, .NET은 공유 런타임이냐 자체 포함이냐에 따라 업그레이드 경로가 둘로 갈립니다. 편집부 해설: 이 표의 의미는 “Node.js를 올렸다”는 행동이 일곱 사본 중 하나에만 작용했다는 것입니다. 그 하나가 서비스의 핵심 경로라 해도, 같은 서버의 다른 사본은 그대로 남습니다.
.NET 쪽에는 알아둘 점이 두 가지 더 있습니다. Microsoft 공지에 따르면 이번 긴급 업데이트는 .NET 8, 9, 10과 11 RC3에 나왔고, 같은 업데이트에 Linux용 .NET 압축 파일의 소유자 정보 문제(CVE-2026-114148)의 수정이 들어 있습니다. .NET 8과 9에서는 소스에서 빌드하지 않고 ASP.NET Core 압축 파일로 설치한 사람도 새로 갱신된 압축 파일로 업데이트해야 합니다. 그래서 Brotli 때문에 올리는 호스트는 이 수정도 함께 받게 됩니다.
Debian의 보안 트래커가 이 구조를 공식 기록으로 보여 줍니다. 편집부가 읽은 시점(11월 2일)의 표에서 해당 행을 옮깁니다.
| 소스 패키지 | 릴리스 | 패키지 버전 | 상태 |
|---|---|---|---|
| brotli | bookworm | 1.0.9-2 | 취약 |
| brotli | bookworm (security) | 1.0.9-2+deb12u1 | 수정 |
| brotli | trixie | 1.1.0-2 | 취약 |
| brotli | trixie (security) | 1.1.0-2+deb13u1 | 수정 |
| brotli | forky | 1.2.0-4 | 취약 |
| brotli | sid | 1.2.0-4.1 | 수정 |
| nodejs | bookworm (security) | 18.20.4+dfsg-1~deb12u3 | 취약 |
| nodejs | trixie (security) | 20.19.2+dfsg-1+deb13u3 | 취약 |
| nodejs | forky | 24.21.0+dfsg+~cs24.13.4-1 | 취약 |
| nodejs | sid | 24.22.2+dfsg+~cs24.19.2-1 | 수정 |
| python-brotlicffi | trixie | 1.1.0.0+ds1-1 | 취약 |
| python-brotlicffi | forky, sid | 1.2.1.0+ds-1 | 수정 |
트래커의 주석은 nodejs와 python-brotlicffi가 시스템에서 제공하는 brotli 라이브러리를 쓴다고 적어 둡니다. 그래서 Debian에서 apt로 설치한 nodejs는 Node.js 프로젝트가 낸 22.23.4와 전혀 다른 방식으로 고쳐집니다. 이 표에서 눈에 띄는 것은 Debian의 nodejs 행이 18.20.4와 20.19.2라는 오래된 계열의 패키지라는 점입니다. Node.js 프로젝트의 공지가 영향 범위로 적은 26, 24, 22 계열 밖이지만, Debian 트래커는 이 패키지를 취약으로 표시합니다. 이유는 번들이 아니라 시스템 라이브러리 1.0.9와 1.1.0을 쓰기 때문이고, 이 버전들은 영향 범위(1.0.3부터 1.2.0까지)에 들어갑니다. 편집부 해설: “Node.js 22 이상인지”를 물은 점검표는 apt 설치 Node.js에는 적용되지 않습니다.
우리 컨테이너의 libbrotli1 1.0.9-2+b6을 이 표와 맞춰 보겠습니다. 편집부 실험: dpkg --compare-versions 1.0.9-2+b6 lt 1.0.9-2+deb12u1이 참을 돌려줘서, 이 컨테이너의 라이브러리는 트래커가 수정 버전으로 적은 1.0.9-2+deb12u1보다 낮은 번호였습니다. 또 이 라이브러리 파일(libbrotlidec.so.1.0.9)에서 FORMAT_BLOCK_SWITCH 문자열을 grep -c -a로 세면 0이었습니다. 편집부 해설: 이 컨테이너는 편집부 로컬에 있던 Debian 12 기반 이미지이고, 보안 업데이트가 반영되기 전의 패키지 상태가 그대로 남아 있었다는 뜻일 뿐 Debian이 늦었다는 뜻이 아닙니다. 이미지 안의 패키지는 이미지를 다시 만들거나 안에서 업데이트하기 전에는 그대로이므로, 이런 이미지는 호스트가 최신이어도 옛 상태일 수 있습니다. 이 값이 알려 주는 일반 원칙은 같은 1.0.9라는 번호가 수정 전과 수정 후 사이에서 배포판이 붙이는 꼬리표(+deb12u1)로만 구분된다는 것입니다. 파이썬이나 Node.js처럼 버전 번호 자체가 바뀌는 사본과 다르게, 배포판 사본은 상류 버전을 보지 말고 배포판 트래커의 “수정된 패키지 번호”와 비교해야 합니다.
업그레이드 목표 번호도 사본마다 다릅니다. google/brotli는 10월 21일 19시 26분 58초(1.0.10), 19시 27분 20초(1.1.1), 19시 27분 34초(1.2.1)에 세 릴리스를 36초 안에 연달아 게시했고, 세 릴리스의 노트는 같은 한 줄입니다. GitHub 권고도 사용자는 1.0.10 또는 1.1.1 또는 1.2.1로 올리라고 적습니다. 편집부 해설: 세 가지가 동시에 나온 이유는 1.0.x, 1.1.x, 1.2.x 계열을 각각 유지보수하고 있어서이고, 1.0.x 사본을 쓰는 곳이 큰 폭의 버전 이동 없이 같은 계열 안에서 수정을 받을 수 있게 한 것입니다. Debian이 bookworm에 1.0.9-2+deb12u1, trixie에 1.1.0-2+deb13u1을 낸 것도 같은 방식입니다(트래커는 이를 각각 DLA-4855-1, DSA-6582-1로 표시합니다). 그래서 “1.2.1이 아니니까 수정 안 됨”이라는 판정도 틀릴 수 있고, 계열마다 정해진 수정 번호와 맞춰 봐야 합니다.
판정: 배포 방식에 따라 갈린다. Node.js 공식 바이너리만 쓰는 서버에서는 맞고, apt로 설치한 nodejs, curl, git, 파이썬, .NET이 같이 있는 서버에서는 틀립니다.
사본 찾기 명령이 잡는 것과 놓치는 것
사본을 목록으로 만들라는 조언은 쉽지만, 어떤 명령이 어떤 사본을 보는지는 명령마다 다릅니다. 편집부가 같은 명령을 세 이미지에 돌린 결과입니다.
편집부 실험: 파일 이름에 brotli가 들어간 것을 찾는 find / -iname "*brotli*"는 공식 Node.js 22.23.4 이미지(Debian 계열 slim)에서 0건이었습니다. Alpine 기반 Node.js 이미지도 마찬가지였습니다. 그런데 이 이미지의 Node.js는 복호기를 가지고 있습니다. 복호기가 별도 파일이 아니라 node 실행 파일(약 1억 2천만 바이트) 안에 들어 있어서 파일 이름으로는 보이지 않을 뿐이고, 실행 파일 안에서 brotli 문자열을 센 값은 85번입니다. 반대로 Debian 12 기반 개발 이미지에서는 같은 find가 26건을 냈습니다. 시스템 라이브러리 파일(libbrotlidec, libbrotlienc, libbrotlicommon의 .so와 .a), 개발용 헤더, dpkg 목록 파일입니다. 이 26건에는 그 이미지 안에 따로 들어 있는 Node.js 22.13.1의 사본이 나타나지 않습니다. 같은 이미지에 Node.js가 있다는 것은 find / -name node로 따로 찾아야 알 수 있었습니다.
| 방법 | 잡는 사본 | 놓치는 사본 |
|---|---|---|
파일 이름 검색(find -iname) |
시스템 라이브러리, 패키지 목록 파일 | 실행 파일 안에 정적으로 든 사본(Node.js, 파이썬 확장 모듈) |
ldd 실행파일 | grep brotli |
동적으로 링크한 사본(curl, libcurl을 쓰는 git) | 정적으로 든 사본, 링크를 거치지 않는 dlopen 사용 |
dpkg -l·apk info의 패키지 목록 |
배포판이 관리하는 사본 | 패키지 관리자 밖에서 들어온 사본(공식 Node.js 바이너리, pip, 자체 포함 앱) |
pip show Brotli brotlicffi |
파이썬 패키지 | 다른 언어 사본 |
dotnet --list-runtimes |
호스트에 설치된 공유 런타임 | 자체 포함 앱과 컨테이너(Microsoft 공지가 따로 점검하라고 명시) |
실행 파일 안의 문자열 검색(grep -c -a) |
번들된 사본이 고쳐졌는지(오류 이름 문자열) | 문자열이 없는 사본이 모두 취약하다는 뜻은 아님(배포판이 다른 방식으로 고친 경우) |
편집부 해설: 이 표에서 한 가지 방법이 모든 사본을 보는 경우는 없습니다. 그래서 목록 작성은 “한 명령으로 전수 조사”가 아니라 “사본 종류마다 맞는 명령을 한 번씩”이어야 하고, 종류가 빠졌는지는 이 표의 행으로 점검할 수 있습니다. 마지막 줄 단서도 중요합니다. 문자열 검색은 고쳐진 쪽을 확실히 표시해 주는 방법이지, 문자열이 없다는 이유만으로 취약하다고 단정하는 방법이 아닙니다. 배포판이 상류의 커밋을 다른 형태로 옮겨 넣었을 수 있기 때문에, 배포판 사본은 마지막에 배포판 트래커의 패키지 번호와 맞춰 봐야 합니다.
주장 5: 들어오는 요청만 막으면 된다
방어를 말할 때 흔히 “외부에서 들어오는 요청”을 떠올립니다. 그러나 복호기는 압축된 바이트가 있고 누군가 그것을 푸는 코드를 부르면 어디서든 실행됩니다. 공식 문서가 밝힌 호출 방식을 방향별로 모아 봤습니다.
그림 5. 복호기에 닿는 세 길. 왼쪽 세 칸은 서비스 바깥의 바이트이고 오른쪽 붉은 칸은 사본이 어느 소프트웨어 안에 있든 같은 복호기입니다
첫째 길은 서비스가 받는 요청 본문입니다. Node.js의 http 서버는 요청 본문을 자동으로 풀지 않으므로, 응용이나 프레임워크가 Content-Encoding을 보고 zlib을 부를 때 이 길이 열립니다. ASP.NET Core는 요청 압축 해제 미들웨어가 이 일을 합니다. Microsoft 문서에 따르면 이 미들웨어는 Content-Encoding 헤더를 보고 맞는 해제 제공자로 요청 본문을 감싸며, 해제는 본문을 읽는 시점(모델 바인딩 때)에 일어나고 미리 일괄로 풀지 않습니다. 기본 제공자 목록에 br(Brotli)이 있고, 잘못된 데이터를 읽으면 Brotli는 InvalidOperationException: Decoder ran into invalid data를 던질 수 있다고 문서가 적습니다. 문서는 같은 맥락에서 두 가지를 더 적습니다. 요청에 Content-Encoding 헤더가 없으면 미들웨어는 요청을 건드리지 않고, 해제할 수 없는 요청(지원하지 않는 인코딩이나 헤더 값이 여럿인 경우)은 해제 없이 다음 단계로 넘깁니다. 편집부 해설: 이 설명대로라면 br 값이 하나만 적힌 요청이 미들웨어를 통과해 본문을 읽는 엔드포인트에서 복호기에 닿는 길이 됩니다. 본문을 읽지 않는 엔드포인트는 복호기를 부르지 않으므로, 이 길의 노출은 “미들웨어 등록 여부”와 “본문을 읽는 엔드포인트 존재 여부” 두 조건의 곱입니다. 미들웨어 등록(AddRequestDecompression, UseRequestDecompression)은 코드에서 직접 해야 하며, 앞서 본 Microsoft 공지도 요청 압축 해제와 자동 응답 해제가 기본으로 켜져 있지 않다고 적습니다.
Node.js 쪽에서 이 길이 어떻게 생기는지도 재 봤습니다. 편집부 실험: Node.js 22.23.4에서 로컬 HTTP 서버를 띄우고 Content-Encoding: br 헤더를 붙인 29바이트짜리 정상 Brotli 본문을 fetch로 POST했습니다. 서버의 http 모듈은 헤더 값 br과 29바이트를 그대로 받았고 풀어 주지 않았습니다. 따라서 Node.js 서버로 들어온 Brotli 요청 본문이 복호기에 닿으려면 응용이나 프레임워크가 zlib을 부르는 코드를 갖고 있어야 합니다. 그 코드가 있는지는 package.json의 의존성만 봐서는 알 수 없고, 사용 중인 프레임워크의 본문 파서 설정을 열어야 합니다.
둘째 길은 우리가 호출한 원격 서버의 응답입니다. Node.js 문서의 예제는 Accept-Encoding: br,gzip,deflate,zstd 헤더로 요청하고, 응답의 content-encoding이 br이면 응답 스트림을 zlib.createBrotliDecompress()로 이어 붙입니다. 편집부 실험: 로컬에서 Content-Encoding: br로 26바이트를 돌려주는 서버를 띄우고 세 가지 클라이언트로 불렀습니다. Node.js의 http.get은 26바이트를 그대로 받았고(풀지 않음), fetch()는 510바이트 텍스트로 자동으로 풀어서 줬으며, curl은 --compressed 옵션을 주면 510바이트로 풀고 주지 않으면 26바이트 그대로였습니다. 22.13.1과 22.23.4에서 결과가 같았습니다. 편집부 해설: 외부 API를 호출하는 코드가 fetch라면 응용 개발자가 압축을 푼다고 의식하지 않아도 복호기가 불립니다. HttpClient의 자동 응답 해제도 Microsoft 문서에 따르면 값이 None이 아니면 보내는 모든 요청에 Accept-Encoding 헤더를 붙입니다. 이 기능이 켜져 있으면 서버가 어떤 인코딩으로 답할지를 호출하는 쪽이 정하는 구조입니다. 같은 문서가 자동 해제는 가장 바깥쪽 압축 하나만 푼다고 밝혀서 gzip, br처럼 겹친 인코딩은 안쪽 gzip이 남습니다.
셋째 길은 도구와 스크립트입니다. 앞 절에서 보았듯 curl과 git이 시스템 libbrotli를 거치고, 파이썬 패키지 쪽에서는 의존성 선언이 길을 만듭니다. 편집부 실험이 아니라 공식 저장소 기록을 보면, urllib3의 pyproject.toml은 선택 의존성 brotli를 brotli>=1.2.0(CPython 기준)과 brotlicffi>=1.2.0.0(그 밖의 구현)로 선언합니다. 이 하한을 읽을 때 주의할 점이 있습니다. google/brotli 변경 기록에 따르면 1.2.0은 파이썬 Decompressor에 output_buffer_limit 인자와 can_accept_more_data 메서드를 더한 릴리스이고, 이 변경은 예상보다 큰 출력(압축 폭탄)을 막으려는 보안 항목이었습니다. urllib3 설정 파일에는 “Brotli >= 1.2.0 is required to prevent decompression bombs”라는 경고 필터도 있습니다. 편집부 해설: 이 하한은 압축 폭탄 때문에 정해진 숫자이므로 지금 이야기하는 결함과는 상관없이 놓였고, 그 하한 위쪽에 취약한 1.2.0 자신이 포함됩니다. 하한을 만족한다고 해서 안전한 것이 아닙니다.
판정: 틀리다. 들어오는 요청은 세 길 중 하나일 뿐입니다. 서비스가 호출하는 쪽(응답)과 도구 쪽도 같은 복호기에 닿습니다. 노출 여부를 판단할 때 묻는 질문은 “누가 압축된 바이트를 내게 줄 수 있는가”가 아니라 “내 쪽 코드가 압축된 바이트를 어디서 받아 푸는가”로 바꾸는 편이 맞습니다.
주장 6: 파일을 바꾸면 프로세스도 바뀐다
패키지를 올리고 “적용 완료”라고 보고하면 파일 시스템은 새 것입니다. 그러나 실행 중인 프로세스가 그 파일을 바라보고 있는지는 별개의 문제입니다. Microsoft의 공지가 프레임워크 의존 앱에 “새 런타임을 설치한 다음 앱을 다시 시작하라”고 덧붙이는 이유이기도 합니다.
편집부 실험: 재시작 문제를 안전하게 재현했습니다. Node.js 22.13.1 바이너리를 /tmp/nodeA로 복사해 반복 출력 스크립트를 실행하고, 실행 중에 같은 경로(/tmp/nodeA)를 22.23.4 바이너리로 mv(이름 바꾸기, 패키지 관리자가 하는 방식)로 교체했습니다. 교체 직후 실행 중인 프로세스의 /proc/PID/exe 링크는 /tmp/nodeA (deleted)로 나왔고, 3초 뒤에도 그 프로세스는 v22.13.1, 오류 상수 undefined를 출력했습니다. 같은 경로로 새로 띄운 프로세스는 v22.23.4, -17이었습니다. 파일은 이미 새것이었지만 돌고 있던 프로세스는 옛 바이너리를 계속 쓴 것입니다.
같은 실험에서 시간도 재 봤습니다. 프로세스 시작은 08시 14분 28초(UTC)였고 교체된 파일의 수정 시각은 08시 14분 30초였습니다. 파일 시각이 프로세스 시작보다 늦다는 것이 “이 프로세스는 교체 전에 시작되었다”는 기록이 됩니다. 다만 파일 수정 시각은 설치 도구가 시각을 보존해 복사하는지에 따라 달라지므로, 편집부 해설: 이 시각 비교는 보조 증거로만 쓰고 (deleted) 표시나 서비스 재시작 기록을 주 증거로 삼는 편이 낫습니다.
이 실험은 점검에도 쓸 수 있는 신호를 하나 줍니다. 파일이 교체된 뒤에도 옛 코드로 도는 프로세스는 /proc/PID/exe가 가리키는 대상 끝에 (deleted)가 붙어 보입니다. 편집부 해설: 패키지 업그레이드 뒤에 ls -l /proc/*/exe | grep deleted를 돌려 Node.js, curl, 서비스 프로세스가 목록에 남아 있는지 보면 재시작이 필요한 프로세스를 찾을 수 있습니다. 컨테이너 안과 밖은 프로세스 공간이 다르므로 컨테이너 안에서 돌려야 하고, 단순히 이름이 바뀐 파일은 이 방법에 걸리지 않습니다. 이 신호가 없다고 해서 새것이라는 증명은 아니고, 있으면 옛것이라는 증명입니다.
편집부 해설: 이 실험은 리눅스에서 파일을 이름 바꾸기로 교체하면 이전 파일이 열려 있는 동안 남아 있다는 일반 동작에 기댑니다. 따라서 점검은 파일 시스템의 버전이 아니라 실행 중 프로세스에게 물어야 합니다. 컨테이너에서는 docker exec 컨테이너이름 node -v가 컨테이너 안의 새 프로세스를 띄우는 명령일 뿐이므로, 서비스 본체 프로세스가 아닌 다른 프로세스를 확인하는 셈이 될 수 있습니다. 서비스를 재시작하거나 새 이미지로 교체하고, 그 이미지에서 서비스가 올라온 뒤에 확인해야 의미가 있습니다. 컨테이너는 한 걸음 더 갑니다. 호스트에 새 Node.js를 설치해도 컨테이너 이미지는 이미지 안의 Node.js를 계속 씁니다. Microsoft 공지가 자체 포함 앱과 컨테이너를 따로 점검하라고 적은 이유와 같습니다. dotnet --list-runtimes는 호스트에 설치된 런타임만 보여 주기 때문입니다.
판정: 틀리다. 파일이나 패키지의 버전은 새것이어도, 서비스 프로세스가 새 바이너리에서 시작되었다는 증거가 따로 있어야 합니다.
주장 7: 악용 확인이면 우리도 뚫렸다
마지막 주장은 방향이 반대입니다. 위의 여섯 개가 “올렸으니 안전하다”의 근거를 흔드는 반면, 이 주장은 “악용이 확인됐다니 이미 늦었다”는 불안을 점검합니다. 이 주장이 기대는 공식 기록은 세 개입니다.
악용 확인 목록(KEV)의 항목은 10월 27일에 추가되었고 조치 기한은 11월 10일입니다. 항목은 결함을 “Google Brotli 반환값 미검사 취약점”으로 부르고, 원격의 인증 없는 공격자가 조작된 Brotli 스트림으로 인접한 복호기 상태 메모리를 손상시키거나 메모리 내용을 노출하거나 서비스 거부를 일으키거나 코드를 실행할 수 있다고 적습니다. 관련 CWE는 252와 787이고, 랜섬웨어 캠페인에서 쓰였는지는 “Unknown”입니다. 비고에는 여러 제품이 쓸 수 있는 오픈소스 구성 요소의 취약점이라는 설명과 구글의 1.2.1 릴리스 링크가 있습니다. 같은 항목은 공식 기록이 악용의 내용(누가, 어디서, 어떤 구성을)을 담고 있지 않다는 점도 보여 줍니다. 편집부 해설: 이 목록에 오른다는 것은 악용 증거가 있다는 뜻이지만, 그 증거는 항목에 공개되지 않으므로 이 글은 악용 경로나 피해 규모를 쓰지 않습니다.
GitHub 권고(PyPI의 brotli, brotlicffi)는 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으로 공개합니다. 읽는 법은 이렇습니다. 공격 경로가 네트워크(AV:N)이고, 복잡도가 낮고(AC:L), 권한과 사용자 개입이 필요 없으며(PR:N, UI:N), 취약한 시스템 자신의 기밀성·무결성·가용성 영향이 모두 높음(VC:H, VI:H, VA:H)입니다. 눈에 띄는 칸은 AT:P로, 공격 요구 조건이 “있음”입니다. 편집부 해설: CVSS 4.0에서 이 칸은 공격이 성립하려면 대상에 특정 배치나 조건이 갖춰져 있어야 한다는 표시입니다. 이 글의 주장 5와 직접 이어집니다. 복호기가 불려야 한다는 조건이 바로 그것입니다. Node.js 공지의 High와 이 9.2 Critical이 서로 다른 것은 척도와 평가 기관이 다르기 때문이지 한쪽이 틀렸기 때문이 아닙니다.
정리하면 이 기록들이 각각 무엇을 말하고 무엇을 말하지 않는지는 아래와 같습니다.
| 기록 | 말하는 것 | 말하지 않는 것 |
|---|---|---|
| 악용 확인 목록(KEV) 등재 | 악용 증거가 존재함, 조치 기한 11월 10일 | 악용 경로, 대상 구성, 피해 규모, 랜섬웨어 사용(항목에 Unknown) |
| GitHub 권고 9.2 (CVSS 4.0) | 원격·무권한·무개입으로 성립할 수 있는 영향의 크기, 공격 요구 조건 있음 | 내 서비스에 복호기를 부르는 경로가 있는지 |
| Node.js 공지 High | node:zlib으로 푸는 Brotli 데이터를 공격자가 줄 수 있을 때의 영향 |
줄 수 있는지 여부 |
| 구글 1.2.1 릴리스, 변경 기록 | 고친 버전과 한 줄 설명 | 어느 소프트웨어가 어느 번호에 대응하는지 |
세 번째로 앞서 본 Debian 트래커의 설명도 같은 방향입니다. 취약한 쪽은 라이브러리 1.0.3부터 1.2.0까지이고, 사용자는 1.0.10, 1.1.1, 1.2.1 중 맞는 가지로 올려야 한다고 적습니다. 이 범위 안에 있다는 사실과, 그 라이브러리를 부르는 코드가 있다는 사실은 별개의 두 조건입니다.
판정: 이 기록들만으로는 말할 수 없다. 악용 확인은 “어딘가에서 쓰였다”를 말해 줄 뿐 “내 서비스가 뚫렸다”를 말해 주지 않고, 점수가 높다는 사실도 그렇습니다. 내 서버가 뚫렸는지는 충돌 기록, 프로세스 동작, 로그 같은 내 쪽 증거로 봐야 합니다. 같은 이유로 한 공공기관의 경보(AL26-027)가 권고하는 것은 취약한 구성 요소를 쓰는 인터넷 노출 시스템부터 고치고, 바로 못 고치면 신뢰할 수 없는 출처의 Brotli 콘텐츠를 영향받는 응용이 처리하지 못하게 막고, 비정상 충돌이나 낯선 프로세스 행동을 지켜보라는 것입니다. 해제 도중 프로세스가 죽었다는 사실만으로 공격이라고 단정할 수도 없으므로 런타임 버전, 응용 로그, 압축 데이터의 출처를 같이 봐야 합니다.
임시 완화는 업데이트를 대신하지 못합니다. 올릴 수 없는 서비스가 쓸 수 있는 편집부 해설 세 가지입니다. 클라이언트가 Brotli 요청 본문을 보내지 않는 서비스는 프록시에서 요청의 Content-Encoding: br을 거부할 수 있습니다(응답 쪽 길은 못 막습니다). 외부 서버를 호출하는 코드에서 Accept-Encoding을 직접 정해 br을 빼 두면 응답 쪽 길을 줄일 수 있습니다. 서비스를 권한 낮은 계정으로 돌리면 메모리가 손상되더라도 입는 피해의 범위가 좁아집니다. 모두 결함 자체를 없애지는 못합니다.
다음에 확인할 것 네 가지
일곱 주장을 가로질러 남는 점검은 네 가지로 줄어듭니다. 앞의 절마다 이유를 달아 두었으니 여기서는 항목만 적습니다.
- 서버마다 Brotli 사본의 종류를 목록으로 적었다(공식 Node.js 바이너리, apt 설치 nodejs, 시스템 libbrotli를 쓰는 curl·git, 파이썬 휠, .NET 공유·자체 포함, 컨테이너 이미지)
- 사본마다 그 종류에 맞는 값으로 확인했다(Node.js는
zlib.constants에 새 블록 전환 오류 상수가 있고 값이 -17인지, 파이썬은 패키지 버전, 배포판 사본은 배포판 트래커의 수정 패키지 번호) - 서비스 프로세스가 새 바이너리나 새 이미지에서 다시 시작되었고, 실행 중인 그 컨테이너 안에서 같은 값을 다시 읽었다
- 복호기가 불리는 코드(요청 본문 해제,
fetch·HTTP 클라이언트의 자동 해제,BrotliStream직접 사용)를 찾아 노출 길을 적었고, 올리지 못한 곳에는 올릴 날짜를 붙였다
출처
- Node.js 2026-10-21 보안 릴리스 공지 nodejs.org
- Node.js v22.23.4 릴리스 노트 github.com
- .NET 10월 긴급 보안 업데이트 (Microsoft .NET 블로그) devblogs.microsoft.com
- GitHub 보안 권고 GHSA-9v8h-qqgm-4m35 (PyPI brotli·brotlicffi) github.com
- google/brotli v1.2.1 릴리스 github.com
- google/brotli 수정 커밋 0ed4268 github.com
- google/brotli v1.2.1 decode.h (복호기 API 문서 주석) github.com
- google/brotli CHANGELOG github.com
- RFC 7932 Brotli 압축 데이터 형식 rfc-editor.org
- Node.js zlib 문서 nodejs.org
- Debian 보안 트래커 CVE-2026-111497 security-tracker.debian.org
- ASP.NET Core 요청 압축 해제 미들웨어 문서 learn.microsoft.com
- HttpClientHandler.AutomaticDecompression 문서 learn.microsoft.com
- urllib3 pyproject.toml github.com
- 사이버 보안 센터 경보 AL26-027 cyber.gc.ca
- 악용 확인 취약점(KEV) 카탈로그 cisa.gov