npm을 흔든 공급망 공격: debug·chalk 등 18개 패키지가 암호화폐 탈취 코드에 감염되다

한 통의 피싱 메일이 20억 다운로드의 보급선을 오염시키는 공급망 침해 한 방울의 독, 주간 20억+ 다운로드의 보급선 위조 명령서 · 피싱 메일 npm 보급 창고 · 18개 패키지 지갑 주소 바꿔치기
겉보기엔 멀쩡한 보급 상자 무더기(npm 패키지) 속 몇 개에 발린 독 — 한 통의 피싱 메일이 20억 다운로드의 보급선을 오염시켜, 브라우저에서 암호화폐 지갑 주소를 몰래 공격자 것으로 바꿔치기한다.

원문 정보

이 글을 Articles의 Security에 담는 이유: 이건 특정 도구 소개가 아니라 실제로 터진 공급망 사고의 해부(incident writeup)다. JavaScript 생태계에서 가장 많이 쓰이는 유틸리티들이 한꺼번에 감염된 사건이라, “내 프로젝트가 안전한가”라는 질문을 모든 개발자에게 던진다.

한 줄 요약 (TL;DR)

2025년 9월 8일, debug·chalk를 포함해 주간 다운로드를 합치면 20억 회가 넘는 18개 npm 패키지가 악성 버전으로 교체됐다. 공격자는 피싱 메일로 메인테이너의 npm 계정을 탈취한 뒤, 브라우저에서 실행되며 암호화폐 지갑 주소를 몰래 공격자 주소로 바꿔치기하는 코드를 심었다. Aikido의 자동 탐지가 배포 약 2시간 만에 잡아냈고 메인테이너가 곧바로 정리에 나섰지만, 그 짧은 창(window) 동안 수많은 빌드에 오염된 버전이 흘러 들어갈 수 있었다.

왜 이 글을 골랐나

이 사고 전체를 관통하는 척추는 한 통의 메일에서 시작해 20억 다운로드로 번지는 하나의 인과 사슬이다.

flowchart LR
    A["피싱 메일<br/>(support@npmjs.help)"] --> B["메인테이너<br/>npm 계정 탈취"]
    B --> C["인기 패키지에<br/>악성 버전 배포"]
    C --> D["전이 의존성으로 확산<br/>(주간 20억 다운로드)"]
    D --> E["브라우저에서<br/>암호화폐 지갑 주소 바꿔치기"]
    E --> F["Aikido 자동 탐지<br/>· 메인테이너 정리"]

이 사고가 특히 무서운 이유는 표적이 화려한 라이브러리가 아니라 아무도 신경 쓰지 않는 기반 유틸리티였다는 점이다. chalk(터미널 색 출력), debug(디버그 로깅), ansi-styles, strip-ansi 같은 패키지는 직접 설치한 기억조차 없이, 다른 수만 개 패키지의 전이 의존성(transitive dependency)으로 딸려 들어온다. 즉 이건 “위험한 패키지를 잘못 골랐다”의 문제가 아니라, 정상적으로 잘 관리되던 패키지가 어느 날 갑자기 무기가 되는 종류의 위협이다.

이 위키는 그동안 보안 쪽에서 LLM이 바꾸는 사회공학의 경제학처럼 ‘공격면’을, 공급망 사고가 났을 때 어느 머신이 노출됐나를 답하는 Bumblebee처럼 ‘대응 도구’를 다뤄왔다. 이 글은 그 둘을 잇는 실제 사건 그 자체다 — 피싱(사회공학)이 어떻게 공급망 침해로 이어지고, 왜 Bumblebee 같은 인벤토리 도구가 필요해지는지를 한 사건 안에서 보여준다.

핵심 내용

원문의 사실관계를 순서대로 정리한다. (수치·주소·타임스탬프는 Aikido 원문에 명시된 것만 사용했다.)

무엇이 감염됐나 — 18개 패키지, 주간 20억 다운로드

침해된 패키지와 악성 버전, 주간 다운로드 수는 다음과 같다. 여기 적힌 버전이 곧 오염된 버전이라는 점이 실무적으로 가장 중요하다.

패키지 악성 버전 주간 다운로드
debug 4.4.2 357.6M
ansi-styles 6.2.2 371.41M
strip-ansi 7.1.1 261.17M
ansi-regex 6.2.1 243.64M
supports-color 10.2.1 287.1M
wrap-ansi 9.0.1 197.99M
color-convert 3.1.1 193.5M
color-name 2.0.1 191.71M
chalk 5.6.1 299.99M
slice-ansi 7.1.1 59.8M
is-arrayish 0.3.3 73.8M
color-string 2.1.1 27.48M
simple-swizzle 0.2.3 26.26M
error-ex 1.3.3 47.17M
has-ansi 6.0.1 12.1M
supports-hyperlinks 4.1.1 19.2M
chalk-template 1.1.1 3.9M
backslash 0.2.1 0.26M

합산 주간 다운로드는 20억 회를 넘는다. 다만 이 숫자는 어디까지나 해당 패키지들의 평시 도달 범위(잠재적 폭발 반경)이지, 짧은 감염 창 동안 실제로 내려받아진 악성 버전의 수는 아니다. 원문도 실제 감염·피해 규모를 정량화하지는 않는다.

공격 벡터: npmjs.help 피싱

시작점은 정교한 기술 취약점이 아니라 한 통의 이메일이었다. 메인테이너는 support@npmjs.help에서 온, 공식 npm 지원을 사칭한 피싱 메일을 받았다. 이 도메인 npmjs.help는 공격 3일 전인 9월 5일에 등록됐다 — 계획된 작전이었다는 신호다. 메인테이너는 사고 후 이렇게 밝혔다: “피싱에 당했다. support at npmjs dot help 에서 온 이메일이었다.” 이 한 번의 계정 탈취가 인기 패키지들에 대한 직접 배포 권한으로 이어졌다.

페이로드: 브라우저 안의 ‘주소 바꿔치기 도둑’

악성코드가 브라우저 안에서 밟는 다섯 단계와, 그 밑에 갈고리를 건 API·지갑 인터페이스는 다음과 같다.

flowchart TB
    H["갈고리를 건 지점 (client-side hooks)<br/>fetch() · XMLHttpRequest · window.ethereum · Solana/크로스체인 지갑"]
    H --> S1["① 초기화<br/>브라우저 환경 감지 · window.ethereum.request()로 지갑 존재 확인"]
    S1 --> S2["② 패턴 매칭<br/>정규식으로 네트워크 응답·트랜잭션에서 여러 체인의 지갑 주소 탐지"]
    S2 --> S3["③ 주소 바꿔치기<br/>Levenshtein 거리로 원본과 가장 닮은 공격자 주소 선택"]
    S3 --> S4["④ 트랜잭션 탈취<br/>서명 직전 수신자 교체 · ERC-20 approve()/permit()의 spender 탈취"]
    S4 --> S5["⑤ 은폐<br/>활성 지갑 감지 시 눈에 보이는 지표 억제 → 의심 회피"]

심어진 코드는 서버가 아니라 웹 브라우저에서 클라이언트 사이드로 실행되는 인터셉터다. 핵심 자바스크립트 API와 지갑 인터페이스에 갈고리를 걸어(fetch(), XMLHttpRequest, window.ethereum, Solana 지갑, 크로스체인 지갑 라이브러리) 네트워크 트래픽과 암호화폐 트랜잭션을 가로챈다. 동작은 다섯 단계로 요약된다.

  1. 초기화 — 페이지 로드 시 브라우저 환경을 감지하고 window.ethereum.request()로 이더리움 지갑의 존재를 확인한다.
  2. 패턴 매칭 — 정규식으로 모든 네트워크 응답과 트랜잭션 데이터에서 여러 블록체인의 지갑 주소를 찾아낸다.
  3. 주소 바꿔치기 — 정당한 수신 주소를 공격자 주소로 교체하는데, 이때 Levenshtein 거리(편집 거리) 알고리즘으로 원래 주소와 시각적으로 가장 닮은 공격자 주소를 골라 사용자가 눈으로 알아채기 어렵게 만든다.
  4. 트랜잭션 탈취 — 서명 직전에 트랜잭션 파라미터를 조작한다. 이더리움에서는 eth_sendTransaction의 수신자와 토큰 승인(approval) 대상을, Solana에서는 트랜잭션 명령과 수신 계정을 다시 쓴다. 특히 ERC-20의 approve()·permit() 호출을 가로채 spender(인출 권한을 갖는 주소)를 공격자 것으로 바꾼다 — 한 번 승인되면 이후 지속적으로 자금을 빼갈 수 있는, 가장 악질적인 수법이다.
  5. 은폐 — 활성 지갑이 감지되면 눈에 보이는 지표를 억제해 사용자의 의심을 피한다.

표적이 된 체인과 주소

악성코드는 단일 체인이 아니라 광범위한 통화를 노렸다. 이더리움(0x…), 비트코인 레거시(1…)와 SegWit(bc1q…), 트론(T…), 라이트코인(ltc1…, m/M…), 비트코인 캐시(bitcoincash:…), 그리고 Solana(base58)다. 난독화를 푼 코드에는 체인별로 공격자 주소가 대량 박혀 있었다 — 비트코인 약 40개, 이더리움 64개, Solana 20개, 트론 40개, 라이트코인 39개, 비트코인 캐시 40개. 다중 체인 지원과 닮은꼴 주소 선택까지 갖춘 설계는, 블록체인에 대한 깊은 이해를 가진 조직화된 위협 행위자의 작품임을 시사한다.

타임라인

사고 타임라인 — 탐지에서 대응까지 약 2시간 ≈ 3일 탐지 → 대응 ≈ 2시간 9월 5일 피싱 도메인 npmjs.help 등록 9월 8일 13:16 UTC Aikido 인텔리전스 자동 탐지 9월 8일 15:15 UTC 메인테이너 인지 · 정리 시작 9월 8일 16:58 UTC 2차 침해 탐지 proto-tinker-wc
탐지는 사람이 아니라 Aikido의 자동 패턴 인식이 해냈고, 탐지(13:16)에서 메인테이너 인지·정리(15:15)까지는 약 2시간 — 같은 날 16:58에는 동일 행위자로 추정되는 2차 침해가 또 잡혔다.
  • 9월 5일 — 피싱 도메인 npmjs.help 등록.
  • 9월 8일 13:16 UTC — Aikido의 인텔리전스가 npm에 푸시된 악성 버전을 탐지.
  • 9월 8일 15:15 UTC — 메인테이너가 Bluesky를 통해 침해를 인정하고 정리를 시작.
  • 9월 8일 16:58 UTCproto-tinker-wc@0.1.87에서 2차 침해가 탐지됨 — 동일 행위자의 조율된 캠페인으로 추정.

탐지와 대응

탐지는 사람이 아니라 Aikido의 자동 악성 패턴 인식 인텔리전스 피드가 해냈다. 탐지에서 메인테이너 인지까지 약 2시간이라는 빠른 대응에도 불구하고, 원문 발행 시점에는 simple-swizzle이 여전히 정리되지 않은 상태였다고 한다. 원문이 제시하는 완화책은 다음과 같다: (1) 의존성이 위 표의 악성 버전과 겹치는지 인벤토리 점검, (2) npm cache clean --force, (3) node_modules/와 lockfile을 지우고 깨끗하게 재설치, (4) 버전을 고정한 package-lock.json 강제, (5) 스캐닝 도구(Aikido 무료 티어, 또는 설치 시점을 감싸는 SafeChain 래퍼) 사용.

분석과 인사이트

여기부터는 원문 요약이 아니라 내 관점이다.

1) 진짜 취약점은 코드가 아니라 사람이었다. 이 사고에 제로데이도, 정교한 익스플로잇도 없었다. 가짜 도메인 하나와 사칭 메일 한 통이 20억 다운로드짜리 폭발 반경으로 번졌다. 공급망 보안 논의는 자꾸 SBOM·서명·격리 같은 기술적 통제로 흐르지만, 이번 침해의 뿌리는 LLM이 사기의 단가를 무너뜨리고 있다는 그 사회공학과 정확히 같은 자리에 있다. 메인테이너 계정의 피싱 저항성(하드웨어 키 기반 2FA 등)이 없으면, 그 아래 붙은 수억 개의 다운로드는 전부 한 통의 메일에 인질로 잡혀 있는 셈이다.

2) 폭발 반경과 실제 피해는 다르다 — 하지만 그게 위안이 되진 않는다. 악성 페이로드는 브라우저에서, 암호화폐 지갑이 있을 때만 작동한다. 즉 이 패키지들을 CI 빌드나 서버 사이드 Node.js에서만 쓴다면 지갑 탈취라는 최종 목적은 발동하지 않는다. 실제 금전 피해가 20억이라는 숫자에 비해 작았을 가능성이 큰 이유다(원문은 피해액을 명시하지 않는다). 그러나 이건 이번 페이로드가 우연히 브라우저·크립토를 노렸기 때문일 뿐이다. 같은 침해 경로에 postinstall 스크립트로 CI 시크릿을 빼가는 페이로드가 실렸다면 이야기는 완전히 달라진다. “우리는 서버에서만 써서 괜찮았다”는 결과론이지 방어가 아니다.

3) Levenshtein 거리로 ‘닮은꼴 주소’를 고른 디테일이 이 공격의 성숙도를 말해준다. 단순히 주소를 바꾸는 게 아니라, 사용자가 트랜잭션 확인 화면에서 주소의 앞뒤 몇 글자만 훑어보는 습관까지 계산에 넣었다. 이는 Bumblebee 글에서 지적한 것과 같은 교훈을 반대편에서 보여준다 — 공격자는 사람의 검증 휴리스틱이 어디서 게으른지를 정확히 안다.

4) 방어가 자동화되어 있었다는 점이 유일하게 희망적이다. 사람의 눈이 아니라 자동 패턴 인식이 2시간 만에 잡아냈다. 공급망의 배포 속도가 사람의 감시 속도를 이미 추월한 시대에, 탐지도 배포 파이프라인 속도로 자동화되어야 함을 보여준다. 문제는 탐지가 빨라도 배포는 즉각적이라, 그 사이의 짧은 창을 완전히 막을 수는 없다는 점이다. 그래서 결국 개인 개발자 수준의 방어(lockfile 고정, 설치 지연)가 마지막 안전망으로 남는다.

5) 2차 침해(proto-tinker-wc)는 이게 일회성이 아니라 캠페인임을 말한다. 같은 날 다른 패키지가 또 뚫렸다는 건, 인기 npm 계정들이 조직적으로 노려지는 표적 목록에 올라 있다는 뜻이다. chalk/debug가 이번에 뚫렸다면, 다음은 당신이 매일 쓰는 다른 어떤 것이다.

적용 포인트

  • 지금 당장 표의 악성 버전이 lockfile에 있는지 검색하라. package-lock.json(또는 pnpm/yarn lock)에서 위 18개 패키지의 정확한 악성 버전 문자열을 grep한다. 있으면 원문 권고대로 캐시 정리 → node_modules·lock 삭제 → 알려진 안전 버전으로 재설치한다.
  • 버전을 고정하고, npm ci로 재현 가능한 설치를 하라. ^/~ 범위 지정은 편하지만, 메인테이너 계정이 뚫리면 다음 npm install이 곧바로 악성 버전을 끌어온다. lockfile 고정 + npm ci는 이번 유형 공격의 자동 확산을 끊는 가장 값싼 방어다.
  • 설치를 지연시켜라(cooldown). 방금 릴리스된 버전을 즉시 당겨오지 말고, 며칠의 ‘숙성 기간’을 두는 정책(예: --before 핀, 프록시 레지스트리의 격리 기간)을 쓰면 탐지·철회가 일어날 그 짧은 창을 우회할 수 있다.
  • 메인테이너라면 피싱 저항성 2FA를 켜라. SMS/TOTP를 넘어 하드웨어 보안 키(WebAuthn) 기반 2FA를 npm 계정에 적용한다. 이번 공격의 시작점을 원천 차단하는 유일한 통제다. npmjs.com을 사칭한 도메인(npmjs.help 등)을 항상 의심하라.
  • 사고 대응 플레이북에 ‘엔드포인트 노출 매칭’을 넣어라. 권고문이 뜨면 CI뿐 아니라 개발자 노트북에 그 버전이 남아 있는지까지 물어야 한다. Bumblebee 같은 읽기 전용 인벤토리 스캐너가 정확히 그 자리에 들어간다.
  • 개발자 도구의 인증 기본값을 점검하라. 계정·토큰 관리 전반에서 안전한 기본값(예: CLI의 Device Flow)을 채택했는지 확인한다.

마무리

이 사고의 교훈은 역설적이다. JavaScript 생태계에서 가장 지루하고 신뢰받던 유틸리티들이, 바로 그 신뢰 때문에 최고의 공격 벡터가 됐다. 한 통의 피싱 메일이 20억 다운로드의 폭발 반경으로 번지는 구조에서, 우리가 통제할 수 있는 건 완벽한 예방이 아니라 확산을 늦추는 규율이다 — 버전 고정, 설치 지연, 자동 탐지, 그리고 사고가 났을 때 “누가 노출됐나”를 빠르게 답하는 인벤토리. 공급망 보안은 결국 “믿을 수 있는 패키지를 고르는 문제”가 아니라, “믿던 것이 배신했을 때 얼마나 빨리 알아채고 격리하느냐”의 문제다.

더 읽어보기