Mini Shai-Hulud 재림 — SLSA 증명까지 위조한 npm 자가전파 웜
2026년 5월 11일 npm 생태계에 유효한 SLSA Build Level 3 증명서를 단 자가전파 웜이 풀렸다. TanStack 84개 버전을 포함해 @uipath·@mistralai 등이 감염됐고, 탈취한 OIDC 토큰으로 정상 CI/CD 파이프라인을 통째로 하이재킹하는 4단계 체인 공격이다.
ㅂㄹㄱ
2026-05-18
들어가며
아침에 인스타에서 보안뉴스 카드뉴스로 이번 사건을 처음 봤다. "TanStack 84개 버전 감염" 문구를 보자마자 내 프로젝트 lockfile부터 뒤졌다. 다행히 내 lockfile에 박혀 있던 @tanstack/table-core 8.21.3은 감염 범위 바깥(router·start 계열만 노림)이었지만, 그 5분 동안 머릿속에 떠올랐던 시나리오는 별로 즐겁지 않았다. CI에서 npm publish 토큰이 평문으로 떠 있었던 시점, AWS 키가 GitHub Actions 시크릿으로 들어가 있는 워크플로우, 마지막으로 토큰 로테이션을 한 게 언제였는지.
이번 Mini Shai-Hulud 사건은 그런 의미에서 좀 다르다. 유효한 SLSA Build Level 3 증명서를 달고 배포된 첫 npm 웜이기 때문이다. "서명되지 않은 패키지를 조심하라"는 기존 방어선 안쪽에서 터진 사건이다. StepSecurity가 5월 12일 분석 보고서를 공개했고 위협 행위자는 TeamPCP로 식별됐다. 가장 두드러진 피해는 @tanstack 네임스페이스에서 나왔고, React Query와 TanStack Router 등 42개 프로젝트의 84개 버전이 감염됐다.
무엇이 감염됐나
핵심 피해 범위는 다음과 같다.
| 네임스페이스 | 감염 규모 | 비고 |
|---|---|---|
| @tanstack | 42개 프로젝트 / 84개 버전 | 주간 수백만 다운로드 |
| @uipath | 60+ 패키지 | RPA/자동화 플랫폼 |
| @mistralai | 일부 버전 | AI 모델 클라이언트 |
| @draftlab | 일부 버전 | |
| @opensearch | 일부 버전 |
TanStack은 React 생태계에서 React Query만으로도 의존성 트리에 들어가 있는 프로젝트가 셀 수 없을 정도다. 직접 설치하지 않았더라도 전이 의존성으로 끌려 들어왔을 가능성이 크니, 일단 npm ls부터 돌려보는 게 맞다.
공격 체인: 토큰을 토큰으로 굴리는 4단계
Mini Shai-Hulud는 한 번 침투한 자격증명을 이용해 다음 패키지를 감염시키는 자가전파 구조를 갖는다.
flowchart TD
A["1단계\n공격자 GitHub 포크에\n악성 페이로드 준비"] --> B["2단계\n탈취한 OIDC 토큰으로\n정상 CI/CD 파이프라인 하이재킹"]
B --> C["3단계\n유효한 SLSA Build Level 3 증명과 함께\nnpm에 악성 버전 배포"]
C --> D["4단계\n빌드 중 수집한 npm 토큰으로\n다음 패키지 감염"]
D -.->|"자가전파 루프"| A
각 단계가 모두 정상 인프라 위에서 돈다. 빌드는 GitHub Actions에서 정식으로 실행되고, SLSA 증명서는 진짜 OIDC로 서명된다. 공급망 검증 도구가 흘끗 봐서는 의심할 구석이 없다.
SLSA가 깨지는 지점
이번 사건에서 가장 곱씹어볼 부분이다. SLSA Build Level 3가 보장하는 항목은 셋이다.
- 빌드가 격리된 호스팅 서비스에서 실행됐다.
- 빌드 설정과 입력이 변조 불가능한 출처(예: 특정 리포지토리의 특정 커밋)에서 왔다.
- 증명서는 빌드 플랫폼이 자체 서명했다.
문제는 OIDC 토큰이 새는 순간이다. 토큰을 손에 쥔 공격자는 진짜 GitHub Actions 러너 안에서, 정상 리포지토리의 정상 커밋을 빌드하는 것처럼 보이는 워크플로우를 돌린다. 증명서가 보장하는 건 "GitHub Actions가 이 빌드를 실행했다"는 사실까지다. 그 빌드 안에서 어떤 코드가 끼어들었는지는 증명 범위 밖이다.
SLSA 증명은 어느 파이프라인이 만들었는지 확인하지만, 그 파이프라인이 의도한 대로 동작했는지는 보장하지 않는다.
이번 공격은 OIDC 토큰 노출 하나가 SLSA 신뢰 모델 전체를 우회하는 지름길로 쓰일 수 있다는 걸 실증했다.
페이로드 해부
감염된 패키지의 루트에는 router_init.js가 박힌다. 정상 패키지에 존재하지 않는 파일이라 IoC로 즉시 잡힌다. 압축 크기도 비정상이라 정상 TanStack 패키지가 190KB 정도라면 감염본은 900KB 안팎으로 부풀어 있다.
페이로드는 2.3MB 난독화된 JavaScript이며 세 겹의 보호로 감싸여 있다.
| 계층 | 기법 |
|---|---|
| 1차 | obfuscator.io 문자열 테이블 (인코딩 문자열 11,516개) |
| 2차 | PBKDF2-SHA256 기반 키 유도 |
| 3차 | AES-256-GCM으로 암호화된 11개 부가 페이로드 |
수집 대상은 광범위하다. AWS·Azure·GCP·Kubernetes 자격증명, SSH 키, npm 토큰, 암호화폐 지갑, AI 도구 설정 파일, VPN 구성까지 100개 이상의 경로를 훑는다. CI 환경에서 특히 위험한 기법 두 가지를 짚고 넘어가야 한다.
Runner.Worker 메모리 스크래핑
GitHub Actions 실행자의 메모리를 /proc/{pid}/mem으로 직접 읽는다. 워크플로우에 정의된 모든 시크릿이 JSON 형태로 통째 추출된다. 시크릿을 ${{ secrets.X }}로 잘 가렸더라도 런타임 메모리에서는 평문이라 그대로 노출된다. 평소 "마스킹돼서 로그에 안 찍히니까 괜찮다"고 생각했다면 여기서 그 가정이 깨진다.
IMDSv2 전체 흐름 악용
AWS Instance Metadata Service v2의 정식 흐름을 그대로 따라간다. PUT으로 토큰을 받고 GET으로 자격증명을 받는 표준 시퀀스를 코드로 구현했기 때문에 IMDSv2 자체로는 막을 수 없다. IAM Role의 임시 자격증명이 그대로 빠져나간다.
데이터는 어디로 가나
탈취 데이터의 1차 데드드롭은 GitHub GraphQL API다. claude@users.noreply.github.com 계정 명의로 저장소에 암호화 커밋을 남기는 방식이다. 어떤 CI 환경에서든 GitHub 트래픽은 화이트리스트에 들어 있기 때문에 일반적인 egress 필터로는 잡히지 않는다.
병행 사용된 C2 도메인은 셋이다.
api.masscan.cloudfilev2.getsession.orggit-tanstack.com(TanStack 공식과 무관한 타이포스쿼팅)
git-tanstack.com은 도메인 이름만 봐도 의도가 분명하다. 빌드 로그에 노출돼도 사람 눈으로는 정상으로 착각하기 쉽게 만든 도메인이다.
IoC 정리
탐지 룰에 바로 박을 수 있도록 정리한다.
| 분류 | 값 |
|---|---|
| 파일 | 패키지 루트의 router_init.js |
| SHA-256 | ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c |
| 도메인 | api.masscan.cloud, filev2.getsession.org, git-tanstack.com |
| GitHub 계정 | voicproducoes (ID 269549300) |
| 데드드롭 | claude@users.noreply.github.com 명의 커밋 |
| 압축 크기 | 정상 190KB → 감염본 900KB 수준 |
| npm 토큰 description | "IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner" |
| package.json | optionalDependencies로 공격자 GitHub 포크 참조 |
npm 토큰 description은 일종의 협박 메시지다. 토큰을 revoke하면 소유자의 컴퓨터를 wipe하겠다는 문구를 박아 두었다. 실제 트리거 코드가 들어있는지와 별개로, 운영자가 토큰 회수를 망설이게 만드는 심리전 장치다. 처음 봤을 때 좀 황당해서 한참 쳐다봤다.
지금 당장 해야 할 것
즉시 조치
- 영향 버전 다운그레이드.
npm ls @tanstack/router @tanstack/react-query등으로 의존성을 전수 점검하고, 감염 버전이 박혀 있다면 직전 안전 버전으로 핀 고정한다. - node_modules 통째 삭제 후 재설치. 캐시(
npm cache clean --force,pnpm store prune)까지 비운다. - CI/CD 시크릿 전면 로테이션. npm publish 토큰, GitHub PAT, AWS·GCP·Azure 키, OIDC trust 설정까지 포함한다. 셋 중 시간이 가장 촉박한 작업이다.
잔존물 제거
ls ~/Library/LaunchAgents/ | grep -i tanstack
rm -f ~/.claude/settings.json ~/.vscode/tasks.json # 의심 시
systemctl --user list-units | grep -i tanstack
systemctl --user stop <unit> && systemctl --user disable <unit>
rm -f ~/.config/Code/User/tasks.json ~/.claude/settings.json # 의심 시
systemctl --user list-units | grep -i tanstack
loginctl enable-linger # 비활성화 여부 확인
rm -f ~/.config/Code/User/tasks.json ~/.claude/settings.json # 의심 시
항구적 방어
- CI 아웃바운드 화이트리스트. 빌드 러너에서 외부 도달 가능 도메인을 npm registry·GitHub·언어 런타임 미러로 제한한다.
- 래퍼 레지스트리 + 쿨다운. 새 버전을 즉시 끌어오지 않고 N일(권장 7~10일) 격리한다. 자가전파 웜은 거의 항상 발견까지 며칠 안에 IoC가 공개되므로 쿨다운만으로도 1차 차단 효과가 크다.
npm audit signatures정기 실행.- OIDC trust 범위 최소화.
repository:OWNER/REPO:environment:prod같은 가장 좁은 조건을 걸어 둔다. 처음에repository:OWNER/*로 풀어 놓고 잊어버리는 게 가장 흔한 실수다.
SLSA 다음에 무엇을 봐야 하나
Mini Shai-Hulud를 보면서 곱씹어볼 점은 SLSA의 가정이 어디서 무너지느냐다. SLSA·Sigstore·in-toto 같은 표준은 "빌드 시스템이 인증한 주체가 곧 신뢰할 수 있는 주체"라는 가정을 깔고 있다. OIDC 토큰이 새는 순간 이 가정이 깨진다.
토큰은 빌드 러너의 환경 변수, 워크플로우 시크릿 저장소, Runner.Worker 프로세스의 힙 메모리 등 여러 위치에 동시에 존재한다. 어느 한 곳만 새도 SLSA 증명 체계 전체가 무력화될 수 있다.
방어 우선순위를 바꿔야 한다.
- 시크릿이 평문으로 존재하는 시간을 최소화한다. 단명(short-lived) 토큰, OIDC federation, secretless workload identity 같은 메커니즘으로 옮긴다.
- 런타임 메모리 노출을 가정한다. 시크릿이 메모리에 평문으로 떠 있는 한 Runner.Worker 스크래핑은 항상 가능하다. 권한이 강한 시크릿은 별도 환경에서, 짧게 사용한다.
- 빌드 결과물의 행위 자체를 검증한다. 의존성 트리 diff, 패키지 사이즈 회귀, 새 진입점 등장 같은 신호를 CI에 박는다. 증명서가 정상이어도 행위가 비정상이면 잡힌다.
SLSA 자체가 무용지물이라는 얘기는 아니다. 여전히 필요한 최소 조건이고, 없으면 더 위험하다. 다만 "증명서가 있다"가 곧 "안전하다"로 통하지 않게 된 시점에 와 있다는 것 정도는 명심해야 한다.
본전 챙기기
지금 이 글을 읽는 중에도 빌드 러너에 평문 토큰이 떠 있을 가능성이 크다. 글 닫고 lockfile 한 번, OIDC trust 조건 한 번, 시크릿 로테이션 주기 한 번 확인해 보는 게 이번 사건의 본전을 챙기는 길이다.