[번역] 포스트모템: TanStack npm 공급망 침해 사고

Sonny·2026년 6월 4일

Article

목록 보기
44/44
post-thumbnail

원문: https://tanstack.com/blog/npm-supply-chain-compromise-postmortem

TL;DR

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를 실행합니다. 이 스크립트는 다음 동작을 수행합니다.

  • 일반적인 위치에서 자격 증명을 수집합니다. 대상은 AWS IMDS / Secrets Manager, GCP 메타데이터, Kubernetes 서비스 계정 토큰, Vault 토큰, ~/.npmrc, GitHub 토큰(환경 변수, gh CLI, .git-credentials), SSH 개인 키입니다.
  • Session/Oxen 메신저 파일 업로드 네트워크(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:49zblgg가 "WIP: simplify history build"라는 제목으로 TanStack/router#main을 대상으로 PR #7378을 엽니다.
2026-05-11 10:49 onwardsbundle-size.ymllabeler.yml(둘 다 pull_request_target)이 PR에 대해 자동으로 실행됩니다. pull_request_target은 first-time-contributor 승인 게이트를 우회하므로 별도의 승인이 필요하지 않습니다. pull_request를 사용하는 pr.yml은 실행되지 않았고, 끝내 오지 않을 승인을 기다리며 대기 상태로 남았습니다.
2026-05-11 11:01–11:11zblgg가 PR head에 여러 차례 force-push를 진행하고, 그때마다 pull_request_target 실행이 추가로 트리거됩니다.
2026-05-11 11:11force-push로 65bf499d(악성 커밋)가 PR head에 올라옵니다. 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캐시 엔트리 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:15Manuel이 PR #7369(Shkumbin의 CSS.supports 수정)를 병합합니다. main으로의 push가 release.yml을 트리거합니다. 워크플로우 실행 25613093674이 19:15:44에 시작되지만, 실패로 끝납니다.
2026-05-11 19:20:39npm 레지스트리가 @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:16Manuel이 PR #7382(jiti tsconfig paths 수정)를 병합합니다. main으로의 두 번째 push가 release.yml을 트리거합니다.
2026-05-11 19:16:22워크플로우 실행 25691781302이 시작됩니다. 동일하게 오염된 캐시가 복원됩니다.
2026-05-11 19:26:14npm 레지스트리가 각 패키지당 두 번째 버전 세트(@tanstack/history@1.161.12 등)의 게시 요청을 받습니다. 동일한 OIDC 메커니즘입니다.
2026-05-11 19:26:20실행 25691781302이 완료됩니다(상태: 실패).

감지와 대응

시간사건
2026-05-11 ~19:50StepSecurity에서 일하는 외부 연구자 ashishkurmi가 악성 optionalDependencies 핑거프린트와 패키지 목록(처음에는 42개 중 14개)에 대한 상세 분석과 함께 이슈 #7383을 엽니다.
2026-05-11 ~19:50연구자가 npm 보안 팀에 직접 알립니다.
2026-05-11 ~20:00Manuel이 이슈 #7383에서 응답합니다. 사고 대응이 시작됩니다.
2026-05-11 ~20:10Manuel이 팀원 머신이 침해되었을 가능성에 대비해 GitHub에서 다른 모든 팀원의 push 권한을 제거합니다.
2026-05-11 ~20:30Tanner가 전체 IOC(Indicator of Compromise, 침해 지표) 목록과 함께 레지스트리 측에서 tarball을 내려달라는 요청을 security@npmjs.com으로 보냅니다. npm을 통한 공식 멀웨어 신고도 제출합니다.
2026-05-11 ~21:00295개 @tanstack/* 패키지를 종합적으로 스캔해 영향 범위가 42개 패키지, 84개 버전임을 확인합니다. Tanner가 영향받은 84개 버전 전체에 대한 npm deprecation 절차를 시작합니다. @tan_stack과 메인테이너들이 Twitter/X/LinkedIn/Bluesky로 공개 공지를 냅니다.
2026-05-11 21:30조사를 통해 bundle-size.ymlpull_request_target 캐시 포이즈닝 벡터와 zblgg/configuration fork를 식별합니다. 모든 TanStack/* GitHub 저장소의 캐시 엔트리를 API로 일괄 삭제합니다. 보안 강화(Hardening) PR이 병합됩니다. bundle-size.yml이 재구성되고, repository_owner 가드가 추가되었으며, 서드파티 액션 ref가 SHA로 고정되었습니다. 공식 GitHub Security Advisory가 게시되고 CVE가 요청됩니다.

근본 원인

세 가지 취약점이 연쇄적으로 결합되어 작용했습니다. 각각은 공격에 필수적인 요소이며, 어느 하나만으로는 공격이 성립하지 않습니다.

1. bundle-size.ymlpull_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에서 프로덕션 워크플로우가 복원할 캐시 엔트리를 오염시킬 수 있습니다.

2. 신뢰 경계를 가로지르는 GitHub Actions 캐시 포이즈닝

악성 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의 알려진 설계 이슈입니다.

3. 러너 메모리에서의 OIDC 토큰 추출

release.ymlid-token: write를 선언합니다(npm OIDC trusted publishing에 필요한 설정입니다). 오염된 pnpm store가 러너에 복원되면 공격자가 제어하는 바이너리가 디스크에 놓이고, 빌드 단계 중에 그 바이너리들이 호출됩니다. 이 바이너리들은 다음 작업을 수행합니다.

  • /proc/*/cmdline으로 GitHub Actions의 Runner.Worker 프로세스를 찾습니다.
  • /proc/<pid>/maps/proc/<pid>/mem을 읽어 워커의 메모리를 덤프합니다.
  • OIDC 토큰을 추출합니다(러너는 id-token: write가 설정되어 있을 때 토큰을 지연 발급하며, 메모리에 보관합니다).
  • 그 토큰으로 registry.npmjs.org에 직접 POST 요청을 인증하며 워크플로우의 Publish Packages 단계를 완전히 우회합니다.

2025년 3월 tj-actions/changed-files 침해 사고에서 사용된 것과 동일한 메모리 추출 기법입니다. 출처 주석까지 똑같이 남긴 Python 스크립트를 그대로 가져왔습니다. 공격자가 만든 것은 새 기법이 아니라 이미 공개된 연구의 재조합한 것입니다.

단독으로는 부족한 이유

  • pull_request_target만으로는 라벨링, 코멘트 같은 신뢰된 작업에 사용하는 한 문제가 없습니다.
  • 캐시 포이즈닝만으로는(예: 이미 침해된 의존성 내부에서 발생한 경우) 별도의 게시 수단이 있어야 합니다.
  • OIDC 토큰 추출만으로는 역시 러너에서 이미 코드 실행 권한이 확보되어 있어야 가능합니다.

이 체인이 작동하는 이유는 각 취약점이 다른 취약점들이 전제한 신뢰 경계를 차례대로 넘어가기 때문입니다. PR fork 코드가 베이스 저장소 캐시로 흘러 들어가고, 그 캐시가 릴리즈 워크플로우 런타임으로, 다시 릴리즈 워크플로우 런타임이 npm 레지스트리 쓰기 권한으로 이어집니다.

감지

우리가 어떻게 알게 되었는가

감지는 외부에서 이루어졌습니다. StepSecurity에서 일하는 외부 연구자 ashishkurmi가 게시 후 약 20분 만에 상세 기술 분석과 함께 이슈 #7383을 열었습니다. Tanner는 워룸을 막 시작한 직후 Socket.dev로부터 상황을 확인하는 전화를 받았습니다.

IOC 핑거프린트(다운스트림 메인테이너와 보안 도구를 위한 정보)

@tanstack/* 패키지의 매니페스트에서 다음과 같은 항목을 확인할 수 있습니다.

"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}
  • 파일: router_init.js(~2.3 MB, 패키지 루트, "files"에 포함되지 않음)
  • 캐시 키: Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11
  • 2단계 페이로드 URL: https://litter.catbox.moe/h8nc9u.js, https://litter.catbox.moe/7rrc6l.mjs
  • 유출 네트워크: filev2.getsession.org, seed{1,2,3}.getsession.org
  • 위조된 커밋 신원: claude <claude@users.noreply.github.com>(참고: 진짜 Anthropic Claude가 아니라 위조된 GitHub no-reply 이메일입니다)
  • 실제 공격자 계정: zblgg(id 127806521), voicproducoes(id 269549300)
  • 공격자 fork: github.com/zblgg/configuration(TanStack/router의 fork 저장소이며, fork 검색을 피하려고 이름을 변경)
  • 고립된 페이로드 커밋(fork 네트워크 내): 79ac49eedf774dd4b0cfa308722bc463cfe5885c
  • 악성 게시를 수행한 워크플로우 실행:

배운 점

잘된 점

  • 외부 연구자들이 사고 발생 약 20분 만에 인지하고 상세한 기술 세부 정보를 담아 신고했습니다.
  • 메인테이너 팀이 여러 시간대에 걸쳐 즉각적이고 효과적으로 협력했습니다.
  • 감지 커뮤니티가 몇 시간 안에 명확한 공개 IOC 패턴을 정리했습니다.

더 잘할 수 있었던 점

  • 내부 알림이 없었습니다. 우리는 침해 사실을 제3자에게서 처음 알게 되었습니다. 자체 게시를 모니터링해야 합니다. 이런 이슈를 매우 빠르게, 잠재적으로는 사내에서도 감지할 수 있는 생태계 내 보안 연구자 회사들과 긴밀히 협력하면서 피드백 루프를 더 촘촘하게 만들 계획입니다.
  • pull_request_target 워크플로우는 오랫동안 알려진 위험한 패턴이었음에도 한 번도 감사된 적이 없었습니다.
  • 서드파티 액션의 떠다니는 ref(@v6.0.2, @main)는 이번 사고와 무관하게도 상시적인 공급망 리스크를 만듭니다.
  • npm의 "의존자가 있으면 unpublish 불가" 정책 때문에 영향받은 거의 모든 패키지에 대해 unpublish가 불가능했습니다. 서버 측에서 tarball을 내리는 작업은 npm 보안 팀에 의존해야 했고, 그 사이 악성 tarball이 설치 가능한 상태로 남아 있었습니다.
  • npm 스코프에 메인테이너 7명이 등록되어 있다는 것은, 같은 영향 범위를 노리는 자격 증명 탈취 표적이 7개나 된다는 의미입니다.
  • OIDC trusted-publisher 바인딩에는 게시별 검토가 없습니다. 한 번 설정되면 워크플로우의 어떤 코드 경로에서든 게시 권한이 있는 토큰을 발급할 수 있습니다. (a) 수동 검토가 가능한 짧은 수명의 클래식 토큰으로 옮기거나, (b) 예상치 못한 워크플로우 단계에서 발생한 게시를 감지할 수 있도록 provenance-source-verification을 추가하는 것 중 하나가 필요합니다.

운이 좋았던 점

  • 공격자가 테스트를 깨는 페이로드를 선택했고, 그 덕분에 (더 깔끔해 보이는 tarball을 만들었을) 게시 단계는 실행되지 않았습니다. 결과적으로 공격이 충분히 시끄러워 빠르게 감지됐습니다. 테스트를 깨지 않은 더 신중한 공격자였다면 훨씬 더 오랜 시간 조용히 게시했을 것입니다.
  • 공격자가 새로운 코드를 작성하지 않고 공개된 기법(출처 주석까지 그대로 둔 메모리 덤프 스크립트)을 재사용했고, 덕분에 IOC 매칭이 빨라졌습니다.

미해결 질문

포스트모템을 마무리하기 전에 다음 질문에 답해야 합니다.

  • bundle-size.yml의 Setup Tools 단계가 실제로 actions/cache@v5를 호출했는가? PR #7378에 대한 pull_request_target 실행 중 하나(예: 실행 id 25666610798)의 post-job 로그를 읽어 검증해야 합니다. Tanner에게 접근 권한이 있으며, 수동으로 확인해야 합니다.
  • force-push로 지워지기 전, 초기 PR head 커밋에는 무엇이 있었는가? GitHub의 reflog에 있을 가능성이 있습니다. gh api 또는 GitHub 지원팀을 통해 확인해야 합니다.
  • 악성 커밋이 fork의 git object store에 들어간 경로는 정확히 어떻게 되었는가 — git으로 직접 push되었는가, 아니면 GitHub 웹 UI로 생성되었는가(이 경우 감사 로그 엔트리가 남았을 것임)?
  • voicproducoes는 실제 계정이었는가, 위장 계정(sock puppet)이었는가? 활동 이력을 교차 확인해야 합니다.
  • npm 캐시도 오염되었는가(중복 linux-npm-store-* 엔트리 6개)? 그 중 실제로 사용된 것이 있는가?
  • TanStack/router fork 네트워크에서 고립된 페이로드 커밋을 담고 있는 다른 fork를 식별할 수 있는가? (있다면 정리가 더 어려워집니다. 그 커밋을 보관하는 모든 fork가 github:tanstack/router#79ac49ee...로 접근 가능하게 유지됩니다.)
  • 다른 TanStack 저장소(router, query, table, form, virtual 등)에서 동일한 bundle-size.yml 스타일 패턴을 사용하고 있는가? 감사해야 합니다.
  • 게시 윈도우 동안 영향받은 버전을 실제로 다운로드한 사용자는 얼마나 되는가? npm 지원팀에서 받아야 합니다.
  • 나열된 7명의 메인테이너 중 머신이 별도로 침해된 사람이 있는가? (악성 게시 중 어느 것도 메인테이너의 npm 토큰을 사용하지 않았지만, 자가 전파 로직으로 메인테이너 머신이 2차 표적이 될 수 있었습니다.)

참고 자료

부록 A — 영향받은 버전

영향받은 버전의 전체 목록은 GitHub Security Advisory를 참조하세요. GHSA-g7cv-rxg3-hmpx

profile
FrontEnd Developer

0개의 댓글