ChainDrop npm 공격 점검기: lock 파일 43개에서 한 버전 차이로 비켜 간 감염
ChainDrop이라는 npm 공급망 공격을 알게 된 것은 공격이 일어나고 거의 두 달이 지난 9월 30일이었습니다. O'Reilly의 9월 트렌드 정리를 읽다가 "자기 복제하는 악성코드가 npm 패키지 1,300개 이상을 감염시켰고, 출처 증명까지 정상이었다"는 한 줄을 봤습니다. 감염 패키지 이름에 flat-cache와 file-entry-cache가 있었습니다. ESLint가 쓰는 캐시 패키지라, 제 리포 대부분에 들어 있을 이름이었습니다.
글을 쓰기 전에 점검부터 했습니다. 이 글은 그 점검에서 무엇을 봤고, 무엇이 헛걸음이었고, 무엇을 끝내 보지 못했는지에 대한 기록입니다. 점검은 Claude Code에게 스크립트를 짜게 해서 돌렸습니다.
정상 출처 증명을 달고 나온 악성 패키지
BleepingComputer 보도에 따르면 공격은 2026년 8월 4일에 일어났습니다. 공격자는 keyv와 cacheable 계열 패키지를 관리하는 메인테이너의 GitHub 계정을 탈취해서 main 브랜치에 악성 코드를 직접 넣었습니다. 그다음은 원래 있던 GitHub Actions 워크플로가 알아서 했습니다. 정상 파이프라인이 악성 코드를 빌드해서 npm에 올렸습니다.
그래서 감염된 버전에는 유효한 출처 증명(provenance)이 붙어 있었습니다. 출처 증명은 "이 패키지는 이 저장소의 이 워크플로에서 빌드됐다"를 보장합니다. 저장소 자체가 오염되면 그 보장은 여전히 참이지만 아무것도 막지 못합니다. 저는 이 부분이 이번 공격에서 가장 불편한 대목이라고 봅니다. 출처 증명을 확인하라는 것은 지난 몇 년간 공급망 보안의 표준 조언이었습니다.
설치하는 순간 preinstall 훅이 setup.mjs를 실행하고, 이것이 환경 변수, GitHub 토큰, npm 토큰, AWS와 Kubernetes 자격증명을 수집해 밖으로 보냅니다. 훔친 npm 토큰으로 그 메인테이너가 관리하는 다른 패키지를 다시 감염시키는 것이 자기 복제의 원리입니다.
대조할 감염 버전 목록
점검은 목록이 있어야 할 수 있습니다. StepSecurity 분석이 1차 감염 11개 패키지의 정확한 버전과 게시 시각을 공개해 두었습니다.
| 패키지 | 감염 버전 | 게시 (UTC) |
|---|---|---|
| keyv | 6.0.0 | 09:35 |
| flat-cache | 6.1.24 | 10:10 |
| file-entry-cache | 11.1.6 | 10:13 |
| cacheable-request | 13.0.20 | 10:11 |
| cacheable | 2.5.1 | 10:10 |
| @cacheable/utils | 2.5.1 | 10:14 |
| @cacheable/memory | 2.2.1 | 10:11 |
| @cacheable/node-cache | 3.1.2 | 10:10 |
| @cacheable/net | 2.1.1 | 10:09 |
| cache-manager | 7.2.10 | 10:14 |
| ecto | 5.0.1 | 10:28 |
노출 시간은 8월 4일 09:35부터 13:20 UTC, 한국 시각으로 18:35부터 22:20까지입니다. 2차 확산으로 433개 패키지가 더 감염됐는데, 이쪽은 @servicetitan, @onereach, @qlik 같은 특정 조직의 스코프에 몰려 있어서 스코프 이름으로 대조했습니다.
lock 파일 43개에서 나온 결과
개발 폴더 아래의 리포 42개에서 lock 파일 43개를 찾았습니다. package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock이 섞여 있어서 형식마다 따로 읽었습니다. package-lock은 JSON으로 파싱하고, yarn.lock은 블록 단위로, pnpm과 bun은 이름@버전 패턴으로 찾았습니다.
감염 버전은 0건이었습니다. 2차 확산 스코프도 0건이었습니다. 그런데 대상 패키지들이 실제로 어느 버전으로 들어 있는지를 함께 뽑았더니 이렇게 나왔습니다.
| 패키지 | 설치된 버전 | 감염 버전 |
|---|---|---|
| flat-cache | 6.1.23 | 6.1.24 |
| file-entry-cache | 11.1.5 | 11.1.6 |
| cacheable | 2.5.0 | 2.5.1 |
| @cacheable/utils | 2.5.0 | 2.5.1 |
| @cacheable/memory | 2.2.0 | 2.2.1 |
다섯 개 모두 감염 버전 바로 아래였습니다. 한 칸 차이로 비켜 간 것입니다.
한 버전 차이가 뜻하는 것
이 리포가 무사했던 이유는 lock 파일입니다. 누군가 8월 4일 저녁 네 시간 사이에 이 리포에서 lock 파일을 무시하고 의존성을 새로 풀었다면, ^6.1.23 같은 범위 지정은 새로 올라온 6.1.24를 그대로 받았을 것입니다. 그날 그 시간에 그런 작업을 하지 않았다는 것 말고는 저를 지켜 준 것이 없습니다.
lock 파일만 믿을 수도 없어서 실제로 설치된 파일도 봤습니다. node_modules 안의 대상 패키지 package.json 버전을 전부 읽었고, lock 파일과 같았습니다. npm 캐시 색인에서 감염 버전의 tarball 이름도 찾아봤지만 없었습니다. 기계 쪽 흔적으로는 악성코드가 남기는 토큰 감시 스크립트(~/.local/bin/gh-token-monitor.sh), 임시 폴더의 bun-dl-* 디렉터리, setup.mjs 파일, C2 도메인이 적힌 설정 파일을 확인했고 모두 없었습니다.
이름만 같았던 Math_Symbol.js
중간에 한 번은 감염으로 보이는 결과가 나왔습니다. 악성코드의 정보 탈취 파일 이름이 Math_Symbol.js인데, 이 이름으로 검색하자 파일이 7개 나왔습니다.
경로를 보니 전부 regenerate-unicode-properties/General_Category/Math_Symbol.js였습니다. Babel이 유니코드 정규식을 처리할 때 쓰는 데이터 패키지이고, Math_Symbol은 유니코드의 수학 기호 범주 이름입니다. 해시로 확인했습니다. 7개 모두 크기 1,074바이트에 같은 해시였고, 악성 파일은 727,680바이트에 전혀 다른 해시입니다.
배운 것은 단순합니다. 파일 이름은 흔적이 아니라 후보입니다. IOC 목록에 해시가 함께 있는 이유가 이것이고, 이름으로 찾았으면 해시로 확정해야 합니다.
점검 스크립트가 멈춘 이유
부끄러운 실수도 하나 있었습니다. 기계 흔적 검사가 5분이 지나도 끝나지 않았습니다. 원인은 스크립트였습니다. setup.mjs를 찾아서 그 목록을 grep에 넘기는 줄이 있었는데, 찾은 파일이 0개였습니다. 파일 인자가 없는 grep은 표준 입력을 기다립니다. 감염이 없다는 좋은 소식 때문에 스크립트가 영원히 멈춰 있었던 것입니다.
목록이 비었을 때 다음 명령이 어떻게 동작하는지는 점검 스크립트를 짤 때 가장 먼저 확인할 것이었습니다. 그 뒤로는 빈 목록이면 다음 단계를 건너뛰도록 고쳐서 돌렸습니다.
이 점검이 보지 못한 곳
깨끗하다는 결론에는 범위가 있습니다. 저는 개발용 기계 한 대의 개발 폴더만 봤습니다. CI 러너와 다른 서버는 보지 않았습니다. 2차 확산 433개 패키지는 스코프로만 대조했고, 스코프 없는 26개 패키지는 개별 버전 목록을 대조하지 못했습니다. 그리고 8월 4일 그 시간에 npx로 무언가를 일회성으로 실행했다면 lock 파일에는 남지 않습니다. npm 캐시에 흔적이 없다는 것이 간접 증거일 뿐입니다.
감염 흔적이 없으니 토큰 전체 교체는 하지 않았습니다. 감염된 기계라면 StepSecurity가 권하듯 토큰 교체보다 토큰 감시 스크립트가 있는지부터 확인해야 합니다. 이 악성코드는 토큰이 폐기되는 것을 감시하다가 반응하는 장치를 심기 때문입니다.
다음 공격 전에 바꿔 둘 것
이번에 한 칸 차이로 비켜 간 것은 운이었습니다. 운이 아니게 만들려면 새로 올라온 버전을 곧바로 받지 않는 장치가 필요합니다. pnpm은 게시된 지 일정 시간이 지나지 않은 버전을 설치하지 않는 minimumReleaseAge 설정을 제공합니다. ChainDrop의 감염 버전들은 네 시간 안에 발견됐으니, 하루만 기다리게 해도 이번 공격은 닿지 않았습니다.
CI에서는 npm install 대신 npm ci로 lock 파일을 벗어나지 못하게 하고, 필요 없는 곳에서는 --ignore-scripts로 설치 훅을 끄는 것도 검토할 만합니다. 이번 공격의 입구가 preinstall 훅이었습니다.
이번 점검에서 가장 오래 걸린 것은 스크립트를 돌리는 일이 아니라 공격을 알게 되기까지의 두 달이었습니다. 공급망 공격은 또 올 것이고, 그때는 발표된 날 감염 버전 목록만 바꿔 넣고 같은 점검을 돌릴 수 있게 준비해 두려고 합니다.
함께 읽기
- X-Forwarded-For 신뢰 경계: 덧붙이는 프록시와 덮어쓰는 플랫폼요청 제한을 분당 30회로 걸어 두었습니다. 접근 로그에는 같은 초에 수백 건이 찍혀 있는데 429는 한 건도 없습니다.
- 업무 시스템 보안 단계적 도입: 1단계 기준선과 나중에 붙일 것첫 고객의 업무 시스템을 만들기 시작하면 보안 항목 목록이 금세 길어집니다. MFA, 세션, 감사 로그, 백업, 접근 통제, SSO, 기기 인증, 로그 중앙화, 전용 네트워크. 목록을 다 채우고 시작하려 하면 업무 기능을 만들기 전에 인프라 설정으로 몇 주가 지나갑니다.
- 업무 시스템 보안 구성: MFA, 세션, WAF, RBAC, RLS사내 업무 시스템을 만들 때 자주 나오는 질문이 있습니다. 「앞에 Cloudflare Access 같은 걸 두면 보안은 끝나는 것 아닌가요?」
- Next.js 보안 헤더와 색인 차단: 새 프로젝트마다 같은 설정을 두는 이유Vercel 로 옮기고 나서 응답 헤더를 다시 봤더니 보안 헤더가 하나도 없었습니다. 예전에는 오리진 앞의 nginx 가 붙여 주고 있었는데, 그 nginx 가 경로에서 빠지면서 전 경로에서 같이 사라진 것입니다. 앱은 그대로였고 오류도 없었습니다. 헤더는 없어져도 화면이 멀쩡해서, 재보기 전에는 모릅니다.
- PDF 암호화: 권한 암호만 걸면 그냥 열리는 이유권한 암호가 걸린 PDF 를 qpdf 로 들여다보면 이런 모양이 나옵니다.