Ray Book
에세이

TanStack npm 침해 사고

들어온 경로는 의존성이 아니라 GitHub Actions 워크플로였다.

essaysecuritysupply-chainnpmci-cdopinion

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 고침" 한 줄 붙어서 그냥 머지된다. 나도 그랬다.

TanStack postmortem의 공격 준비 단계 타임라인 표. 5월 10일 17시 16분 fork 생성부터 5월 11일 11시 31분 PR 삭제까지 적혀 있다
공격 준비 단계 타임라인. 마지막 줄이 PR을 닫고 브랜치까지 지웠지만 캐시 오염은 남았다는 내용이다. TanStack Blog

준비 과정도 볼 만하다. 공격자는 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.

일단 설정해두면 워크플로 안의 어떤 코드 경로든 배포 권한이 있는 토큰을 발급받는다. 훔칠 토큰이 사라진 게 아니라, 토큰을 발급받을 수 있는 지점이 워크플로 전체로 넓어졌다.

npm trusted publishing with OIDC is generally available 라는 제목의 GitHub Changelog 공지. 2025년 7월 31일. 토큰 없이 배포, 토큰 보안 위험 제거, 암호학적 신뢰 확립 항목이 나열돼 있다
사고 9개월 전의 공지. 빼내거나 재사용할 수 없는 자격 증명이라고 적혀 있다. GitHub Changelog

안전장치를 도입하는 건 위험을 없애는 일이 아니라 옮기는 일에 가깝다. 공격 대상이 메인테이너의 노트북에서 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도, 캐시가 권한 체계를 안 탄다는 것도, 메모리에서 토큰을 뽑는 기법도 전부 이미 알려져 있었다. 알려진 것 셋을 이어 붙였을 뿐이다. 개별 기능은 다 문서에 적혀 있는데, 그걸 이어 붙이면 어떻게 되는지 정리해둔 사람이 없었다.

참고 자료