
원문: https://tanstack.com/blog/npm-supply-chain-compromise-postmortem
2026-05-11 UTC 19:20에서 19:26 사이, 공격자가 다음 세 가지 기법을 조합해 42개의 @tanstack/* npm 패키지에 걸쳐 84개의 악성 버전을 게시했습니다. pull_request_target "Pwn Request" 패턴, fork 저장소와 base 저장소 사이의 신뢰 경계를 가로지르는 GitHub Actions 캐시 포이즈닝, 그리고 GitHub Actions 러너 프로세스의 런타임 메모리에서 OIDC 토큰을 추출하는 기법입니다. npm 토큰이 탈취된 사실은 없고, npm 게시 워크플로우 자체도 침해되지 않았습니다.
악성 버전은 stepsecurity에서 일하는 외부 연구자 ashishkurmi에 의해 게시 후 20분 안에 공개적으로 감지되었습니다. 영향받은 모든 버전은 더 이상 사용되지 않도록 폐기(deprecate)되었으며 npm 보안 팀에 레지스트리에서 tarball을 제거해달라고 요청해 둔 상태입니다. npm 자격 증명이 도난당했다는 증거는 없지만, 2026-05-11에 영향받은 버전을 설치한 분이라면 설치 호스트에서 접근 가능한 AWS, GCP, Kubernetes, Vault, GitHub, npm, SSH 자격 증명을 모두 교체할 것을 강력히 권고합니다.
추적 이슈: TanStack/router#7383 Github 보안 고지: 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 네트워크에서 고립된 페이로드 커밋을 가져온 뒤, prepare 라이프사이클 스크립트를 실행하며, 영향받은 tarball에 숨겨진 ~2.3 MB짜리 난독화된 router_init.js를 실행합니다. 이 스크립트는 다음 동작을 수행합니다.
~/.npmrc, GitHub 토큰(환경 변수, gh CLI, .git-credentials), SSH 개인 키입니다.filev2.getsession.org, seed{1,2,3}.getsession.org)를 통해 데이터를 유출합니다. 종단 간 암호화된 방식이고 공격자가 제어하는 C2(Command and Control, 명령제어 서버)도 없기 때문에, IP/도메인 차단이 유일한 네트워크 차단 수단입니다.registry.npmjs.org/-/v1/search?text=maintainer:<user>로 피해자가 유지보수하는 다른 패키지를 열거한 뒤, 동일한 인젝션을 적용해 다시 게시합니다.페이로드는 npm install의 라이프사이클 일부로 실행되므로, 2026-05-11에 영향받은 버전을 설치한 사용자는 누구든 설치 호스트가 잠재적으로 침해되었다고 간주해야 합니다.
모든 시각은 UTC 기준입니다. 타임스탬프는 GitHub API와 npm 레지스트리에서 가져왔습니다.
| 시간 | 사건 |
|---|---|
| 2026-05-10 17:16 | 공격자가 github.com/zblgg/configuration 저장소를 생성합니다(TanStack/router의 fork이며, fork 목록 검색을 회피하려고 의도적으로 이름을 변경한 것). |
| 2026-05-10 23:29 | 조작된 신원인 claude <claude@users.noreply.github.com>로 fork에 악성 커밋 65bf499d16a5e8d25ba95d69ec9790a6dd4a1f14가 작성됩니다. packages/history/vite_setup.mjs(약 30,000줄짜리 번들된 JS 페이로드)를 추가합니다. 커밋 메시지에 [skip ci]를 붙여 push 이벤트에서 CI가 실행되지 않도록 했습니다. |
| 2026-05-11 ~10:49 | zblgg가 "WIP: simplify history build"라는 제목으로 TanStack/router#main을 대상으로 PR #7378을 엽니다. |
| 2026-05-11 10:49 onwards | bundle-size.yml과 labeler.yml(둘 다 pull_request_target)이 PR에 대해 자동으로 실행됩니다. pull_request_target은 first-time-contributor 승인 게이트를 우회하므로 별도의 승인이 필요하지 않습니다. pull_request를 사용하는 pr.yml은 실행되지 않았고, 끝내 오지 않을 승인을 기다리며 대기 상태로 남았습니다. |
| 2026-05-11 11:01–11:11 | zblgg가 PR head에 여러 차례 force-push를 진행하고, 그때마다 pull_request_target 실행이 추가로 트리거됩니다. |
| 2026-05-11 11:11 | force-push로 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 | 캐시 엔트리 Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11(1.1 GB)가 TanStack/router의 GitHub Actions 캐시에 저장됩니다. 스코프는 refs/heads/main이고, main에 다음 push가 일어났을 때 release.yml이 조회할 키와 일치하도록 설계되었습니다. |
| 2026-05-11 11:31 | 공격자가 PR을 현재 main HEAD(b1c061af)로 되돌리는 force-push를 진행해, 겉으로는 0-file no-op PR로 보이게 만듭니다. 같은 시각에 PR이 닫히고 브랜치가 삭제됩니다. 캐시 포이즌은 그대로 남습니다. |
| 시간 | 사건 |
|---|---|
| 2026-05-11 19:15 | Manuel이 PR #7369(Shkumbin의 CSS.supports 수정)를 병합합니다. main으로의 push가 release.yml을 트리거합니다. 워크플로우 실행 25613093674이 19:15:44에 시작되지만, 실패로 끝납니다. |
| 2026-05-11 19:20:39 | npm 레지스트리가 @tanstack/history@1.161.9와 함께 묶인 41개 패키지의 게시 요청을 받습니다(42개 패키지에 걸쳐 약 84개 버전이지만, 정확히 이 시점에 보이는 것은 절반 정도이고 나머지는 두 번째 실행에서 처리됩니다). 게시는 TanStack/router release.yml@refs/heads/main에 묶인 OIDC trusted-publisher 바인딩으로 인증되지만, 워크플로우에 정의된 Publish Packages 단계에서 나온 것은 아닙니다. 테스트가 실패해 그 단계는 실행되지 않았습니다. 게시는 테스트/정리 단계에서 실행되는 멀웨어에서 발생합니다. 멀웨어는 워크플로우의 id-token: write 권한으로 OIDC 토큰을 발급받아 registry.npmjs.org로 직접 POST합니다. |
| 2026-05-11 19:20:47 | 실행 25613093674이 완료됩니다(상태: 실패). |
| 2026-05-11 19:16 | Manuel이 PR #7382(jiti tsconfig paths 수정)를 병합합니다. main으로의 두 번째 push가 release.yml을 트리거합니다. |
| 2026-05-11 19:16:22 | 워크플로우 실행 25691781302이 시작됩니다. 동일하게 오염된 캐시가 복원됩니다. |
| 2026-05-11 19:26:14 | npm 레지스트리가 각 패키지당 두 번째 버전 세트(@tanstack/history@1.161.12 등)의 게시 요청을 받습니다. 동일한 OIDC 메커니즘입니다. |
| 2026-05-11 19:26:20 | 실행 25691781302이 완료됩니다(상태: 실패). |
| 시간 | 사건 |
|---|---|
| 2026-05-11 ~19:50 | StepSecurity에서 일하는 외부 연구자 ashishkurmi가 악성 optionalDependencies 핑거프린트와 패키지 목록(처음에는 42개 중 14개)에 대한 상세 분석과 함께 이슈 #7383을 엽니다. |
| 2026-05-11 ~19:50 | 연구자가 npm 보안 팀에 직접 알립니다. |
| 2026-05-11 ~20:00 | Manuel이 이슈 #7383에서 응답합니다. 사고 대응이 시작됩니다. |
| 2026-05-11 ~20:10 | Manuel이 팀원 머신이 침해되었을 가능성에 대비해 GitHub에서 다른 모든 팀원의 push 권한을 제거합니다. |
| 2026-05-11 ~20:30 | Tanner가 전체 IOC(Indicator of Compromise, 침해 지표) 목록과 함께 레지스트리 측에서 tarball을 내려달라는 요청을 security@npmjs.com으로 보냅니다. npm을 통한 공식 멀웨어 신고도 제출합니다. |
| 2026-05-11 ~21:00 | 295개 @tanstack/* 패키지를 종합적으로 스캔해 영향 범위가 42개 패키지, 84개 버전임을 확인합니다. Tanner가 영향받은 84개 버전 전체에 대한 npm deprecation 절차를 시작합니다. @tan_stack과 메인테이너들이 Twitter/X/LinkedIn/Bluesky로 공개 공지를 냅니다. |
| 2026-05-11 21:30 | 조사를 통해 bundle-size.yml의 pull_request_target 캐시 포이즈닝 벡터와 zblgg/configuration fork를 식별합니다. 모든 TanStack/* GitHub 저장소의 캐시 엔트리를 API로 일괄 삭제합니다. 보안 강화(Hardening) PR이 병합됩니다. bundle-size.yml이 재구성되고, repository_owner 가드가 추가되었으며, 서드파티 액션 ref가 SHA로 고정되었습니다. 공식 GitHub Security Advisory가 게시되고 CVE가 요청됩니다. |
세 가지 취약점이 연쇄적으로 결합되어 작용했습니다. 각각은 공격에 필수적인 요소이며, 어느 하나만으로는 공격이 성립하지 않습니다.
bundle-size.yml의 pull_request_target "Pwn Request" 패턴bundle-size.yml은 fork PR에 대해 pull_request_target을 실행하고, 그 트리거 컨텍스트 안에서 fork의 PR-merge ref를 체크아웃한 뒤 빌드를 실행했습니다.
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
워크플로우 작성자는 신뢰 경계를 분리하려고 했습니다. comment-pr job이 benchmark-pr과 분리되어 있고, YAML에는 benchmark-pr을 "읽기 전용 권한을 가진 신뢰하지 않는 작업으로 유지하려 했다"는 의도가 주석으로 남아 있습니다. 분리 자체는 취지에 부합하지만, 두 가지 사실을 놓쳤습니다.
actions/cache@v5의 post-job save는 permissions: 설정으로 제한되지 않습니다. 캐시 쓰기는 워크플로우 GITHUB_TOKEN이 아니라 러너 내부 토큰을 사용하므로 permissions: contents: read로 설정해도 캐시 변경을 막을 수 없습니다.pull_request_target 실행(베이스 저장소의 캐시 스코프를 사용)과 main으로의 push 사이에 공유됩니다. 베이스 저장소 캐시 스코프에서 실행되는 PR은 나중에 main에서 프로덕션 워크플로우가 복원할 캐시 엔트리를 오염시킬 수 있습니다.악성 vite_setup.mjs는 정상 release.yml 워크플로우가 계산하고 조회할 키에 맞춰 pnpm-store 디렉터리에 데이터를 쓰도록 설계되어 있었습니다. 키는 Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}입니다. benchmark-pr job이 끝나면 actions/cache@v5의 post-step이 (이미 오염된) pnpm store를 정확히 그 키로 저장했습니다. 이후 main으로 push가 발생해 release.yml이 실행되자 Setup Tools 단계가 오염된 엔트리를 복원했고 흐름은 공격자의 의도대로 진행되었습니다.
이는 Adnan Khan이 2024년에 문서화한 공격 부류에 해당합니다. TanStack에 특화된 버그가 아니라, 명시적인 완화 조치가 필요한 GitHub Actions의 알려진 설계 이슈입니다.
release.yml은 id-token: write를 선언합니다(npm OIDC trusted publishing에 필요한 설정입니다). 오염된 pnpm store가 러너에 복원되면 공격자가 제어하는 바이너리가 디스크에 놓이고, 빌드 단계 중에 그 바이너리들이 호출됩니다. 이 바이너리들은 다음 작업을 수행합니다.
/proc/*/cmdline으로 GitHub Actions의 Runner.Worker 프로세스를 찾습니다./proc/<pid>/maps와 /proc/<pid>/mem을 읽어 워커의 메모리를 덤프합니다.id-token: write가 설정되어 있을 때 토큰을 지연 발급하며, 메모리에 보관합니다).registry.npmjs.org에 직접 POST 요청을 인증하며 워크플로우의 Publish Packages 단계를 완전히 우회합니다.2025년 3월 tj-actions/changed-files 침해 사고에서 사용된 것과 동일한 메모리 추출 기법입니다. 출처 주석까지 똑같이 남긴 Python 스크립트를 그대로 가져왔습니다. 공격자가 만든 것은 새 기법이 아니라 이미 공개된 연구의 재조합한 것입니다.
pull_request_target만으로는 라벨링, 코멘트 같은 신뢰된 작업에 사용하는 한 문제가 없습니다.이 체인이 작동하는 이유는 각 취약점이 다른 취약점들이 전제한 신뢰 경계를 차례대로 넘어가기 때문입니다. PR fork 코드가 베이스 저장소 캐시로 흘러 들어가고, 그 캐시가 릴리즈 워크플로우 런타임으로, 다시 릴리즈 워크플로우 런타임이 npm 레지스트리 쓰기 권한으로 이어집니다.
감지는 외부에서 이루어졌습니다. StepSecurity에서 일하는 외부 연구자 ashishkurmi가 게시 후 약 20분 만에 상세 기술 분석과 함께 이슈 #7383을 열었습니다. Tanner는 워룸을 막 시작한 직후 Socket.dev로부터 상황을 확인하는 전화를 받았습니다.
@tanstack/* 패키지의 매니페스트에서 다음과 같은 항목을 확인할 수 있습니다.
"optionalDependencies": {
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}
router_init.js(~2.3 MB, 패키지 루트, "files"에 포함되지 않음)Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11https://litter.catbox.moe/h8nc9u.js, https://litter.catbox.moe/7rrc6l.mjsfilev2.getsession.org, seed{1,2,3}.getsession.orgclaude <claude@users.noreply.github.com>(참고: 진짜 Anthropic Claude가 아니라 위조된 GitHub no-reply 이메일입니다)zblgg(id 127806521), voicproducoes(id 269549300)79ac49eedf774dd4b0cfa308722bc463cfe5885cpull_request_target 워크플로우는 오랫동안 알려진 위험한 패턴이었음에도 한 번도 감사된 적이 없었습니다.@v6.0.2, @main)는 이번 사고와 무관하게도 상시적인 공급망 리스크를 만듭니다.포스트모템을 마무리하기 전에 다음 질문에 답해야 합니다.
bundle-size.yml의 Setup Tools 단계가 실제로 actions/cache@v5를 호출했는가? PR #7378에 대한 pull_request_target 실행 중 하나(예: 실행 id 25666610798)의 post-job 로그를 읽어 검증해야 합니다. Tanner에게 접근 권한이 있으며, 수동으로 확인해야 합니다.gh api 또는 GitHub 지원팀을 통해 확인해야 합니다.voicproducoes는 실제 계정이었는가, 위장 계정(sock puppet)이었는가? 활동 이력을 교차 확인해야 합니다.linux-npm-store-* 엔트리 6개)? 그 중 실제로 사용된 것이 있는가?TanStack/router fork 네트워크에서 고립된 페이로드 커밋을 담고 있는 다른 fork를 식별할 수 있는가? (있다면 정리가 더 어려워집니다. 그 커밋을 보관하는 모든 fork가 github:tanstack/router#79ac49ee...로 접근 가능하게 유지됩니다.)bundle-size.yml 스타일 패턴을 사용하고 있는가? 감사해야 합니다.영향받은 버전의 전체 목록은 GitHub Security Advisory를 참조하세요. GHSA-g7cv-rxg3-hmpx