사후 분석: TanStack npm 공급망 침해
요약
2026년 5월 11일, 공격자가 TanStack의 42개 npm 패키지에 걸쳐 악성 버전 84개를 게시하는 대규모 공급망 침해 사건이 발생했습니다. 이 공격은 GitHub Actions 캐시 오염과 OIDC 토큰 추출을 결합한 복잡한 체인을 사용했으며, npm 토큰 자체는 탈취되지 않았음에도 불구하고 높은 권한으로 레지스트리에 악성코드를 직접 게시하는 방식으로 이루어졌습니다. 악성 코드는 `npm install` 라이프사이클 스크립트를 통해 실행되며 AWS, GCP, Kubernetes, Vault, GitHub 등 광범위한 자격 증명을 수집하고 외부 C2 서버로 유출했습니다. 따라서 해당 날짜에 영향을 받은 버전의 패키지를 설치한 모든 사용자는 관련 자격 증명 교체가 필수적입니다.
핵심 포인트
- 공격은 npm 토큰 탈취 없이 OIDC trusted publisher 권한을 이용해 악성코드를 직접 게시함.
- 악성 코드는 `npm install` 라이프사이클 스크립트와 `optionalDependencies`를 활용하여 실행됨.
- AWS, GCP, Kubernetes, Vault 등 광범위한 클라우드 및 개발 환경의 자격 증명이 노출되었을 수 있음.
- 공격 체인은 GitHub Actions 캐시 오염과 OIDC 토큰 추출을 결합하는 고도화된 방식을 사용함.
- 영향 버전 설치 시 해당 호스트는 잠재적으로 손상된 것으로 간주하고 모든 관련 자격 증명을 교체해야 함.
2026-05-11 19:20~19:26 UTC에 공격자가 42개 **@tanstack/**npm 패키지에 걸쳐 악성 버전 84개를 게시함 - 공격 체인은 pull_request_target “Pwn Request”, GitHub Actions 캐시 오염, runner 메모리의 OIDC 토큰 추출을 결합함 - npm 토큰과 publish 워크플로는 탈취·손상되지 않았고, 악성코드가 OIDC trusted publisher 권한으로 registry에 직접 POST함 - 영향 버전 설치 시 AWS, GCP, Kubernetes, Vault, GitHub, npm, SSH 자격 증명이 노출됐을 수 있어 교체가 필요함 - 모든 영향 버전은 deprecated 처리됐고 npm security와 tarball 제거를 진행했으며, 추적 이슈와 GitHub Security Advisory가 공개됨
사건 개요
- 2026-05-11 19:20~19:26 UTC 사이 공격자가 42개
@tanstack/*npm 패키지에 걸쳐 악성 버전 84개를 게시함 - 공격 체인은pull_request_target“Pwn Request” 패턴 - fork↔base 신뢰 경계를 넘는 GitHub Actions 캐시 오염, GitHub Actions runner 프로세스 메모리에서의 OIDC 토큰 추출을 결합함 - npm 토큰은 탈취되지 않았고, npm publish 워크플로 자체도 손상되지 않은 것으로 확인됨 - 악성 버전은 외부 연구자
ashishkurmi가stepsecurity에서 공개적으로 20분 안에 탐지함 - 모든 영향 버전은 deprecated 처리됐고, npm security와 함께 레지스트리에서 tarball 제거를 진행함 - 2026-05-11에 영향 버전을 설치한 사용자는 설치 호스트에서 접근 가능한 AWS, GCP, Kubernetes, Vault, GitHub, npm, SSH 자격 증명을 교체해야 함 - 추적 이슈는 TanStack/router#7383, GitHub Security Advisory는 GHSA-g7cv-rxg3-hmpx임
영향 범위
영향받은 패키지
-
영향 범위는 42개 패키지와 84개 버전이며, 패키지당 2개 버전이 약 6분 간격으로 게시됨
-
전체 목록은 추적 이슈에 포함됨
-
확인된 비영향 제품군은
@tanstack/query*,@tanstack/table*,@tanstack/form*,@tanstack/virtual*,@tanstack/store,@tanstack/start메타 패키지임@tanstack/start-*는 확인된 비영향 목록에 포함되지 않음
악성코드 동작
-
개발자 또는 CI 환경이 영향 버전에 대해
npm install,pnpm install,yarn install을 실행하면 npm이 악성optionalDependencies항목을 해석하고 fork network의 orphan payload commit을 가져옴 - 이후prepare라이프사이클 스크립트가 실행되며, 영향 tarball 안에 숨겨진 약 2.3MB 난독화router_init.js가 동작함 - 악성 스크립트는 AWS IMDS/Secrets Manager, GCP metadata, Kubernetes service-account token, Vault token,~/.npmrc, GitHub token,ghCLI,.git-credentials, SSH private key 등 일반적인 위치에서 자격 증명을 수집함 - 탈취 데이터는 Session/Oxen messenger file-upload network를 통해 유출되며, 대상은filev2.getsession.org,seed{1,2,3}.getsession.org -
해당 네트워크는 종단 간 암호화(End-to-end encryption)되어 있고 공격자가 제어하는 C2(Command and Control)가 없으므로, 네트워크 완화책은 IP/도메인 차단뿐임
-
자기 전파(Self-propagation) 로직은
registry.npmjs.org/-/v1/search?text=maintainer:<user>
를 통해 피해자가 관리하는 다른 패키지를 열거한 뒤 같은 주입 방식으로 다시 게시함 - 페이로드(Payload)가 npm install 라이프사이클의 일부로 실행되므로, 2026-05-11에 영향 버전을 설치한 호스트는 잠재적으로 손상된 것으로 취급해야 함
- 개발자 또는 CI 환경이 영향 버전에 대해
타임라인
공격 전: 캐시 오염(Cache poisoning) 단계
-
2026-05-10 17:16 UTC에 공격자가 TanStack/router의 포크(Fork)인
github.com/zblgg/configuration을 만들고, 포크 목록 검색을 피하려고 이름을 바꿈 -
2026-05-10 23:29 UTC에 조작된 신원인
claude <claude@users.noreply.github.com>으로 악성 커밋65bf499d16a5e8d25ba95d69ec9790a6dd4a1f14가 포크에 작성됨 - 해당 커밋은 약 30,000줄의 번들 JS 페이로드(Bundle JS payload)인packages/history/vite_setup.mjs를 추가했고, 푸시(Push) 이벤트의 CI를 억제하려고 커밋 메시지에[skip ci]를 붙임 - 2026-05-11 약 10:49 UTC에zblgg가 TanStack/routermain을 대상으로 “WIP: simplify history build” 제목의 PR #7378을 염 -bundle-size.yml과labeler.yml은 모두pull_request_target으로 PR에 대해 자동 실행됐고,pull_request_target은 첫 기여자 승인 게이트를 우회하므로 별도 승인이 필요 없었음 -pull_request를 쓰는pr.yml은 승인 대기 상태로 차단됐고 실행되지 않음 - 2026-05-11 11:01~11:11 UTC에zblgg가 PR 헤드(Head)에 여러 차례 강제 푸시(Force-push)하여 추가적인pull_request_target실행을 유발함 - 2026-05-11 11:11 UTC에 악성 커밋65bf499d가 PR 헤드에 올라갔고,bundle-size.yml의benchmark-pr작업(Job)이refs/pull/7378/merge를 체크아웃한 뒤pnpm install과pnpm nx run @benchmarks/bundle-size:build를 실행하면서vite_setup.mjs가 동작함 - 2026-05-11 11:29 UTC에Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11라는 1.1GB GitHub Actions 캐시가 TanStack/router에 저장됨 - 이 캐시는refs/heads/main스코프(Scope)에 저장됐고, 다음main푸시에서release.yml이 조회할 키와 맞도록 구성됨 - 2026-05-11 11:31 UTC에 공격자가 PR을 당시mainHEAD인b1c061af로 되돌려 가시적인 PR을 0-file no-op처럼 만들었고, 같은 분에 PR을 닫고 브랜치(Branch)를 삭제했지만 오염된 캐시는 남음
발화: 게시(Publish) 단계
- 2026-05-11 19:15 UTC에 Manuel이 PR #7369를 머지(Merge)하면서
main푸시가 발생했고,release.yml워크플로 실행(Workflow run)25613093674가 19:15:44에 시작된 뒤 실패함 - 2026-05-11 19:20:39 UTC에 npm 레지스트리(Registry)가@tanstack/history@1.161.9
와 41개의 sibling package(형제 패키지)의 publish(게시)를 수신함 - 전체적으로 42개 패키지에 걸쳐 약 84개 버전이 게시됐지만, 이 정확한 초에 보이는 것은 약 절반이며 나머지는 두 번째 run(실행)에서 게시됨
-
publish(게시) 인증은
TanStack/router의release.yml@refs/heads/main에 대한 **OIDC trusted-publisher binding(OIDC 신뢰할 수 있는 게시자 바인딩)**으로 이뤄졌지만, 테스트 실패로 건너뛴 workflow(워크플로)의Publish Packages단계에서 발생한 것은 아님 - 실제 게시자는 테스트/정리 단계에서 실행된 malware(악성코드)였고,id-token: write권한으로 OIDC 토큰을 mint(발급)한 뒤registry.npmjs.org에 직접 POST함 - 2026-05-11 19:20:47 UTC에 run25613093674는 failure(실패) 상태로 완료됨 - 2026-05-11 19:16 UTC에 Manuel이 PR #7382를 merge(병합)하면서 두 번째mainpush(푸시)가 발생했고, 19:16:22에 workflow run25691781302가 시작됨 - 두 번째 run(실행)도 같은 오염된 캐시를 restore(복구)했고, 2026-05-11 19:26:14 UTC에@tanstack/history@1.161.12등 패키지당 두 번째 버전 세트가 동일한 OIDC 메커니즘으로 게시됨 - 2026-05-11 19:26:20 UTC에 run25691781302도 failure(실패) 상태로 완료됨 -
2026-05-11 19:15 UTC에 Manuel이 PR #7369를 merge(병합)하면서
근본 원인
세 취약점의 결합
- 2026-05-11 약 19:50 UTC에 외부 연구자
carlini가 악성optionalDependencies(선택적 의존성) fingerprint(지문)와 패키지 목록을 포함한 이슈 #7383을 염 - 초기 목록은 42개 중 14개였고, 연구자는 npm security에도 직접 알림
탐지와 대응
-
2026-05-11 약 20:00 UTC에 Manuel이 #7383에서 사고를 확인하고 대응을 시작함
-
2026-05-11 약 20:10 UTC에 Manuel이 사용자 머신 손상 가능성에 대비해 다른 팀원의 GitHub push(푸시) 권한을 제거함
-
2026-05-11 약 20:30 UTC에 Tanner가 전체 IOC(침해 지표) 목록과 registry-side(레지스트리 측) tarball 제거 요청을 security@npmjs.com으로 보냈고, npm을 통해 정식 malware(악성코드) report(보고)를 제출함
-
2026-05-11 약 21:00 UTC에 295개
@tanstack/*패키지 전체 스캔으로 범위가 42개 패키지, 84개 버전으로 확인됨 - Tanner가 84개 영향 패키지 전체에 대한 npm deprecation(사용 중단)을 시작했고,@tan_stack과 maintainer(유지 관리자)들이 Twitter/X, LinkedIn, Bluesky에서 공개 알림을 진행함 - 2026-05-11 21:30 UTC에bundle-size.yml의pull_request_target캐시 오염 벡터와zblgg/configurationfork(포크)가 식별됨 - 모든 TanStack/* GitHub repository(저장소)의 캐시 항목이 API로 제거됨 -
hardening(경화) PR이 merge(병합)되어
bundle-size.yml이 재구성되고,repository_ownerguard(가드)가 추가됐으며, third-party action(제3자 액션) ref(참조)가 SHA로 고정됨 - 공식 GitHub Security Advisory(보안 권고)가 게시됐고 CVE가 요청됨 -
공격에는 세 가지 취약점이 모두 필요했고, 어느 하나만으로는 충분하지 않았음
-
fork PR 코드가 base repository cache로 넘어가고, base repository cache가 release workflow runtime으로 넘어가며, release workflow runtime이 npm registry 쓰기 권한으로 이어지는 식으로 각 취약점이 서로의 신뢰 경계를 연결함
pull_request_target
“Pwn Request” 패턴bundle-size.yml
은 fork PR에 대해 pull_request_target으로 실행됐고, 그 trigger context 안에서 fork의 PR merge ref를 checkout한 뒤 build를 실행함 - 핵심 구조는 다음과 같음
on: pull_request_target: paths: ['packages/**', 'benchmarks/**'] jobs: benchmark-pr: steps: - uses: actions/checkout@v6.0.2 with: ref: refs/pull/${{ github.event.pull_request.number }}/merge # fork's merged code - uses: TanStack/config/.github/setup@main # transitively calls actions/cache@v5 - run: pnpm nx run @benchmarks/bundle-size:build # executes fork-controlled code
-
workflow 작성자는
comment-prjob과benchmark-prjob을 분리해 신뢰 경계를 나누려 했고, YAML comment에는benchmark-pr를 “untrusted with read-only permissions”로 유지하려는 의도가 적혀 있었음 - 그러나actions/cache@v5의 post-job save는permissions:로 막히지 않으며, cache write는 workflowGITHUB_TOKEN이 아니라 runner 내부 token을 사용함 - 따라서permissions: contents: read설정은 cache mutation (캐시 변조)을 막지 못함 - cache scope (캐시 범위)는 repository 단위이고, base repository cache scope를 사용하는pull_request_targetrun과mainpush가 이를 공유함 - base repository cache scope에서 실행되는 PR은 나중에main의 production workflow가 restore (복구)할 cache entry (캐시 항목)를 오염시킬 수 있음
GitHub Actions 캐시 오염 (Cache Poisoning)
-
악성
vite_setup.mjs는 정상release.ymlworkflow가 계산하고 조회할 pnpm-store key에 맞춰 데이터를 쓰도록 설계됨 - 대상 key는Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}형식임 -benchmark-prjob 종료 시actions/cache@v5post-step이 오염된 pnpm store를 정확히 그 key로 저장함 - 이후mainpush에서release.yml이 실행되자Setup Toolsstep이 오염된 entry를 설계대로 restore함 - 이 공격 유형은 2024년 Adnan Khan이 문서화한 GitHub Actions cache poisoning (캐시 오염) 계열이며, TanStack에만 국한된 버그가 아니라 의식적인 완화 (mitigation)가 필요한 GitHub Actions 설계 이슈임 -
악성
runner 메모리에서 OIDC 토큰 추출
release.yml은 npm OIDC trusted publishing (신뢰할 수 있는 게시)에 필요해서 정당하게 id-token: write
를 선언함. 오염된 pnpm store가 runner에 restore되면 공격자 제어 binary가 디스크에 존재하게 되고, build step에서 호출됨.
-
해당 binary는
/proc/*/cmdline
로 GitHub ActionsRunner.Worker
프로세스를 찾고,/proc/<pid>/maps
와/proc/<pid>/mem
을 읽어 worker 메모리를 dump함. 이후 runner가
id-token: write
설정에서 lazy mint한 OIDC 토큰을 메모리에서 추출함. 추출한 token으로
registry.npmjs.org
에 직접 POST 요청을 인증해, workflow의Publish Packages
step을 완전히 우회함. 이 메모리 추출 방식은 2025년 3월
tj-actions/changed-files
compromise에 쓰인 방식과 같고, attribution comment가 포함된 동일 Python script가 사용됨. 공격자는 새로운 기법을 발명한 것이 아니라 공개 연구를 재조합함.
각 요소가 단독으로 충분하지 않은 이유
pull_request_target
자체는 label이나 comment 같은 신뢰된 작업에는 사용할 수 있음. 이미 손상된 dependency 내부에서의 cache poisoning만으로는 별도의 publish vehicle이 필요함.
- OIDC token extraction만으로는 runner에서의 기존 code execution이 필요함.
탐지와 IOC
탐지 경로
-
탐지는 내부가 아니라 외부에서 이뤄짐.
carlini
가 publish 후 약 20분 만에 이슈 #7383을 열어 전체 기술 분석을 제공함. Tanner는 war room을 시작한 직후 Socket.dev에서 상황을 확인하는 전화를 받음.
downstream maintainer와 보안 도구용 fingerprint
@tanstack/*
패키지 manifest에서 다음 optionalDependencies
항목이 핵심 IOC임:
"optionalDependencies": { "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c" }
- 파일 IOC는 package root의
router_init.js
이며, 약 2.3MB이고 `
임 - 악성 publish를 수행한 workflow run은 github.com/TanStack/router/actions/runs/25613093674 attempt 4와 github.com/TanStack/router/actions/runs/25691781302임
교훈
잘된 점
-
외부 연구자들이 사고 후 약 20분 안에 탐지하고 전체 기술 세부사항과 함께 보고함
-
maintainer team이 여러 time zone에 걸쳐 즉시 조율함
-
탐지 커뮤니티가 몇 시간 안에 명확한 공개 IOC 패턴을 확보함
개선이 필요했던 점
-
내부 alerting (경보)이 없었고, compromise (침해) 사실을 제3자로부터 알게 됨
-
자체 publish monitoring (게시 모니터링)이 필요하며, 이런 문제를 빠르게 탐지할 수 있는 생태계 보안 연구 기업들과 더 긴밀히 협력하고 feedback loop (피드백 루프)를 좁힐 계획임
-
pull_request_targetworkflow는 오래전부터 위험한 패턴으로 알려져 있었지만 audit (감사)되지 않았음 -
third-party action (제3자 액션)의 floating ref (유동 참조)인
@v6.0.2,@main은 이번 사건과 별개로 상시 supply-chain risk (공급망 리스크)를 만듦 -
npm의 “dependent (의존성)가 있으면 unpublish (게시 취소) 불가” 정책 때문에 거의 모든 영향 패키지에서 unpublish가 불가능했음
-
registry-side (레지스트리 측) tarball 제거를 npm security에 의존해야 했고, 이로 인해 악성 tarball이 설치 가능한 상태로 남는 시간이 몇 시간 추가됨
-
npm scope의 7명 maintainer (관리자) 목록은 동일 blast radius (폭발 반경)에 대해 7개의 별도 credential-theft (자격 증명 탈취) target (대상)을 만든다는 의미가 됨
-
OIDC trusted-publisher binding (신뢰할 수 있는 게시자 바인딩)에는 publish (게시)별 review (검토)가 없고, 한 번 설정되면 workflow 안의 어떤 code path (코드 경로)라도 publish 가능한 token (토큰)을 mint (발급)할 수 있음
-
필요한 대안은 수동 review (검토)가 있는 단기 classic token (클래식 토큰)으로 이동하거나, 예상치 못한 workflow step (워크플로 단계)에서의 publish를 탐지하는 provenance-source-verification (출처 검증)을 추가하는 것임
운이 좋았던 점
- 공격자가 테스트를 깨뜨리는 payload (페이로드)를 선택해 정상 publish step (게시 단계)이 skip (건너뛰기)됐고, 더 깨끗해 보이는 tarball이 생성되지 않았음
- 이 때문에 공격이 충분히 요란하게 드러나 빠르게 탐지됨
- 더 조심스러운 공격자가 테스트를 깨뜨리지 않았다면 몇 시간 더 조용히 publish할 수 있었음
- 공격자는 attribution comment (귀속 주석)가 포함된 공개 memory-dump (메모리 덤프) script (스크립트)를 재사용했고, 새로운 코드를 작성하지 않아 IOC matching (IOC 매칭)이 더 빨라짐
남은 질문
bundle-size.yml의Setup Toolsstep (단계)이 실제로actions/cache@v5를 호출했는지 확인해야 함- PR #7378에 대한
pull_request_targetrun (실행) 중 하나의 post-job log (사후 작업 로그)를 읽어 검증해야 하며, 예시 run id는25666610798임 - force-push (강제 푸시)로 사라지기 전 최초 PR head commit (헤드 커밋)에 무엇이 있었는지 확인해야 하며, GitHub reflog에 남아 있을 수 있음
- 악성 commit (커밋)이 fork (포크)의 git object store (객체 저장소)에 들어간 방식이 직접 git push (깃 푸시)였는지, audit-log (감사 로그) entry (항목)를 남길 GitHub web UI 생성이었는지 확인해야 함
voicproducoes가 실제 계정인지 sock puppet (가짜 계정)인지 활동 이력과 대조해야 함- 6개의 중복
linux-npm-store-*
entry로 보이는 npm cache도 오염됐는지, 실제 사용됐는지 확인해야 함 - 공격에 Nx Cloud가 필요했는지, GitHub Actions cache만으로도 작동했을지 확인해야 함
- TanStack/router fork network 안에서 orphan payload commit을 포함한 다른 fork를 식별할 수 있는지 확인해야 함
- 다른 fork가 해당 commit을 hosting하고 있다면
github:tanstack/router#79ac49ee...
접근성이 유지돼 cleanup이 더 어려워짐 - router, query, table, form, virtual 등 다른 TanStack repo가 같은 bundle-size.yml 스타일 패턴을 사용하는지 audit이 필요함 - publish window 동안 영향 버전을 실제로 다운로드한 사용자 수를 npm support에서 받아야 함
- 7명의 maintainer의 머신이 별도로 손상됐는지 확인해야 함
- 악성 publish에는 maintainer npm token이 사용되지 않았지만, maintainer machine은 self-propagation (자기 전파) logic의 2차 target일 수 있음
참고 자료
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기