기술

Node2Shell 패치, 셸의 node -v 로 확인하면 안 됩니다 — 앱이 실제로 쓰던 Node 는 따로 있었습니다

2026.10.3110분 읽기

2026년 10월 21일 Node.js 보안 릴리스에 'Node2Shell'(CVE-2026-111497)이 실렸습니다. Node 안에 들어 있는 Brotli 압축 해제 모듈의 결함입니다. 조작한 압축 데이터 하나로 인증 없이 서버에서 원격으로 코드를 실행할 수 있습니다. 다음 날 공개 공격 코드가 나왔습니다.

이 취약점은 Express·axios·Next 같은 라이브러리를 올려서는 막을 수 없습니다. 런타임을 패치판(22.23.4·24.22.1·26.12.1)으로 바꾸고 프로세스를 다시 띄워야 합니다. 20·23·25 는 지원이 끝나 패치가 나오지 않았습니다.

저희 자사 클라우드 서버 한 대에서는 pm2(Node 앱 프로세스 관리 도구) 앱 십여 개가 Node 하나를 같이 쓰고 있었습니다. 그 안에 고객 서비스 앱이 여러 개 섞여 있었습니다. 하루 동안 이 서버와 다른 고객사 서버들을 옮긴 기록을, 저희가 낸 장애 두 번까지 포함해 적습니다.


셸에서 본 버전은 앱이 쓰는 버전이 아니었습니다

처음 확인은 셸에서 했습니다. 일반 사용자 셸에서 node -v 를 실행하면 v22.12.0 이 나왔습니다. 개인용으로 설치해 둔 nvm(Node 버전 관리 도구)의 버전이었습니다.

그런데 pm2 앱은 root 권한으로 /usr/bin/node 22.23.3 을 쓰고 있었습니다. 셸에서 본 숫자로 범위를 정했다면 실제로 앱을 돌리지도 않는 Node 를 기준으로 판단했을 겁니다. 그래서 셸 대신 실행 중인 프로세스를 봤습니다.

sudo pm2 jlist 의 node_version 값을 봅니다

/proc/<pid>/exe(그 프로세스가 실제로 쓰는 실행 파일)를 자식 프로세스(npm → next-server)까지 따라가 버전을 찍습니다

바이너리를 바꾸고 재시작하지 않았으면 /proc/<pid>/exe 뒤에 (deleted) 가 붙습니다. 프로세스가 아직 옛 파일로 돌고 있다는 뜻입니다

같은 방법으로 다른 고객사 서버의 웹 앱 둘(22.23.3)과 상시 가동 기계의 웹 화면 하나(Bun 1.4.2)를 찾았습니다. 작업용 맥에는 지원이 끝난 v20 이 남아 있었습니다.

바이너리만 바꾸고 재시작을 빼먹으면 배포 표시는 새것이어도 서버는 옛 코드로 돕니다. 같은 일을 배포에서 겪은 기록이 있습니다.

셸에서 보이는 Node 버전과 실제로 앱을 돌리는 프로세스의 Node 버전이 다른 구조


버전 숫자로는 패치 여부를 알 수 없었습니다

패치판 22.23.4 를 넣고 process.versions.brotli 를 확인했습니다. 값은 여전히 1.1.0 이었습니다. 공지 표에는 패치된 Brotli 가 1.1.1 로 적혀 있었습니다. 수정만 골라 넣는 방식(cherry-pick)이라 표시 숫자는 그대로였습니다.

그래서 패치가 새로 추가한 상수를 직접 확인했습니다.

node -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH"
# 패치판: -17
# 패치 전(24.14.0): undefined

공지의 '오류 코드 17을 따로 반환하게 고쳤다'는 설명과 맞습니다. 다만 이 확인법은 이번 취약점에만 쓸 수 있습니다. 다음 취약점에서는 그 패치가 바꾼 다른 동작을 찾아야 합니다.


기본값을 바로 바꾸지 않고 옆에 설치했습니다

22 안에서 패치를 먼저 넣은 뒤, 메이저 버전을 26 으로 옮기기로 했습니다. 방법은 두 가지였습니다.

방법

장점

위험

기본 런타임(/usr/bin/node)을 바로 26 으로 교체

빠르다

다음 재시작 때 13개가 한꺼번에 바뀐다. 한 앱이 26 에서 안 돌면 전부 영향을 받는다

/opt/node-26 에 따로 설치하고 앱마다 실행 Node 지정

앱 하나씩 확인하고 되돌릴 수 있다

느리다. 지정 방법을 틀리면 옛 Node 가 섞인다

두 번째를 골랐습니다. 순서는 로드밸런서의 7일 요청 수로 정했습니다. 요청이 0 인 앱부터 옮기고, 요청이 가장 많은 고객 앱을 마지막에 뒀습니다.

앱마다 실행 Node 를 바꾸는 데서 두 군데가 걸렸습니다.

sudo 는 PATH 를 초기화합니다. sudo env PATH=/opt/node-26/bin:… 로 넘기지 않으면 next 의 #!/usr/bin/env node 가 다시 22 를 잡습니다

pm2 reload·restart 는 저장해 둔 실행 설정을 그대로 씁니다. 실행 Node 를 바꾸려면 delete 한 뒤 --interpreter 를 붙여 다시 start 해야 했습니다

다시 빌드는 하지 않았습니다. 네이티브 모듈(sharp·bcrypt·Next swc 등)이 전부 Node 버전에 묶이지 않는 N-API 방식이었습니다. 그래서 22 로 빌드한 결과물이 26 에서 그대로 돌았습니다. N-API 가 아닌 모듈이 있었다면 다시 빌드해야 했을 겁니다.

공용 Node 런타임의 메이저 버전을 옆에 설치하고 앱별로 옮긴 뒤 하나로 합치는 네 단계


저희가 낸 장애 두 번

옮기는 도중 두 번 서비스를 멈췄습니다. 고객 쪽 문의로 안 것은 아니고 작업하던 쪽에서 발견했습니다.

요청이 적은 고객 앱 몇 개가 몇 분 멈췄습니다. 전환 스크립트의 셸 함수 안에서 "$@" 가 함수 인자를 가리켜, 기존 프로세스만 지우고 새 프로세스를 못 띄웠습니다. 되돌리는 단계도 같은 버그였는데, 실패해도 다음 앱으로 넘어갔습니다

요청이 가장 많은 고객 앱을 포함해 서버의 앱 전부가 몇 분 멈췄습니다. pm2 관리 프로세스(앱들의 부모 프로세스)를 새 Node 로 올리려고 pm2 update 를 실행했는데, 새 관리 프로세스가 뜨지 않은 채 명령이 멈췄습니다. 명령이 끝나길 기다리게 짜 둬서 5분 제한이 지나서야 알았습니다. pm2 resurrect 로 15초 만에 복구했습니다

둘 다 여러 앱을 한 번에, 중간 확인 없이 건드린 데서 났습니다. 그 뒤 스크립트는 한 앱으로 먼저 시험하고, 실패하면 22 로 되돌린 뒤 전체를 멈추게 고쳤습니다. 관리 프로세스가 왜 멈췄는지는 아직 모릅니다.

되돌리는 경로가 작동하지 않는다는 사실은 실제로 써 보기 전엔 안 보입니다.

여러 앱을 한 번에 바꾸다 낸 장애와 한 개씩 먼저 시험하도록 바꾼 뒤의 비교


옆에 설치한 사본은 다음 패치를 못 받습니다

Jenkins 빌드도 jenkins 사용자의 /usr/bin/node 로 돌고 있어 실행 Node 와 맞췄습니다. 모든 앱이 26 에서 도는 것을 확인한 뒤 nodesource 저장소를 pub_22.x 에서 pub_26.x 로 바꿨습니다. 기본 Node 자체가 26 이 됐습니다.

그러자 /opt/node-26 과 /usr/bin/node 가 같은 26.13.0 이 됐습니다. 문제는 압축본으로 설치한 /opt 쪽은 dnf update 로 올라가지 않는다는 점입니다. 그대로 두었다면 다음 보안 패치 때 /opt 로 옮긴 앱들이 패치 없이 남았을 겁니다.

같은 날 하나로 합쳤습니다. 앱을 한 개씩 /usr/bin/node 로 다시 띄웠습니다. 프로세스·pm2 저장 목록·Jenkins·cron 어디에도 node-26 경로가 없는 것을 확인한 뒤 /opt/node-26 을 지웠습니다. 앞으로 패치는 dnf update 와 앱 재시작, 그리고 -17 확인이면 됩니다.


최신 메이저가 늘 답은 아니었습니다

다른 고객사 서버 두 곳도 26.13.0 으로 옮겼습니다. 한 고객사 시스템의 웹은 24.22.1 에서 멈췄습니다. Next 13.5 안에 들어 있는 jsonwebtoken 이 Node 25 부터 없어진 SlowBuffer 를 참조하기 때문입니다.

같은 Next 13 계열인데 13.3.0 은 26 에서 빌드와 실행이 모두 됐습니다. 앱마다 직접 돌려 봐야 압니다. 지원 중인 이전 메이저의 패치판에 머무는 것도 정답입니다.


런타임 보안 패치 전에 확인할 것

시스템을 맡긴 쪽에서 보면 런타임 보안 패치는 라이브러리 업데이트가 아닙니다. 서버의 실행 환경을 바꾸는 일이라 몇 분 단위로 서비스가 멈출 위험이 있습니다. 그래서 아래를 먼저 봅니다.

범위를 셸의 버전이 아니라 실행 중인 프로세스의 실행 파일로 정했는가

패치 여부를 버전 표시가 아니라 패치가 바꾼 동작으로 확인했는가

여러 앱을 건드리는 작업을 한 개로 먼저 시험하고, 실패하면 멈추게 했는가

빌드에 쓰는 Node 와 실행에 쓰는 Node 가 같은가

전환용으로 따로 설치한 런타임을 작업 끝에 지웠는가

남은 것도 적어 둡니다. 관리 프로세스가 멈춘 원인은 로그를 아직 보지 못했습니다. 전환 뒤에는 응답 코드만 확인했고, 화면과 로그인은 직접 써 보지 않았습니다.

#Node.js#Node2Shell#보안패치#pm2#런타임전환#장애회고#Jenkins#무중단