본문으로 건너뛰기

© 2026 Molayo

GN헤드라인2026. 05. 14. 04:15

사후 분석: 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 워크플로 자체도 손상되지 않은 것으로 확인됨
  • 악성 버전은 외부 연구자 ashishkurmistepsecurity에서 공개적으로 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

  • 해당 네트워크는 종단 간 암호화(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/router main을 대상으로 “WIP: simplify history build” 제목의 PR #7378을 염 - bundle-size.ymllabeler.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.ymlbenchmark-pr 작업(Job)이 refs/pull/7378/merge를 체크아웃한 뒤 pnpm installpnpm 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을 당시 main HEAD인 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/routerrelease.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에 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: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.ymlpull_request_target 캐시 오염 벡터와 zblgg/configuration fork(포크)가 식별됨 - 모든 TanStack/* GitHub repository(저장소)의 캐시 항목이 API로 제거됨

  • hardening(경화) PR이 merge(병합)되어 bundle-size.yml이 재구성되고, repository_owner guard(가드)가 추가됐으며, 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-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 캐시 오염 (Cache Poisoning)

  • 악성 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에만 국한된 버그가 아니라 의식적인 완화 (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 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" }

  • 파일 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_target workflow는 오래전부터 위험한 패턴으로 알려져 있었지만 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.ymlSetup 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일 수 있음

참고 자료

AI 자동 생성 콘텐츠

본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
1

댓글

0