사후 분석: TanStack npm 공급망 침해
22 hours ago
5
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, gh CLI, .git-credentials, SSH private key 등 일반적인 위치에서 자격 증명을 수집함
탈취 데이터는 Session/Oxen messenger file-upload network 를 통해 유출되며, 대상은 filev2.getsession.org, seed{1,2,3}.getsession.org임
해당 네트워크는 종단 간 암호화되고 공격자 제어 C2가 없으므로, 네트워크 완화책은 IP/도메인 차단뿐임
자기 전파 로직은 registry.npmjs.org/-/v1/search?text=maintainer:<user>로 피해자가 관리하는 다른 패키지를 열거한 뒤 같은 주입 방식으로 다시 게시함
payload가 npm install 라이프사이클 일부로 실행되므로, 2026-05-11에 영향 버전을 설치한 호스트는 잠재적으로 손상된 것으로 취급해야 함
타임라인
공격 전: 캐시 오염 단계
2026-05-10 17:16 UTC에 공격자가 TanStack/router fork인 github.com/zblgg/configuration 을 만들고, fork 목록 검색을 피하려고 이름을 바꿈
2026-05-10 23:29 UTC에 조작된 신원 claude <claude@users.noreply.github.com>으로 악성 커밋 65bf499d16a5e8d25ba95d69ec9790a6dd4a1f14가 fork에 작성됨
해당 커밋은 약 30,000줄 번들 JS payload인 packages/history/vite_setup.mjs를 추가했고, push 이벤트의 CI를 억제하려고 커밋 메시지에 [skip ci]를 붙임
2026-05-11 약 10:49 UTC에 zblgg가 TanStack/router main을 대상으로 “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 head에 올라갔고, 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 push에서 release.yml이 조회할 키와 맞도록 구성됨
2026-05-11 11:31 UTC에 공격자가 PR을 당시 main HEAD인 b1c061af로 되돌려 visible PR을 0-file no-op처럼 만들었고, 같은 분에 PR을 닫고 branch를 삭제했지만 오염된 캐시는 남음
발화: 게시 단계
2026-05-11 19:15 UTC에 Manuel이 PR #7369를 merge하면서 main push가 발생했고, 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 으로 이뤄졌지만, 테스트 실패로 건너뛴 workflow의 Publish Packages step에서 발생한 것은 아님
실제 게시자는 테스트/정리 단계에서 실행된 malware였고, id-token: write 권한으로 OIDC 토큰을 mint한 뒤 registry.npmjs.org에 직접 POST함
2026-05-11 19:20:47 UTC에 run 25613093674는 failure 상태로 완료됨
2026-05-11 19:16 UTC에 Manuel이 PR #7382를 merge하면서 두 번째 main push가 발생했고, 19:16:22에 workflow run 25691781302가 시작됨
두 번째 run도 같은 오염 캐시를 restore했고, 2026-05-11 19:26:14 UTC에 @tanstack/history@1.161.12 등 패키지당 두 번째 버전 세트가 같은 OIDC 메커니즘으로 게시됨
2026-05-11 19:26:20 UTC에 run 25691781302도 failure 상태로 완료됨
탐지와 대응
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/configuration fork가 식별됨
모든 TanStack/* GitHub repository의 캐시 항목이 API로 제거됨
hardening PR이 merge되어 bundle-size.yml이 재구성되고, repository_owner guard가 추가됐으며, third-party action 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-pr job과 benchmark-pr job을 분리해 신뢰 경계를 나누려 했고, YAML comment에는 benchmark-pr를 “untrusted with read-only permissions”로 유지하려는 의도가 적혀 있었음
그러나 actions/cache@v5의 post-job save는 permissions:로 막히지 않으며, cache write는 workflow GITHUB_TOKEN이 아니라 runner 내부 token을 사용함
따라서 permissions: contents: read 설정은 cache mutation을 막지 못함
cache scope는 repository 단위이고, base repository cache scope를 사용하는 pull_request_target run과 main push가 공유함
base repository cache scope에서 실행되는 PR은 나중에 main의 production workflow가 restore할 cache entry를 오염시킬 수 있음
GitHub Actions 캐시 오염
악성 vite_setup.mjs는 정상 release.yml workflow가 계산하고 조회할 pnpm-store key에 맞춰 데이터를 쓰도록 설계됨
대상 key는 Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')} 형식임
benchmark-pr job 종료 시 actions/cache@v5 post-step이 오염된 pnpm store를 정확히 그 key로 저장함
이후 main push에서 release.yml이 실행되자 Setup Tools step이 오염된 entry를 설계대로 restore함
이 공격 유형은 2024년 Adnan Khan이 문서화한 GitHub Actions cache poisoning 계열이며, TanStack에만 국한된 버그가 아니라 의식적인 완화가 필요한 GitHub Actions 설계 이슈임
runner 메모리에서 OIDC 토큰 추출
release.yml은 npm OIDC trusted publishing에 필요해서 정당하게 id-token: write를 선언함
오염된 pnpm store가 runner에 restore되면 공격자 제어 binary가 디스크에 존재하게 되고, build step에서 호출됨
해당 binary는 /proc/*/cmdline로 GitHub Actions Runner.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"
}
교훈
잘된 점
외부 연구자들이 사고 후 약 20분 안에 탐지하고 전체 기술 세부사항과 함께 보고함
maintainer team이 여러 time zone에 걸쳐 즉시 조율함
탐지 커뮤니티가 몇 시간 안에 명확한 공개 IOC 패턴을 확보함
개선이 필요했던 점
내부 alerting이 없었고, compromise 사실을 제3자로부터 알게 됨
자체 publish monitoring이 필요하며, 이런 문제를 빠르게 탐지할 수 있는 생태계 보안 연구 기업들과 더 긴밀히 협력하고 feedback loop를 좁힐 계획임
pull_request_target workflow는 오래전부터 위험한 패턴으로 알려져 있었지만 audit되지 않았음
third-party action의 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이 더 빨라짐
남은 질문
bundle-size.yml의 Setup Tools step이 실제로 actions/cache@v5를 호출했는지 확인해야 함
PR #7378에 대한 pull_request_target run 중 하나의 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일 수 있음
참고 자료
Homepage
Tech blog
사후 분석: TanStack npm 공급망 침해
🔉 볼륨 줄이기
🔊 볼륨 키우기
🔇 음소거
⏭️ 다음 곡