사후 분석: TanStack npm 공급망 침해

2 hours ago 1
  • 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일 수 있음

참고 자료

Read Entire Article