10월 21일 Node.js가 취약점 10개(High 4개)를 고쳤습니다. Brotli 원격 코드 실행 결함은 fetch() 한 줄로도 닿습니다. 컨테이너 Node.js 8개 점검과 next/image 허용 범위 축소까지 정리했습니다.
Ian4 min read3
같은 날 나온 두 패치
10월 21일은 바쁜 날이었습니다. PHP2Shell 패치가 나온 바로 그날, Node.js도 보안 릴리스를 냈습니다. 22.x, 24.x, 26.x 세 브랜치에서 취약점 10개를 고쳤고, 그중 4개가 High입니다. 고친 버전은 아래와 같습니다.
브랜치
고친 버전
26.x
26.12.1
24.x
24.22.1
22.x
22.23.4
지원이 끝난 Node.js 20 이하와 25는 패치를 받지 못합니다. 함께 포함된 undici, c-ares, OpenSSL도 업데이트됐습니다. 플린랩스의 서비스는 대부분 Node.js 위에서 돌기 때문에, 우리에게는 이번 릴리스가 PHP 쪽보다 더 직접적인 문제였습니다.
가장 위험한 Brotli
가장 심각한 것은 CVE-2026-111497입니다. node:zlib의 Brotli 압축 해제 코드에 있는 범위 밖 쓰기 결함입니다. 공식 공지에 따르면 디코더는 잘못된 블록 전환을 "입력이 더 필요하다"는 신호로 착각합니다. 그래서 8바이트짜리 버퍼 끝을 넘어서까지 계속 씁니다. 결과는 프로세스 종료, 메모리 노출, 최악의 경우 원격 코드 실행입니다.
이 결함이 무서운 이유는 우리가 Brotli를 직접 쓰지 않아도 노출된다는 점입니다. Node.js에 내장된 fetch()는 응답이 Content-Encoding: br이면 알아서 압축을 풉니다. 우리 서버가 우리가 통제하지 않는 서버에서 무언가를 받아 오기만 하면, 그 응답이 공격 코드가 될 수 있습니다. 링크 미리보기, 웹훅, RSS 수집기처럼 사용자가 준 URL을 가져오는 기능이 대표적입니다.
주소를 다르게 읽는 문제
나머지 High 3개는 "같은 주소를 서로 다르게 읽는" 문제입니다.
CVE-2026-106069: 0127.0.0.1을 URL 파서는 8진수로 읽어 87.0.0.1로, OS의 리졸버는 10진수로 읽어 127.0.0.1로 해석할 수 있습니다. 인증서는 한 주소로 검사하고, 연결은 다른 주소로 합니다.
CVE-2026-105483: 127.0.0.1처럼 전각 숫자로 쓴 IP를 IP로 인식하지 못해, 엉뚱한 인증서가 통과할 수 있습니다.
CVE-2026-105482: dns.resolve()가 NUL 바이트에서 이름을 잘라 버립니다. attacker.example\0.trusted.example은 끝이 trusted.example이라 허용 목록 검사를 통과하지만, 실제로는 attacker.example을 조회합니다.
셋 다 공격자가 연결할 주소에 손을 댈 수 있어야 의미가 있습니다. 하지만 사용자 입력으로 받은 주소를 허용 목록으로 걸러 접속하는 코드는 생각보다 흔합니다. SSRF 방어가 바로 그런 코드입니다. 10개 중 3개가 이전 패치의 불완전한 수정이나 회귀였다는 점도 눈에 띕니다. 보안 패치는 한 번에 끝나지 않는다는 것을 다시 보여 줍니다.
우리 서버 점검
PHP2Shell 때처럼 "Node가 어디에서 돌고 있는가"부터 확인했습니다.
호스트: 엣지 서버와 홈서버 모두 호스트에는 Node.js가 설치되어 있지 않았습니다. 우리 인프라의 Node.js는 전부 컨테이너 안에 있습니다.
다시 빌드: Dockerfile은 node:22-alpine처럼 메이저 버전 태그를 쓰기 때문에, 새 베이스 이미지를 받아 다시 빌드하면 패치 버전이 들어갑니다. 10월 23일부터 26일까지 Node 컨테이너를 차례로 다시 빌드하고 띄웠습니다.
실제 버전 확인: 이미지를 바꿨다고 끝이 아닙니다. 돌고 있는 컨테이너 8개 안에서 하나씩 node -v를 실행했습니다. 22.x 5개는 22.23.4, 24.x 3개는 24.22.1로 모두 고친 버전이었습니다.
고객사 서버: 우리가 운영을 맡은 고객사 서버들도 같은 방식으로 패치를 마쳤습니다.
여기서 PHP와 결정적으로 다른 점이 드러납니다. PHP는 Ubuntu 자동 보안 업데이트가 호스트 패키지를 고쳐 줬습니다. 컨테이너 안의 Node.js는 아무도 대신 고쳐 주지 않습니다. 이미지를 다시 빌드하고 컨테이너를 다시 띄워야만 패치가 들어갑니다. 그래서 Node.js 보안 릴리스에는 "이미지 다시 빌드 → 컨테이너 재시작 → 안에서 버전 확인"까지 한 번에 해야 합니다.
bash
# 돌고 있는 모든 컨테이너의 Node.js 버전 확인for c in $(docker ps --format '{{.Names}}'); do v=$(docker exec "$c" node -v 2>/dev/null) && echo "$c $v"done
우리 코드의 노출 지점
버전과 별개로, 이번 취약점이 우리 코드 어디에 닿는지도 살펴봤습니다. 우리 Next.js는 next/image의 원격 이미지 허용 범위를 모든 HTTPS 호스트(hostname: '**')로 열어 두었습니다. 이미지 최적화 기능은 요청받은 URL의 이미지를 서버에서 fetch()로 가져옵니다. 외부 사람이 지정한 서버의 응답을 우리 서버가 받아서 푸는 경로가 있었던 셈입니다. 패치 전이었다면 Brotli 결함에 그대로 노출됐을 것입니다.
운영 사이트에 외부 이미지 주소를 넣어 요청해 보니, 실제로 우리 서버가 그 이미지를 받아 와서 200으로 돌려줬습니다. 지금은 글 이미지와 커버를 모두 우리 API에 업로드하기 때문에, 허용 범위를 우리 API의 /uploads/ 경로로 좁혔습니다.
javascript
// next.config.mjsconst api = new URL(process.env.NEXT_PUBLIC_API_URL);const remotePatterns = [ { protocol: api.protocol.replace(':', ''), hostname: api.hostname, pathname: '/uploads/**' },];
배포하자마자 우리 업로드 이미지까지 400이 났습니다. next start는 next.config.mjs를 실행할 때 다시 읽는데, 이 환경 변수를 빌드 단계에만 넣어 두었기 때문입니다. 실행 이미지에도 같은 값을 넣어 다시 배포했고, 지금은 외부 이미지는 400, 우리 이미지는 200입니다.
마치며
이번 Node.js 릴리스에서 우리가 얻은 교훈은 두 가지입니다. 첫째, 컨테이너 안의 런타임은 다시 빌드하기 전까지 절대 패치되지 않습니다. 호스트 자동 업데이트가 있다고 안심하면 안 됩니다. 보안 릴리스가 나오면 이미지를 다시 빌드하고, 돌고 있는 모든 컨테이너의 node -v를 확인해야 끝난 것입니다. 둘째, "우리는 그 기능을 안 쓴다"는 생각은 위험합니다. Brotli를 직접 부르는 코드가 없어도, fetch() 한 줄과 넓게 열린 설정 하나가 공격 경로가 될 수 있습니다.
같은 날 두 런타임이 동시에 패치를 냈다는 것은 앞으로 이런 날이 더 잦아진다는 신호라고 생각합니다. 그날마다 무엇이 어디서 돌고 있는지 알고, 패치를 확인까지 끝내는 것이 우리가 지키려는 기본입니다.