6분 만에 84개 버전이 올라갔다
2026년 5월 11일, @tanstack/* 패키지 42개에 악성 버전 84개가 올라갔다. 19시 20분에 시작해서 19시 26분에 끝났다. 6분이다. 이 글의 시각은 전부 UTC 기준이다.
설치하면 뭘 하는지가 문제였다. AWS IMDS와 Secrets Manager, GCP 메타데이터, 쿠버네티스 서비스 계정 토큰, Vault 토큰, ~/.npmrc, GitHub 토큰, SSH 개인키를 긁어간다. 그리고 훔친 npm 자격 증명으로 그 계정이 관리하는 다른 패키지를 찾아내 같은 걸 심는다. 2.3MB짜리 스크립트가 설치할 때 자동으로 실행되니까 pnpm install 한 번이면 끝이었다.
TanStack 팀은 사고 뒤에 postmortem을 공개했다. 타임라인부터 못 막은 지점까지 꽤 솔직하게 적혀 있다. 읽어보니 npm 사고 기록이라기보다 우리가 평소에 어디를 안 보고 있는지 짚어주는 글에 가까웠다. 읽는 내내 괜히 뜨끔했다.
공격은 의존성이 아니라 워크플로로 들어왔다
공급망 공격이라고 하면 보통 이런 걸 떠올린다. 어떤 라이브러리가 뚫려서, 그걸 의존하는 내 프로젝트까지 딸려 들어오는 것. 그래서 우리가 하는 방어도 거기 맞춰져 있다. npm audit을 돌리고, Dependabot을 켜고, lockfile을 커밋한다. 전부 package.json을 들여다보는 일이다.
이번에 뚫린 건 package.json이 아니었다. .github/workflows/bundle-size.yml이었다.
이 워크플로는 pull_request_target으로 돌고 있었다. 이 트리거는 fork에서 온 PR을 원본 저장소 권한으로 실행한다. 그러면서 PR의 merge ref를 체크아웃해서 빌드까지 했다. 남이 보낸 코드를 내 권한으로 실행한 거다.
이건 새로 발견된 취약점이 아니다. GitHub 공식 문서에 위험하다고 적혀 있고 보안 쪽에서는 Pwn Request라고 부르는 패턴이다. 알려진 지 몇 년 됐다.
그런데도 남아 있었다. 이유는 얼추 짐작이 간다. 워크플로 파일은 도통 리뷰 대상으로 안 본다. 기능 코드 PR은 두 명이 붙어서 보는데, CI 설정 고치는 PR은 "CI 고침" 한 줄 붙어서 그냥 머지된다. 나도 그랬다.

준비 과정도 볼 만하다. 공격자는 fork 저장소 이름을 configuration으로 지었다. 알림 목록에서 눈에 안 띄게 하려던 것 같다. 악성 커밋의 작성자도 claude <claude@users.noreply.github.com>으로 위조해뒀다. PR 제목은 "WIP: simplify history build"였다. 전부 대충 넘길 만하게 생긴 것들이다.
오염된 캐시가 8시간 뒤에 쓰였다
이 대목이 제일 이상했다. 몇 번을 다시 읽었다.
캐시가 오염된 시각은 11시 29분이다. 실제로 터진 건 19시 15분이다. 그 사이 8시간 가까이 아무 일도 안 일어났다. 공격자는 PR을 원래 커밋으로 되돌려놓고 닫은 다음 지워버렸다. 저장소에는 흔적이 없었다. 캐시만 남았다.
이게 되는 이유가 있다. GitHub Actions의 캐시 쓰기는 워크플로의 GITHUB_TOKEN 권한 체계를 안 탄다. 러너 내부 토큰을 쓴다. 그래서 permissions를 아무리 조여놔도 캐시에 뭘 쓰는 건 안 막힌다. 나중에 릴리스 워크플로가 키만 맞으면 fork PR이 저장해 둔 1.1GB짜리 pnpm store 캐시를 그대로 복원한다.
우리는 캐시를 그저 빌드 빠르게 하려고 쓰는 거라고만 생각한다. 그런데 캐시는 누가 넣었는지 안 묻고 꺼내 쓰는 저장소이기도 하다. 전에 캐시 무효화를 쓰면서 "이거, 아직 안 바뀌었나?"가 캐시의 어려운 질문이라고 했는데, 여기엔 질문이 하나 더 있었다. "이거, 우리가 넣은 거 맞나."
이 질문을 하는 도구는 아직 못 봤다.
OIDC는 토큰을 없앤 게 아니라 옮겼다
캐시에서 복원된 코드는 러너 프로세스의 메모리를 읽었다. /proc/<pid>/maps와 /proc/<pid>/mem을 열어서 워커 메모리를 덤프하고, 거기서 npm 배포용 OIDC 토큰을 뽑아냈다. 그 토큰으로 npm 레지스트리에 직접 POST를 날렸다. 정식 배포 단계는 아예 거치지 않았다.
npm이 trusted publishing을 정식 기능으로 내놓은 게 2025년 7월 31일이다. CI에서 OIDC로 인증해 패키지를 올리는 방식이다. 공지에는 이렇게 적혀 있다.
Eliminate token security risks: No more storing, rotating, or accidentally exposing npm tokens in your CI/CD environments.
Establish cryptographic trust: Each publish is authenticated using short-lived, workflow-specific credentials that cannot be exfiltrated or reused.
첫 줄은 토큰을 저장하고 교체하고 실수로 노출할 일이 없어진다는 얘기다. 맞는 말이고, 실제로 좋은 기능이다. CI에 만료 없는 토큰을 하드코딩해 두던 시절보다 나은 건 분명하다.
걸리는 건 두 번째 줄이다. 수명이 짧고 워크플로에 묶인 자격 증명이라 빼내거나 재사용할 수 없다고 적혀 있는데, 이번 공격이 한 게 정확히 빼내서 재사용하는 거였다. 토큰 수명이 짧은 건 맞다. 다만 만료되기 전까지는 메모리 어딘가에 있고, 같은 러너에서 도는 코드는 그걸 읽을 수 있다. postmortem이 짚은 것도 이 지점이다.
OIDC trusted-publisher binding has no per-publish review. Once configured, any code path in the workflow can mint a publish-capable token.
일단 설정해두면 워크플로 안의 어떤 코드 경로든 배포 권한이 있는 토큰을 발급받는다. 훔칠 토큰이 사라진 게 아니라, 토큰을 발급받을 수 있는 지점이 워크플로 전체로 넓어졌다.

안전장치를 도입하는 건 위험을 없애는 일이 아니라 옮기는 일에 가깝다. 공격 대상이 메인테이너의 노트북에서 CI 러너로 옮겨갔다. 그러면 감시도 거기로 옮겼어야 하는데, 그게 안 됐다. 도구를 바꾸고 나서 "이제 안전하다"로 끝내면 딱 여기서 걸린다. 남 일 같지 않은 얘기다.
참고로 메모리를 덤프한 그 스크립트는 새로 짠 것도 아니었다. 2025년 3월 tj-actions/changed-files 사고에서 쓰인 파이썬 코드를 출처 주석까지 붙인 채로 그대로 가져다 썼다. 공격자도 남이 쓴 걸 가져다 쓴다.
지우지 못했다
사고를 알고 나서도 바로 못 지웠다.
npm에는 다른 패키지가 의존하고 있으면 unpublish를 못 하게 하는 정책이 있다. 2016년 left-pad 사고 이후에 생긴 규칙이다. 패키지 하나가 사라져서 생태계 절반의 빌드가 깨지는 일을 다시는 만들지 말자는 것이었고 그 판단은 지금 봐도 맞다.
그런데 하필 이번엔 그 규칙 때문에 메인테이너가 자기 악성 버전을 스스로 못 내렸다. deprecated 처리만 해두고 실제 파일을 내리는 건 npm 쪽에 요청해서 서버에서 처리해야 했다. 84개 전부 deprecated 처리까지 1시간 43분, 마지막 tarball이 내려가기까지 4시간 35분이 걸렸다.
전에 되돌릴 수 있는 결정과 없는 결정을 쓰면서 못 되돌리는 걸 되돌릴 수 있게 만드는 게 좋은 설계라고 했는데, 이건 반대 사례다. 안전을 위해 일부러 못 지우게 막아뒀는데, 정작 급할 때 그것 때문에 못 지웠다.
npm이 잘못했다는 얘기는 아니다. left-pad 쪽 위험과 이번 쪽 위험 중에 뭘 막을지 골랐을 뿐이다. 다만 안전장치를 걸 때 "그래서 사고 나면 어떻게 푸나"를 같이 정해두지 않으면 대응 시간이 그만큼 늘어난다.
잡은 건 실력이 아니라 운이었다
탐지는 26분 만에 됐다. 빠른 편이다. 그런데 잡은 사람이 메인테이너가 아니었다. StepSecurity의 외부 연구자였다. 팀은 제3자한테 들었다. postmortem에도 그렇게 적혀 있다. 자기 패키지가 자기 이름으로 배포되는 걸 지켜보는 사람이 아무도 없었다는 뜻이다.
운도 따랐다. 심어놓은 페이로드가 테스트를 깨뜨렸다. 그래서 배포 단계 일부가 그냥 넘어갔고, 결과물이 이상해진 게 눈에 띄었다. postmortem은 이걸 이렇게 정리한다.
A more careful attacker who didn't break tests could have published silently for hours longer.
테스트를 안 깨뜨리는 공격자였으면 몇 시간 더 조용히 배포할 수 있었다.
대응은 빨랐지만 탐지는 우연이었다. 이 둘을 구분해서 봐야 한다. 대응 속도만 보고 "잘 막았다"고 정리하면, 다음에 조용한 공격자가 왔을 때 똑같이 못 잡는다.
그럼 우리는 뭘 바꿔야 하나
세 가지 정도가 남는다.
워크플로 파일도 코드다. package.json 변경에는 눈이 가는데 .github/workflows/ 변경은 그냥 넘어간다. 정작 그 파일들은 우리 저장소에서 권한이 제일 센 코드다. pull_request_target을 쓰는 워크플로가 있는지, 서드파티 액션을 태그로 참조하는지 정도는 한 번 훑어볼 만하다. TanStack도 사고 후에 액션 참조를 전부 커밋 SHA로 고정했다.
안전장치를 도입했으면 감시도 같이 옮겨야 한다. OIDC로 바꾸는 건 좋은 선택이다. 다만 그건 "이제 토큰 걱정 없다"가 아니라 "이제 CI 러너가 공격 대상이다"라는 뜻이다. 새 도구를 넣을 때마다 위험이 어디로 갔는지 한 번 물어보는 게 낫다.
일부러 못 되돌리게 막아둔 것 중에 사고 때 발목 잡을 게 있는지 본다. unpublish 금지가 딱 그거였다. 되돌릴 수 없게 만드는 결정은 대개 이유가 있어서 내린 건데, 그 이유가 사고 상황까지 계산에 넣은 건지는 따로 확인해야 한다. 삭제 방지, 로그 변경 금지, 강제 승인 절차 같은 것들이 다 여기 해당한다.
하나 더 있는데 이건 한참 생각해도 답이 안 나왔다. TanStack에는 배포 권한을 가진 메인테이너가 일곱 명이었다. 훔칠 대상이 일곱 개고 어느 계정이 뚫리든 피해 범위는 똑같다. 그렇다고 권한 가진 사람을 줄이면 한 명이 자리를 비웠을 때 릴리스가 멈춘다. 오픈소스에서는 이쪽도 저쪽도 답이 아니다.
정리하면 이번 사고에서 새로 발견된 취약점은 하나도 없었다. pull_request_target도, 캐시가 권한 체계를 안 탄다는 것도, 메모리에서 토큰을 뽑는 기법도 전부 이미 알려져 있었다. 알려진 것 셋을 이어 붙였을 뿐이다. 개별 기능은 다 문서에 적혀 있는데, 그걸 이어 붙이면 어떻게 되는지 정리해둔 사람이 없었다.
참고 자료
- TanStack, Postmortem: TanStack npm supply-chain compromise (2026)
- GitHub Security Advisory, GHSA-g7cv-rxg3-hmpx, 영향받은 패키지와 버전 전체 목록
- GitHub Changelog, npm trusted publishing with OIDC is generally available (2025)
- npm Docs, Trusted publishing for npm packages