Node.js 취약점 10건을 고쳤다, 4건이 High라 지금 올려야 한다
Node.js 22.23.4·24.22.1·26.12.1이 취약점 10건(High 4건)을 고쳤습니다. 계열마다 걸린 건수와 수정 뒤 달라진 동작, 서버를 올려도 남는 사본까지 정리했습니다.
그림 1. 수정판 22.23.4에서 편집부가 직접 돌려 본 출력(TLS 이름 검사 4건, dns.resolve4, zlib 상수)
위 출력이 이번 Node.js 보안 릴리스에서 달라진 동작의 한 단면입니다. 10월 21일(이 글의 날짜는 UTC 기준) 프로젝트가 취약점 10건을 한꺼번에 고쳤고, 수정 버전은 22.23.4, 24.22.1, 26.12.1입니다. 수정 전에는 위 TLS 검사들이 통과했고(전각 표기는 24.x·26.x), \0이 든 DNS 이름은 앞부분만 조회됐습니다. 10월 27일 현재 이 세 버전보다 새로운 릴리스는 없고, 공지에도 발행 뒤 덧붙은 갱신 내용이 없습니다. 이 글은 변경을 영역별로 나눠 수정 전과 후를 대응시키고, 10건이 계열마다 어떻게 갈리는지, 그리고 서버를 올려도 남는 사본을 어떻게 찾는지까지 따라갑니다.
수정 버전 셋과 계열의 현재 단계
세 버전은 같은 날 같은 공지에 묶여 나왔지만 놓인 단계가 다릅니다. 아래 표의 단계는 Node.js 릴리스 일정표의 날짜를 10월 27일 기준으로 읽은 것입니다.
| 계열 | 수정 버전 | 단계 | 이번에 걸린 결함 |
|---|---|---|---|
| 22.x | 22.23.4 | 유지보수 LTS(2025-10-21부터) | 7건, High 3 |
| 24.x | 24.22.1 | 유지보수 LTS(2026-10-20부터) | 8건, High 4 |
| 26.x | 26.12.1 | Current, LTS 전환은 10월 28일 예정 | 10건, High 4 |
| 20 이하, 21·23·25 | 없음 | 수명 종료 | 공지 기준으로 늘 영향권 |
24.x는 며칠 전에 LTS의 활성 단계를 끝내고 유지보수로 넘어갔습니다. 26.x는 아직 Current이지만 LTS 전환이 하루 뒤로 잡혀 있어서, 장기 지원 계열을 새로 고르는 팀에게는 곧 선택지가 됩니다. 공지는 수명이 끝난 버전이 보안 릴리스가 나올 때마다 언제나 영향받는다고 적고 지원되는 계열을 쓰라고 안내합니다.
각 계열이 언제까지 보안 수정을 받는지는 달력 위에 놓으면 한눈에 보입니다. 아래 그림에서 빨간 세로선이 보안 릴리스 날, 점선이 오늘입니다.
그림 2. 릴리스 일정표의 단계 구간. 20.x와 25.x는 이미 끝났고, 22.x가 다음 종료(2027년 4월)
20.x와 25.x는 오늘 선보다 왼쪽에서 끝난 막대라서 이번 수정을 받을 길이 없습니다. 이번 패치를 받는 계열 중 가장 먼저 끝나는 것은 22.x이고, 종료까지 6개월가량이 남았습니다. 22.x를 쓰는 팀은 이번 업데이트를 하면서 다음 계열로 옮길 일정을 같이 잡는 편이 낫습니다. 일정표에는 날짜가 바뀔 수 있다는 단서가 붙어 있습니다.
열 건 가운데 계열마다 걸린 것
10건은 같은 무게로 모든 계열에 걸리지 않습니다. 공지의 영향 계열 열을 세어 격자로 옮기면 어디가 비는지 보입니다.
그림 3. 심각도가 진할수록 위험. 점선 칸은 그 계열에 해당하지 않는 결함
26.x는 10건 전부, 24.x는 8건, 22.x는 7건입니다. 빈 칸은 세 곳입니다. 전각 숫자 TLS 건은 22.x에 없고, ZIP 헤더 캐시 건과 권한 모델 와일드카드 건은 26.x에만 있습니다. High만 세면 22.x는 3건, 24.x와 26.x는 4건입니다. 건수가 적은 22.x가 안전하다는 뜻은 아닙니다. Brotli, DNS 이름, 앞자리 0 TLS 세 건이 22.x에도 그대로 걸려 있고 모두 High입니다.
격자에서 또 하나 읽히는 것은 10건 중 4건이 이전 수정의 후속이라는 점입니다. DNS 이름 건(CVE-2026-105482)은 dns.lookup()과 net·tls의 호스트 옵션만 막았던 CVE-2026-48930이 dns.resolve*()를 빠뜨려 남았고, 전각 숫자 건(CVE-2026-105483)은 CVE-2026-48618을 고친 뒤에 생긴 재발입니다. keep-alive 소켓 건(CVE-2026-105481)과 fs.realpathSync() 건(CVE-2026-106073)도 각각 CVE-2026-48931과 CVE-2026-21715가 일부만 막은 자리입니다.
숫자처럼 보이는 호스트를 먼저 가려내는 TLS
High 세 건 가운데 둘과 Medium 한 건이 TLS 호스트 이름 검증에서 나왔습니다. tls.checkServerIdentity()가 “이 문자열이 IP인가”를 가리는 부분이 세 가지 방식으로 틀어졌고, 수정은 한 방향입니다. 비표준 표기는 IP로 인정하지 않고 거부합니다.
- 앞자리 0(CVE-2026-106069).
0127.0.0.1을 웹 표준(WHATWG URL) 규칙은 8진수로 읽어87.0.0.1로 바꿉니다. 실제 접속은 운영체제 해석기를 거치는데 macOS 같은 환경은 같은 문자열을 10진수127.0.0.1로 읽습니다. 검사한 주소와 접속한 주소가 달라져서, 접속한 서버가 다른 IP용으로 발급된 인증서로 인증될 수 있었습니다. - 전각 숫자(CVE-2026-105483).
127.0.0.1같은 호스트를 IP로 알아보지 못하고 인증서의 DNS 이름·Common Name과 비교했습니다. 24.x와 26.x에서만 나타납니다. - 숫자만 다른 표기(CVE-2026-106072).
0177.0.0.1,0x7f.0.0.1,2130706433,127.1을 IP로 분류하지 않아서, 인증서에 숫자로만 된 DNS 항목(DNS:127.0.0.1)이 있으면 통과됐습니다. 성립 조건은 클라이언트가 신뢰하는 인증기관이 그런 인증서를 발급하는 것과, glibc처럼 그 표기를 받아 주는 해석기입니다.
입력별로 수정 전후를 대응시키면 다음과 같습니다. 수정 전 열은 공지의 서술이고, 수정 후 열은 그림 1의 22.23.4 실행 결과입니다.
| 호스트로 쓴 문자열 | 인증서 | 수정 전 | 수정 후 |
|---|---|---|---|
0127.0.0.1 |
DNS:87.0.0.1 |
8진수로 읽어 일치로 처리 | 거부(Host is a non-canonical IP address) |
127.1, 0x7f.0.0.1, 2130706433 |
DNS:127.0.0.1 |
숫자 DNS 항목에 통과 | 거부(같은 메시지) |
127.0.0.1(전각) |
DNS:127.0.0.1 |
DNS 이름처럼 비교해 통과 | 거부(같은 메시지) |
127.0.0.1(정상 표기) |
DNS:127.0.0.1만 있음 |
거부 | 그대로 거부(IP 접속은 IP 항목이 있어야 함) |
마지막 줄이 바뀌지 않은 쪽의 기준선입니다. 정상 표기는 처음부터 DNS 항목만으로는 통과하지 못했고, 비표준 표기만 그 틈을 탔습니다. 그래서 수정의 효과는 “엄격해졌다”보다 “표기를 바꿔 우회하던 길이 정상 표기와 같은 결과로 합쳐졌다”에 가깝습니다. 22.23.4 릴리스 노트에는 IPv6 호스트를 인증서의 IP 항목과 맞추는 tls: match IPv6 hosts against IP-Address SANs 커밋도 올라 있습니다. 22.x에서 IPv6 접속의 인증서 검증 오류를 겪었다면 같이 볼 항목입니다.
dns.resolve가 NUL이 든 이름을 받지 않는다
DNS 건(CVE-2026-105482)은 검사하는 코드와 조회하는 코드가 같은 문자열을 서로 다른 길이로 읽은 경우입니다. dns.resolve*()와 dns.reverse()가 이름을 C 라이브러리 c-ares에 C 문자열로 넘기는데, 이름 중간에 NUL 문자가 있으면 거기서 문자열이 끝난 것으로 읽습니다.
// 허용 목록 검사는 문자열 전체로, 실제 조회는 NUL 앞까지만
if (!name.endsWith('.trusted.example')) throw new Error('blocked');
dns.resolve4(name, cb);
// name = 'attacker.example\0.trusted.example' 이면
// 검사는 통과하고, 조회는 attacker.example 의 레코드를 가져온다
수정 뒤에는 입력 단계에서 막힙니다. 그림 1에 보였듯 22.23.4의 dns.resolve4('example.com\0.trusted.example', cb)는 ERR_INVALID_ARG_VALUE로 끝나고 이름이 c-ares까지 가지 않습니다. 앞선 수정(48930)이 dns.lookup()과 net·tls의 호스트 옵션에만 NUL 검증을 넣었다가 resolve* 계열을 놓친 것이라, 이번에야 DNS 모듈 전체가 같은 규칙으로 정리됐습니다.
코드 쪽에서 할 일은 한 가지입니다. 검사한 값과 조회에 쓰는 값을 같은 변수로 만드세요. 사용자가 준 이름을 정규화하고, 그 결과를 검사하고, 같은 결과를 넘기면 이런 틈은 라이브러리가 고쳐지기 전에도 생기지 않습니다.
Brotli는 틀린 블록 전환을 오류로 말하게 되었다
가장 무거운 건은 zlib 모듈에 번들된 Brotli 디코더(CVE-2026-111497)입니다. 공지는 형식이 틀린 데이터를 node:zlib로 풀면 프로세스 메모리가 망가져 충돌, 메모리 내용 노출, 원격 코드 실행까지 갈 수 있다고 적었습니다. 보고한 곳은 OpenAI의 보안 점검 도구 팀(Codex Security)이고, Node.js의 수정은 Brotli 원 프로젝트의 수정(커밋 0ed4268a)을 가져온 것입니다.
작동은 이렇게 갈렸습니다. 압축 해제기는 입력을 조각으로 받아 가며 상태를 이어 가는 스트리밍 방식이라, 디코더는 매번 “이 입력은 형식이 틀렸다”는 영구 오류와 “아직 다 안 왔으니 더 달라”는 일시 신호를 구분해야 합니다. 잘못된 블록 전환이 후자로 보고됐고, 디코더가 8바이트짜리 내부 버퍼에 다음 조각을 계속 덧붙였습니다. 수정 뒤에는 이 경우를 일찍 형식 오류로 끝내며, 새 오류 상수 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH가 zlib.constants에 노출됩니다. 그림 1의 마지막 줄이 22.23.4에서 그 상수가 보이는 모습입니다.
영향 범위는 입력의 출처가 정합니다. 공지의 조건은 공격자가 보낸 Brotli 데이터를 node:zlib로 푸는 응용 프로그램입니다. Node.js 문서는 zlib.brotliDecompress(), zlib.brotliDecompressSync(), zlib.createBrotliDecompress() 세 경로를 안내하고, HTTP 클라이언트 예제도 Content-Encoding: br 응답을 createBrotliDecompress()로 풉니다. 업로드 처리, 프록시, 외부 응답을 받아 푸는 작업처럼 바깥에서 온 압축 데이터를 직접 푸는 구조가 가장 먼저 볼 대상입니다. 내부에서 만든 데이터만 푸는 배치라면 급한 정도는 낮아지지만, 올려 둘 수 있는 버전이 있으니 올립니다.
이 결함은 Node.js 자체 코드가 아니라 번들된 Brotli 라이브러리의 것입니다. 한 취약점 데이터베이스(패치스택)는 시스템에 설치된 Brotli 라이브러리를 연결해 Node.js를 직접 빌드했을 때 그 라이브러리가 상류 수정 이전 버전이면 영향이 있다고 설명합니다. 배포판 패키지나 직접 빌드한 Node.js를 쓴다면 Brotli 패키지도 갱신 대상인지 확인합니다.
조건이 붙은 다섯 건: keep-alive, 난수 캐시, ZIP, 권한 모델 둘
나머지 다섯 건은 Medium 둘과 Low 셋이고, 모두 걸리는 조건이 좁습니다.
keep-alive 소켓(CVE-2026-105481, Medium). HTTP 클라이언트는 연결을 재사용하려고 유휴 소켓을 쥐고 있고, 이 소켓에 뜻밖의 데이터가 오면 소켓을 부수는 보호 장치가 있습니다. 이 장치가 이벤트 루프가 입출력을 조사할 때만 돌았습니다. 루프가 동기 작업으로 바쁜 사이 요청 전에 도착한 응답이 읽히지 않은 채 쌓여 있다가 소켓이 새 요청에 배정되면, 보호 장치가 지나가고 그 데이터가 새 요청의 응답으로 해석됩니다. 수정 커밋 제목은 “입출력 없이도 에이전트 데이터 보호가 동작하게 한다”입니다.
난수 캐시(CVE-2026-106074, Medium). crypto.randomUUID(), randomUUIDv7(), randomInt()는 속도를 위해 난수를 미리 만들어 캐시합니다. 시작 스냅샷(--build-snapshot)이나 useSnapshot을 켠 단일 실행 파일을 빌드하는 동안 이 함수들(또는 이를 쓰는 URL.createObjectURL())을 부르면 남은 캐시가 스냅샷에 저장되고, 그 결과물에서 시작한 모든 프로세스가 캐시가 비워질 때까지 같은 값을 냅니다. 공지가 적은 크기는 UUID 최대 127개, randomInt() 값 약 1,000개입니다. 값이 겹치고, 한 프로세스를 관찰하면 다른 프로세스의 값을 맞힐 수 있다는 뜻입니다. 스냅샷 없이 평소처럼 시작하는 서버에는 해당하지 않습니다.
ZIP 읽기(CVE-2026-106070, Low, 26.x). 로컬 헤더가 파일 끝의 중앙 디렉터리와 맞지 않는 압축 파일에서 zipEntry.modified를 읽으면 검증 오류는 잡히지만 파싱된 헤더가 캐시에 남습니다. 뒤이은 내용 읽기는 거부되지 않고 파일 앞부분의 바이트를 돌려주며, CRC-32는 공격자가 중앙 디렉터리의 CRC를 정했기 때문에 통과합니다. 공지는 Node.js 위에 만든 검사기가 어떤 내용을 승인했는데 다른 ZIP 구현이 다르게 푸는 상황을 예로 듭니다. 26.12.1 릴리스 노트에서는 zlib: avoid caching invalid ZIP local headers로 올라 있습니다.
권한 모델 둘(Low). 권한 모델은 node --permission에 --allow-fs-read·--allow-fs-write로 허용 경로를 주는 기능입니다. 와일드카드 건(CVE-2026-106071, 26.x)은 요청 경로가 와일드카드 허용의 접두사보다 짧으면 일치로 처리한 문제입니다. 공지의 예로 --allow-fs-read=/etc/passwd.*가 /etc/passwd 읽기까지 허용했고, 와일드카드 허용이 권한 트리 최상단에 있을 때만 성립합니다. fs.realpathSync() 건(CVE-2026-106073)은 앞선 수정이 fs.realpathSync.native()만 고친 탓에, --permission으로 읽기 범위를 제한해도 허용 밖 경로의 존재 확인, 심볼릭 링크 대상 확인, 경로 나열이 가능했습니다.
권한 모델을 믿고 있었더라도 이번 Low 두 건 때문에 방어선이 무너진 것은 아닙니다. Node.js 문서는 이 기능을 신뢰하는 코드가 허용받지 않은 자원을 실수로 건드리지 않게 하는 “안전벨트”로 설명하고, 악성 코드가 있을 때의 보안 보장은 제공하지 않는다고 적습니다. 심볼릭 링크는 따라가고, 이미 열린 파일 기술자는 우회한다는 한계도 문서에 있습니다. 바깥에서 온 코드를 가두려면 별도 사용자, 컨테이너, 운영체제 수준의 격리가 맞습니다.
이번에도 바뀌지 않은 것
변화 기록에서 못지않게 중요한 것은 그대로인 쪽입니다.
- 우회 방법. 공지는 결함별 우회 방법을 하나도 적지 않았고 안내는 수정 버전으로의 업데이트뿐입니다. 입력 형식을 제한하거나 접속 대상을 코드에 고정하는 식의 노출 줄이기는 업데이트까지 시간을 버는 일반적인 방법일 뿐입니다.
- 수명 종료 계열. 위 일정표대로 20 이하와 25는 수정판이 없습니다. 이번 결함이 해당하는지 따지기보다 지원되는 계열로 옮기는 계획이 먼저입니다.
- 정상 표기의 거부. 표 속 마지막 줄처럼
127.0.0.1에 DNS 항목만 있는 인증서는 수정 전에도 후에도 거부됩니다. - 권한 모델의 성격. 위 안전벨트 설명 그대로입니다. 이번에 틈 두 곳이 막혔을 뿐 샌드박스가 된 것은 아닙니다.
- 사본의 버전. 컨테이너 이미지, 단일 실행 파일, 시작 스냅샷에 들어 있는 Node.js는 서버를 올려도 바뀌지 않습니다. 아래에서 따로 다룹니다.
- npm으로 설치한 undici.
package.json에 직접 넣은undici는 Node.js가 내장한 것과 별개의 패키지라서, Node.js를 올려도 그대로입니다. 내장fetch만 쓴다면 Node.js 업데이트로 충분합니다.
외부 입력이 닿는 서비스부터
10건이 모두 같은 무게는 아닙니다. 편집부가 보기에 점검 순서를 가르는 것은 공격자가 값을 정할 수 있는 입력을 받는지와 걸렸을 때 피해가 얼마나 큰지입니다. 아래 세 상황은 편집부가 설정과 구조만으로 만든 예시이고, 공격 절차는 다루지 않습니다.
업로드 파일이나 외부 API 응답을 서버에서 푸는 서비스. Content-Encoding: br 본문이나 Brotli로 압축된 첨부를 node:zlib로 풀고 있다면 이번에 가장 먼저 올릴 서비스입니다. 인터넷에 열려 있고 입력이 인증 없이 들어온다면 더 급합니다. 자기가 만든 파일만 푸는 내부 배치는 정기 업데이트 주기로 올려도 무리가 없습니다.
도메인 허용 목록을 두고 사용자가 준 주소로 접속하는 웹훅·링크 미리보기. 허용 여부는 문자열로 가리고 실제 연결은 fetch·https·tls나 DNS 조회가 합니다. 위의 TLS와 DNS 건은 모두 “검사한 문자열과 쓰는 대상이 다르다”는 같은 뿌리입니다. 접속 대상을 코드에서 고정할 수 있으면 고정하고, 사용자 입력을 받는다면 정규화한 뒤 IP인지 이름인지 먼저 가린 다음 허용 여부를 정하세요. 이미 비표준 표기로 접속하던 코드는 올린 뒤 오류가 날 수 있습니다.
단일 실행 파일로 배포하는 명령행 도구. useSnapshot으로 시작을 빠르게 만든 도구가 빌드 중에 randomUUID()를 불렀다면 그 결과물에서 시작한 모든 프로세스가 처음 몇 개의 값을 똑같이 냅니다. 세션 식별자나 요청 번호에 쓰면 사용자 사이에서 겹치거나 예측될 수 있습니다. 새 Node.js로 다시 빌드하는 것이 먼저이고, 기존 결과물이 만든 값을 토큰이나 임시 키로 썼다면 교체도 검토하세요. 교체 권고는 편집부의 판단이고 Node.js 프로젝트의 지침이 아닙니다.
사본까지 올리는 순서
Node.js 업데이트가 번거로운 이유는 한 조직에 Node.js가 한 벌만 있지 않기 때문입니다. 아래 그림은 내 계열을 확인하는 지점에서 출발해 어디까지 다시 만들어야 끝나는지를 그립니다.
그림 4. 수정 버전이 있는 계열과 없는 계열, 서버를 올려도 따라오지 않는 사본, 마지막 확인
수정 버전이 없는 계열은 위쪽 길로 가지 못하고 옮기는 일이 먼저입니다. 오른쪽의 노란 상자는 서버 업데이트가 닿지 않는 것들입니다. 컨테이너 이미지는 안에 Node.js가 들어 있어 호스트를 올려도 그대로이므로 새 기반 이미지를 받아 다시 빌드·배포합니다. 단일 실행 파일은 Node.js 문서에 따르면 node 바이너리 사본에 앱 데이터를 주입해 만들기 때문에, 만든 시점의 Node.js가 그대로 박혀 있습니다. 서버를 올렸어도 프로세스를 재시작하지 않으면 메모리에는 옛 버전이 떠 있습니다.
서버리스는 AWS Lambda 공식 문서의 구분이 가장 분명합니다. Lambda는 함수 버전마다 런타임 갱신 방식을 고르게 하고, 기본값인 Auto는 최신 보안 런타임으로 두 단계에 걸쳐 자동 갱신합니다. Function update는 함수를 만들거나 갱신할 때만 최신으로 올라가서 개발이 멈춘 함수는 직접 갱신해야 합니다. Manual은 런타임 버전을 직접 지정해 계속 쓰는 방식이고 롤백용 임시 수단이라 기본으로 두지 말라고 안내합니다. 컨테이너 이미지로 만든 함수는 갱신 대상이 아니라서 새 기반 이미지로 이미지를 다시 만들어 재배포해야 합니다. 같은 런타임 버전도 리전마다 퍼지는 시점이 다를 수 있으니 다른 업체 서버리스는 해당 업체의 공지를 확인합니다.
먼저 지금 쓰는 버전을 봅니다. 모두 읽기 전용 명령입니다.
# 이 PC·서버의 Node.js 버전과 번들 부품 버전
node -p "[process.version, 'openssl ' + process.versions.openssl, 'c-ares ' + process.versions.ares, 'undici ' + process.versions.undici].join('\n')"
# 컨테이너: 로컬에 오래된 이미지가 남아 있을 수 있으니 먼저 받아 온 뒤 확인 (이미지 이름은 바꿔서)
docker pull node:22
docker run --rm node:22 node --version
docker pull을 먼저 하는 이유는 로컬에 같은 태그가 이미 있으면 docker run이 새로 받지 않고 옛 이미지를 쓰기 때문입니다. 내 코드가 이번 결함과 닿는지는 저장소를 훑어서 대략 가려집니다. 소스 위치(src)는 프로젝트에 맞게 바꾸세요.
# Brotli 해제를 직접 쓰는 곳
grep -rnE "brotliDecompress|createBrotliDecompress" --include=*.js --include=*.mjs --include=*.ts src
# DNS 이름을 조회하거나 TLS 호스트 이름을 직접 검사하는 곳
grep -rnE "dns\.(promises\.)?(resolve|reverse)|checkServerIdentity" --include=*.js --include=*.mjs --include=*.ts src
# 스냅샷·단일 실행 파일·권한 모델을 쓰는 곳
grep -rnE "build-snapshot|useSnapshot|--permission|allow-fs" . --exclude-dir=node_modules --exclude-dir=.git
찾은 곳이 없어도 직접 경로가 없다는 뜻일 뿐입니다. 의존하는 npm 패키지가 안쪽에서 node:zlib이나 dns를 쓰는 경우는 이 검색에 잡히지 않으니 Node.js 업데이트는 그래도 합니다.
- 모든 서버·CI·개발 PC에서
node -v가 22.23.4 · 24.22.1 · 26.12.1 이상인지 확인 - 컨테이너는 최신 기반 이미지로 다시 빌드한 뒤 실행 중인 컨테이너 안에서 버전을 확인
- 단일 실행 파일과 시작 스냅샷은 새 Node.js로 다시 만들고, 이전 결과물이 만든 난수 값을 토큰에 썼다면 교체 검토
- 서버리스 함수의 런타임 갱신 방식(Auto, Function update, Manual)을 확인
- 외부 Brotli 입력과 사용자 입력 이름·주소를 다루는 서비스를 먼저 배포
- 프로세스 재시작, 그리고
package.json에 직접 넣은undici는 별도로 갱신 - 20 이하 같은 수명 종료 계열은 이전 일정 확정
같이 갱신된 부품과 계열마다 다른 것
Node.js 릴리스는 자체 코드 외에 번들 부품도 함께 올립니다. 공지의 의존성 목록과 계열별 릴리스 노트를 맞춰 보면 부품 버전이 계열마다 갈립니다. 표의 -는 해당 계열 릴리스 노트의 갱신 목록에 없는 항목입니다.
| 부품 | 22.23.4 | 24.22.1 | 26.12.1 |
|---|---|---|---|
| c-ares(DNS 조회) | 1.34.9 | 1.34.9 | 1.34.9 |
| OpenSSL | 3.5.9 | 3.5.9 | - |
Undici(내장 fetch의 바탕) |
6.29.1 | 7.30.1 | 8.12.2 |
| SQLite | 3.53.4 | - | - |
| 인증서 목록(NSS) | 3.131.1 | 3.131.1 | 3.131.1 |
| 시간대 데이터 | 2026e | 2026e | - |
인증서 목록 갱신에는 eMudhra 중간 인증기관 차단이 담겼고, 세 릴리스 모두 tls: reject chains containing blocked public keys 커밋이 올라 있습니다. 차단 목록에 든 공개 키를 가진 인증서 사슬을 거부하는 변경이어서, 해당 인증기관에서 받은 인증서를 쓰는 서비스는 업데이트 뒤 연결 결과가 달라질 수 있습니다. 26.12.1의 커밋 목록에는 보안과 무관한 Revert "module: synchronously load most ES modules"도 있습니다. 모듈 로딩 쪽을 건드리는 프로젝트는 26.x 릴리스 노트에서 이 항목을 읽어 보세요.
같은 주에 겹친 사건 두 가지는 따로 정리했습니다. 10월 21일 같은 날 PHP도 보안 릴리스를 냈고 PHP2Shell 총정리에서 다뤘습니다. 위의 인증서 목록 갱신의 배경인 eMudhra 사건은 인증서 서명 키가 사실상 0이었다에서 설명했습니다.
올린 뒤 연결 오류가 나면
가장 흔한 후유증은 비표준 방식으로 적은 IP 주소로 접속하던 코드에서 나옵니다. 앞자리에 0을 붙였거나 127.1처럼 줄여 쓴 주소, 전각 숫자를 쓴 호스트가 업데이트 뒤 ERR_TLS_CERT_ALTNAME_INVALID와 함께 Host is a non-canonical IP address로 거부됩니다. 오류 메시지가 이 문장이라면 원인은 인증서가 아니라 주소 표기입니다.
연결 오류를 보고 rejectUnauthorized: false나 NODE_TLS_REJECT_UNAUTHORIZED=0으로 검증을 끄지 마세요. 이번 수정이 막은 길을 직접 다시 여는 일입니다. 접속 주소를 정상 표기(127.0.0.1)로 고치거나 이름으로 접속하고, 인증서에는 그 주소나 이름에 맞는 항목이 있는지 확인합니다.
DNS 쪽에서는 이름에 NUL 문자가 섞인 입력이 ERR_INVALID_ARG_VALUE로 터지는지, 스냅샷을 쓰는 도구는 새로 빌드한 결과물에서 randomUUID()가 프로세스마다 달라졌는지 정도가 올린 뒤에 확인할 것들입니다.
이번 10건이 한 줄로 모이는 곳은 “같은 문자열을 검사하는 쪽과 쓰는 쪽이 다르게 읽는다”입니다. TLS의 표기, DNS의 NUL, Brotli의 오류 신호, keep-alive 소켓의 도착 순서가 모두 그렇고, 이전 수정의 후속이 4건이나 나온 것도 같은 읽기 경로가 여러 곳에 흩어져 있기 때문입니다. 라이브러리가 읽는 방식을 바로잡아 줘도 내 코드의 허용 목록과 접속 코드가 따로 값을 만들고 있으면 같은 간격은 다시 생깁니다. 이번 업데이트가 끝난 뒤 남는 숙제는 버전이 아니라, 검사한 값을 그대로 쓰는 구조를 내 코드에 심는 일입니다.