- Published on
axios 계정 탈취 3시간 — provenance는 켜져 있었고, 아무도 확인하지 않았다
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 2026년 3월 31일 00:21 UTC
- 레지스트리가 남긴 영수증
- 침투 경로 — 토큰이 아니라 사람의 PC
- trusted publishing은 켜져 있었다
- 벤더 설명 하나가 타임라인과 어긋나는 지점
- 실제로 이걸 멈춘 건 사람이었다
- provenance는 무엇을 증명하나
- npm audit signatures를 직접 돌려보면
- 정작 효과가 있었을 통제 — 기다리기
- 폭발 반경은 아무도 모른다
- 그래서 무엇을 할 것인가
- 마치며
- 참고 자료
들어가며 — 2026년 3월 31일 00:21 UTC
공급망 보안 글은 대개 두 종류입니다. 사고가 터졌다는 뉴스거나, "SBOM을 도입하고 서명을 검증하세요"라는 일반론이거나. 둘 다 별로 도움이 안 됩니다. 앞의 것은 무슨 통제가 왜 실패했는지 안 알려주고, 뒤의 것은 이미 그 통제를 켜 둔 사람이 왜 당했는지 설명하지 못합니다.
2026년 3월 31일 axios 사건은 후자에 정확히 해당합니다. axios는 이미 npm의 trusted publishing을 쓰고 있었습니다. 직전 정상 릴리스에는 SLSA provenance가 붙어 있었습니다. 유지보수자 계정에는 2FA가 켜져 있었습니다. 그리고 뚫렸습니다.
이 글이 다른 글과 다른 점은, 여기 나오는 타임스탬프 대부분을 제가 npm 레지스트리 API에서 직접 확인했다는 것입니다. 그리고 여러분도 지금 그대로 확인할 수 있습니다. 사건은 끝났지만 영수증은 레지스트리에 남아 있습니다.
레지스트리가 남긴 영수증
npm 레지스트리의 패키지 메타데이터에는 time 맵이 있습니다. 여기엔 재미있는 성질이 하나 있는데, 버전이 unpublish 되어도 time 항목은 남는다는 겁니다. 즉 삭제된 악성 버전이 언제 올라왔는지가 지금도 공개돼 있습니다.
직접 해 보세요.
curl -s https://registry.npmjs.org/axios | python3 -c "
import sys, json
d = json.load(sys.stdin)
t = d['time']
for v in ['1.14.0', '1.14.1', '0.30.3', '0.30.4']:
print(v, t.get(v), '| 현재 존재:', v in d['versions'])
"
제가 2026년 7월 16일에 돌린 결과는 이렇습니다.
1.14.0 2026-03-27T19:01:40.915Z | 현재 존재: True
1.14.1 2026-03-31T00:21:58.168Z | 현재 존재: False
0.30.3 2026-02-18T17:19:20.081Z | 현재 존재: True
0.30.4 2026-03-31T01:00:57.285Z | 현재 존재: False
1.14.1과 0.30.4는 발행된 시각은 기록돼 있는데 지금은 존재하지 않습니다. 이게 unpublish의 흔적입니다. 그리고 이 두 타임스탬프는 유지보수자 Jason Saayman이 쓴 공식 포스트모템의 타임라인과 초 단위로 일치합니다.
주입된 의존성 쪽도 마찬가지입니다.
curl -s https://registry.npmjs.org/plain-crypto-js | python3 -m json.tool | head -20
plain-crypto-js의 time 맵에는 4.2.0이 2026-03-30T05:57:32.867Z, 4.2.1이 2026-03-30T23:59:12.278Z로 남아 있고, 현재 dist-tags.latest는 0.0.1-security.0입니다. repository는 npm/security-holder — npm이 악성 패키지를 지우고 이름을 선점해 두는 자리 표시자입니다. 그 자리 표시자가 올라간 시각이 2026-03-31T03:25:11.636Z입니다.
정리하면 공격 순서는 이렇습니다. 공격자는 먼저 3월 30일 05:57에 무해한 plain-crypto-js@4.2.0을 올려 자리를 잡고, 그날 밤 23:59에 악성 postinstall을 담은 4.2.1을 올린 뒤, 한 시간이 채 안 된 3월 31일 00:21에 그걸 의존성으로 끼운 axios@1.14.1을 발행했습니다. 미끼를 먼저 심고 본체를 나중에 건드린 겁니다.
침투 경로 — 토큰이 아니라 사람의 PC
포스트모템에 따르면 시작은 레지스트리가 아니라 사람이었습니다. 공격자는 약 2주에 걸친 표적 사회공학 캠페인으로 리드 유지보수자의 PC에 RAT(원격 접근 트로이목마)를 심었고, 거기서 npm 계정 자격증명을 획득했습니다.
여기서 중요한 건 2FA가 켜져 있었다는 점입니다(Datadog Security Labs 분석). 당연합니다 — 공격자가 유지보수자의 기계 안에 앉아 있으면 2FA는 통과 의례일 뿐입니다. 2FA는 "다른 곳에서 로그인하는 남"을 막는 통제지, "당신 컴퓨터가 된 남"을 막는 통제가 아닙니다.
페이로드는 macOS·Windows·Linux를 모두 노리는 크로스플랫폼 RAT였고, C2는 sfrclak[.]com(포트 8000)이었습니다. 포스트모템은 영향받았을 수 있는 사람에게 락파일에서 axios@1.14.1, axios@0.30.4, plain-crypto-js를 grep 해 보고, 걸리면 그 머신을 침해된 것으로 간주하고 모든 자격증명을 회전시키라고 안내합니다. 특히 CI 러너에서 걸렸다면 그 빌드에 주입된 모든 시크릿을 회전시키라고 합니다.
trusted publishing은 켜져 있었다
이제 이 사건에서 가장 흥미로운 부분입니다. axios는 이미 trusted publishing을 쓰고 있었습니다. 레지스트리에서 버전별 발행자와 provenance 유무를 뽑아 보면 바로 보입니다.
curl -s https://registry.npmjs.org/axios | python3 -c "
import sys, json
d = json.load(sys.stdin)
for v in ['1.13.0', '1.14.0', '1.18.1', '0.30.3']:
ver = d['versions'][v]
att = ver['dist'].get('attestations')
print(v, '| 발행자:', ver['_npmUser']['name'], '| provenance:', 'O' if att else 'X')
"
결과입니다.
1.13.0 | 발행자: jasonsaayman | provenance: O
1.14.0 | 발행자: GitHub Actions | provenance: O
1.18.1 | 발행자: GitHub Actions | provenance: O
0.30.3 | 발행자: jasonsaayman | provenance: X
여기서 두 가지가 읽힙니다.
첫째, 1.13.0과 1.14.0 사이에서 trusted publishing으로 넘어간 흔적이 그대로 보입니다. 발행자 이름이 개인 계정에서 GitHub Actions로 바뀌었습니다. 즉 공격 나흘 전인 3월 27일에 나간 정상 릴리스 1.14.0은 GitHub Actions가 OIDC로 발행한 것이고, provenance도 자동으로 붙었습니다.
둘째, 레거시 0.x 라인은 provenance가 아예 없었습니다. 개인 계정에서 수동으로 나가고 있었던 겁니다. 공격자가 1.14.1과 0.30.4를 함께 노린 게 우연이 아닐 수 있습니다 — 0.x 라인은 애초에 놓칠 신호조차 없는 무른 표적이었습니다.
그러면 질문이 이렇게 바뀝니다. trusted publishing을 켜 뒀는데 왜 개인 계정으로 발행이 됐나?
답은 npm 공식 문서에 그대로 적혀 있습니다. trusted publishing을 설정하면 npm은 승인된 워크플로의 발행을 받아 주되, 문서의 표현을 그대로 옮기면 "npm 토큰이나 수동 발행 같은 전통적 인증 수단에 더해서(in addition to)" 받아 줍니다. 즉 trusted publishing은 기본적으로 추가 경로이지 배타적 경로가 아닙니다. 토큰과 수동 발행 경로를 닫으려면 패키지 설정에서 "Require two-factor authentication and disallow tokens"를 따로 골라야 합니다.
그러니 정확히 말하면 trusted publishing이 뚫린 게 아닙니다. 암호학적으로 우회된 것도 아닙니다. 잠기지 않은 옆문이 그대로 있었고 공격자는 그리로 걸어 들어갔습니다. 유지보수자가 포스트모템의 "앞으로 바뀌는 것" 표에 "Proper adoption of OIDC flow for publishing"이라고 쓴 게 이 뜻입니다. 여기서 방점은 proper에 있습니다 — 켜는 게 아니라 유일하게 만드는 것.
Orca Security도 이 구조적 지점은 정확히 짚었습니다. 그들의 표현을 옮기면 "Trusted Publishing only works if it's the sole publishing path" — 유일한 배포 경로일 때만 작동한다는 것입니다. 이건 맞는 말이고, npm 문서가 뒷받침합니다.
벤더 설명 하나가 타임라인과 어긋나는 지점
다만 같은 Orca 글에는 이렇게도 적혀 있습니다 — "The likely entry point was a long-lived classic npm access token, which can sidestep 2FA requirements entirely." 오래된 classic 토큰이 남아 있었고 그게 입구였을 것 같다는 가설입니다. 그럴듯합니다. 레거시 토큰이 최신 통제를 우회하는 뒷문이었다는 서사는 깔끔하고, 보안 업계가 좋아하는 모양입니다.
문제는 날짜입니다. GitHub는 2025년 12월 9일 체인지로그에서 이렇게 못 박았습니다 — "We've permanently revoked all existing npm classic tokens. They can no longer authenticate, be recreated, or be recovered." 모든 classic 토큰이 영구 폐기됐고, 인증도 재생성도 복구도 안 된다는 겁니다. 같은 발표에서 npm login은 장기 토큰 대신 2시간짜리 세션 토큰을 주도록 바뀌었습니다.
axios 공격은 그로부터 약 3개월 반 뒤인 2026년 3월 31일입니다. 그 시점에 classic 토큰은 인증 자체가 불가능했습니다. 그러니 Orca의 가설은 성립하기 어렵습니다. 그리고 유지보수자 본인은 포스트모템에서 훨씬 단순한 경로를 말합니다 — 사회공학으로 PC에 RAT를 심었고, 거기서 자격증명을 가져갔다.
공정하게 말하면 Orca는 "likely"라고 헤지했고, 구조적 결론("유일한 경로여야 한다")은 맞았습니다. 저는 그들이 거짓말을 했다고 말하는 게 아닙니다. 다만 이건 사고 분석에서 흔한 실패 모드입니다 — 알려진 실패 유형에 사건을 끼워 맞추면, 결론이 우연히 맞더라도 대응은 엉뚱한 데로 갑니다. 이 가설을 믿으면 "잔여 classic 토큰을 감사하라"는 액션 아이템이 나오는데, 그건 이미 npm이 4개월 전에 전역으로 해결한 문제이고, 정작 진짜 문제였던 "유지보수자 워크스테이션 침해"와 "수동 발행 경로 개방"은 손도 못 댑니다.
교훈은 간단합니다. 벤더 블로그는 IOC와 기술 분석에는 유용하지만, 침투 경로 서사는 1차 포스트모템과 레지스트리 타임라인에 대조해 보세요. 이 경우 대조에 든 시간은 5분이었습니다.
실제로 이걸 멈춘 건 사람이었다
그래서 무엇이 막았을까요. provenance가 아닙니다. trusted publishing도, 2FA도, 어떤 스캐너도 아닙니다.
포스트모템의 문장을 그대로 옮기는 게 가장 정확합니다 — "There was no automated way to detect an unauthorized publish. Detection depended entirely on the community noticing." 무단 발행을 탐지할 자동화된 방법이 없었고, 탐지는 전적으로 커뮤니티가 알아채는 데 달려 있었다는 겁니다.
실제로 일어난 일은 이렇습니다. GitHub API로 확인한 PR #10591은 axios 협력자 DigitalBrainJS가 2026-03-31T01:38:47Z에 열어 01:42:15Z에 병합됐습니다. 제목은 밋밋하게 chore(ci): add deprecate action;입니다. 악성 발행(00:21)으로부터 77분 뒤입니다. 그는 손상된 버전을 deprecate 하고, 커뮤니티에 알리고, npm에 직접 연락했습니다. 그리고 03:15에 악성 버전이 레지스트리에서 내려갔습니다.
이 대목에서 놓치기 쉬운 디테일이 두 개 있습니다.
공격자는 신고를 지우고 있었습니다. 포스트모템에 따르면 01:00경 커뮤니티 구성원들이 침해를 신고하는 이슈를 올렸고, 공격자는 탈취한 계정으로 그 이슈들을 삭제했습니다. 계정 탈취는 발행 권한만 넘어가는 게 아닙니다 — 모더레이션 권한도 함께 넘어갑니다. 공격자는 배포하면서 동시에 신고를 지울 수 있었습니다.
대응자의 권한이 공격자보다 낮았습니다. 유지보수자는 감사의 말에서 DigitalBrainJS가 "탈취된 계정이 자기보다 높은 권한을 가진 상황에서" 빠르게 움직여 npm이 조치하게 만들었다고 적었습니다. 사고 대응 계획을 짤 때 거의 아무도 상정하지 않는 시나리오입니다 — 가장 높은 권한을 가진 계정이 적대적일 때, 남은 사람들은 무엇을 할 수 있는가.
3시간이라는 창은 짧아 보이지만, 그 짧음은 통제의 성과가 아니라 깨어 있던 협력자 한 명의 성과였습니다. 그가 그날 밤 자고 있었다면 창은 몇 배로 길어졌을 겁니다. 사람에 의존하는 탐지는 확장되지 않습니다.
provenance는 무엇을 증명하나
여기서 provenance를 제대로 짚고 갈 필요가 있습니다. 사람들이 이걸 "안전 인증서"로 오해하기 때문입니다.
1.14.0의 attestation 번들을 직접 열어 보면 실제로 무엇이 들어 있는지 보입니다.
curl -s "https://registry.npmjs.org/-/npm/v1/attestations/axios@1.14.0" | python3 -c "
import sys, json, base64
d = json.load(sys.stdin)
for a in d['attestations']:
if 'slsa' not in a['predicateType']: continue
p = json.loads(base64.b64decode(a['bundle']['dsseEnvelope']['payload']))
print(json.dumps(p['predicate']['buildDefinition']['externalParameters'], indent=2))
"
나오는 내용은 이렇습니다.
{
"workflow": {
"ref": "refs/tags/v1.14.0",
"repository": "https://github.com/axios/axios",
"path": ".github/workflows/publish.yml"
}
}
즉 provenance가 서명해서 말해 주는 건 이겁니다 — 이 tarball은 axios/axios 저장소의 v1.14.0 태그에서, publish.yml 워크플로로, GitHub 호스티드 러너가 빌드했다. 출처와 빌드 경로입니다.
그게 전부입니다. npm 문서가 직접 못 박습니다 — "When a package in the npm registry has established provenance, it does not guarantee the package has no malicious code." provenance가 있다고 악성 코드가 없다는 보장은 아니라는 겁니다.
이게 왜 중요하냐면, 이번 공격자는 유지보수자 PC를 장악하고 있었습니다. 그 상태라면 GitHub 자격증명도 함께 가져갔을 개연성이 높습니다. 만약 공격자가 npm에 직접 발행하는 대신 저장소에 커밋을 밀어 태그를 달았다면, CI가 빌드하고 완벽하게 유효한 provenance가 붙은 악성 패키지가 나왔을 겁니다. 서명은 참이고, 출처도 참이고, 코드만 악성인 상태. provenance는 그걸 잡지 못합니다. 잡으라고 만든 물건이 아니니까요.
그러니 trusted publishing을 배타적으로 켰다면 이번 공격은 막혔겠지만, 그건 공격을 없앤 게 아니라 더 비싼 경로로 옮긴 것입니다. 저장소 푸시 → 리뷰 → 태그 → CI라는 경로는 브랜치 보호와 리뷰가 걸려 있고, 무엇보다 커밋이라는 흔적을 남깁니다. 이게 통제의 정직한 값어치입니다. 불가능하게 만드는 게 아니라, 비싸고 시끄럽게 만드는 것.
npm audit signatures를 직접 돌려보면
그런데 이 사건에는 더 뼈아픈 구석이 있습니다. 신호는 레지스트리에 있었습니다.
1.14.0에는 provenance가 있었고 발행자가 GitHub Actions였습니다. 1.14.1에는 provenance가 없었고 발행자가 개인 계정이었습니다. 이 차이는 발행 즉시 공개 API에 노출돼 있었습니다. 소비자 쪽에서 "axios는 반드시 axios/axios 저장소에서 나온 provenance가 있어야 한다"를 강제하기만 했다면 1.14.1은 자동으로 튕겨 나갔을 겁니다.
문제는 아무도 그걸 강제하지 않는다는 겁니다. 기본값이 그렇지 않으니까요.
직접 확인해 봤습니다. provenance가 있는 패키지와 없는 패키지를 같이 설치하고 npm audit signatures를 돌렸습니다.
npm init -y
npm i axios@1.14.0 left-pad@1.3.0
npm audit signatures
결과입니다.
audited 24 packages in 0s
24 packages have verified registry signatures
1 package has a verified attestation
종료 코드는 0이었습니다. 통과입니다.
24개 중 attestation이 검증된 건 1개뿐인데 통과합니다. 즉 npm audit signatures는 있는 것만 검증하고, 없는 건 문제 삼지 않습니다. provenance의 부재는 오류가 아닙니다. 공격자가 provenance 없이 1.14.1을 올렸어도 이 명령은 똑같이 0을 뱉었을 거라는 뜻입니다.
이게 이 사건의 핵심 아이러니입니다. 생태계는 신호를 만드는 데는 성공했고, 소비하는 데는 실패했습니다. provenance는 발행자 쪽 산출물이고, 소비자가 검증을 강제하지 않으면 값이 0입니다. 배지는 예쁘게 붙어 있지만 CI는 아무것도 안 물어봅니다.
여기엔 닭과 달걀 문제가 깔려 있습니다. provenance 커버리지가 낮으니 "provenance 없으면 실패"를 기본값으로 켤 수 없고, 아무도 안 켜니 provenance의 값이 안 오릅니다. 그래서 현실적인 강제는 전역 정책이 아니라 당신이 아는 고위험 패키지 몇 개에 대한 핀포인트 검증입니다. axios, 빌드 도구, CI 액션 — 손에 꼽는 것들부터.
정작 효과가 있었을 통제 — 기다리기
그럼 소비자로서 이번 공격을 실제로 막을 수 있었던 통제는 무엇일까요. 서명이 아닙니다. 시간입니다.
pnpm에는 minimumReleaseAge 설정이 있습니다. 공식 문서의 설명은 이렇습니다 — 새로 발행된 버전의 설치를 지연시켜 침해된 패키지를 설치할 위험을 줄인다. 값은 분 단위이고, 전이 의존성을 포함한 모든 의존성에 적용됩니다. 그리고 문서가 근거로 드는 문장이 이 사건과 정확히 겹칩니다 — "In most cases, malicious releases are discovered and removed from the registry within an hour." 대부분의 악성 릴리스는 한 시간 안에 발견되고 제거된다는 겁니다.
기본값이 중요합니다. pnpm v11부터 minimumReleaseAge의 기본값은 1440분, 즉 24시간입니다(v11 이전은 0).
axios의 악성 버전이 살아 있던 창은 약 3시간이었습니다. 24시간 대기는 그 창을 통째로 덮습니다. 사건 당시 pnpm v11 기본값으로 설치하던 사람은 1.14.1을 아예 만나지 못했습니다. 서명도, 스캐너도, 위협 인텔리전스도 필요 없습니다. 그냥 하루 기다린 것뿐입니다.
이건 좀 김빠지는 결론입니다. 생태계가 지난 3년간 sigstore와 SLSA에 쏟은 노력에 비하면, "하루 기다리세요"는 초라해 보입니다. 하지만 이번 사건에 한정하면 그게 효과가 있었고 provenance는 없었습니다. 공급망 공격의 지배적 형태가 "짧은 창에 올렸다가 발각되면 내려가는 것"인 한, 대기는 비대칭적으로 강력합니다. 공격자의 유일한 자산인 그 창을 직접 겨냥하니까요.
물론 트레이드오프가 있고, 정직하게 말해야 합니다.
- 보안 패치가 늦게 옵니다. 진짜 급한 CVE 수정도 24시간 기다립니다. 그래서
minimumReleaseAgeExclude로 특정 의존성을 예외 처리하는 장치가 있습니다. - 긴 잠복 공격은 못 막습니다. 공격자가 악성 버전을 올려 두고 일주일을 기다리면 대기는 무력합니다. 대기는 "빨리 발각되는 공격"에만 듣습니다. 발각이 늦어질수록 값이 떨어집니다.
- 탐지가 아니라 회피입니다. 대기는 아무것도 알려주지 않습니다. 그냥 남들이 먼저 밟게 둘 뿐입니다. 이게 생태계 차원에서 공정한 전략인지는 별개 문제입니다 — 모두가 24시간을 기다리면 아무도 먼저 발각해 주지 않으니까요. 이 통제는 남들이 안 쓸 때 가장 잘 듣는 종류입니다.
폭발 반경은 아무도 모른다
마지막으로 숫자 이야기입니다. 이 사건을 다룬 글들은 대체로 "주간 다운로드 1억 건"과 "의존 패키지 17만 4천 개"를 인용합니다. Orca와 Datadog 모두 "roughly 100 million weekly downloads and over 174,000 dependent packages"라고 씁니다.
제가 npm 공식 다운로드 API로 직접 재 봤습니다.
curl -s "https://api.npmjs.org/downloads/point/last-week/axios"
{"downloads":85473581,"start":"2026-07-09","end":"2026-07-15","package":"axios"}
2026년 7월 9일부터 15일까지 실측 8,547만 건입니다. "약 1억"은 반올림이 후한 편이고, 어차피 사건 당시가 아니라 지금 시점의 수치입니다. 큰 차이는 아니지만, 인용된 숫자를 그대로 옮기기 전에 30초면 원본을 확인할 수 있다는 점은 짚어 둘 만합니다.
더 중요한 건 이겁니다. 악성 버전이 실제로 몇 번 설치됐는지는 공개적으로 알 수 없습니다. npm의 버전별 다운로드 API를 보면 1.14.1과 0.30.4는 아예 목록에 없습니다.
curl -s "https://api.npmjs.org/versions/axios/last-week" | python3 -c "
import sys, json
dl = json.load(sys.stdin)['downloads']
for v in ['1.14.0', '1.14.1', '0.30.4']:
print(v, dl.get(v, '없음'))
"
1.14.0 1735295
1.14.1 없음
0.30.4 없음
unpublish 된 버전의 다운로드 수는 노출되지 않습니다. 그러니 "1억 명이 위험했다"류의 문장은 전부 잠재 노출 규모이지 실제 피해 규모가 아닙니다. 주간 다운로드 8,500만 건짜리 패키지라도, 3시간 창에 그중 몇이 새로 설치를 돌렸고 그중 몇이 latest를 따라갔는지는 별개 문제입니다. 락파일에 핀돼 있었고 그 3시간에 fresh install을 안 돌렸다면 안전했습니다 — 포스트모템도 정확히 그렇게 말합니다.
Orca가 "live for under three hours"라는 점을 들어 실제 노출이 제한적이었다고 단서를 단 건 공정한 서술입니다. 하지만 헤드라인에 남는 건 언제나 1억입니다. 보안 벤더의 집계는 대체로 잠재 노출을 최대치로 잡는 방향으로 기울고, 그게 늘 거짓은 아니지만 늘 상한선이라는 건 기억할 필요가 있습니다. 정직한 답은 이겁니다 — 모릅니다. 레지스트리가 그 데이터를 공개하지 않습니다.
그래서 무엇을 할 것인가
사건에서 실제로 나오는 액션은 역할에 따라 다릅니다.
패키지를 발행한다면
- trusted publishing을 켜는 것으로 끝내지 마세요. 패키지 설정에서 토큰과 수동 발행을 닫아야 배타적 경로가 됩니다. 켜 두기만 한 trusted publishing은 axios가 증명했듯 옆문을 남깁니다.
- 레거시 릴리스 라인을 잊지 마세요. axios의 0.x는 provenance 없이 개인 계정에서 나가고 있었습니다. 공격자는 가장 잘 지켜진 라인이 아니라 가장 안 지켜진 라인을 고릅니다.
- 무단 발행에 대한 알림 경로를 만드세요. 이번 탐지는 전적으로 사람 운이었습니다.
패키지를 소비한다면
minimumReleaseAge를 켜세요(pnpm v11부터는 이미 24시간이 기본값입니다). 이 사건에 한해서는 이게 유일하게 실제로 작동했을 통제입니다.- 락파일에 핀하고 fresh install을 아무 때나 돌리지 마세요. 핀돼 있던 사람들은 그냥 안전했습니다.
- 고위험 패키지 몇 개에 한해 provenance 출처를 강제로 검증하세요.
npm audit signatures는 없는 provenance를 문제 삼지 않으므로, 그것만 믿으면 안 됩니다. - 설치 스크립트를 차단하는 걸 검토하세요. 이번 페이로드는
postinstall로 실행됐습니다.
어느 쪽이든
RAT이 심긴 유지보수자 워크스테이션 앞에서는 레지스트리 통제 대부분이 무력해집니다. 이 사건의 근본 원인은 npm이 아니라 2주짜리 사회공학이었습니다. 그리고 그건 서명으로 푸는 문제가 아닙니다.
마치며
정리하면 이렇습니다. axios는 trusted publishing을 켜 뒀고, provenance를 발행하고 있었고, 2FA를 쓰고 있었고, 그럼에도 3시간 동안 크로스플랫폼 RAT을 배포했습니다. trusted publishing이 뚫려서가 아니라 배타적이지 않아서, provenance가 실패해서가 아니라 아무도 그걸 강제하지 않아서였습니다. 그리고 이걸 멈춘 건 어떤 통제도 아니고 그날 밤 깨어 있던 협력자 한 명이었습니다.
이 글에서 가져갈 게 하나라면 이겁니다 — 통제는 켜는 게 아니라 유일하게 만들고, 강제해야 값을 합니다. 켜져만 있는 통제는 감사 체크리스트를 통과시키지 대시보드 밖의 공격자를 막지 못합니다.
그리고 하나 더. 이 글의 사실관계 대부분은 벤더 블로그가 아니라 curl과 공식 포스트모템에서 나왔고, 그 과정에서 널리 인용된 침투 경로 설명 하나가 레지스트리 타임라인과 어긋난다는 걸 발견했습니다. 사고를 읽을 때는 1차 자료부터 보세요. 레지스트리는 대체로 거짓말을 하지 않습니다.
참고 자료
- Post Mortem: axios npm supply chain compromise — Jason Saayman (axios/axios#10636) — 유지보수자 본인의 1차 포스트모템
- PR #10591 — DigitalBrainJS가 침해 버전을 deprecate 한 커밋
- Compromised axios npm package delivers cross-platform RAT — Datadog Security Labs — 기술 분석과 IOC (보안 벤더 자체 분석)
- Supply Chain Attack on Axios Delivers Cross-Platform RAT via Compromised npm Account — Orca Security — 구조적 지적은 타당하나 classic 토큰 가설은 타임라인과 어긋남 (보안 벤더 자체 집계)
- npm Trusted Publishers 공식 문서 — "in addition to" 문구와 토큰 차단 설정
- Generating provenance statements — npm 공식 문서 — provenance가 악성 코드 부재를 보장하지 않는다는 명시
- npm classic tokens revoked, session-based auth — GitHub Changelog (2025-12-09)
- pnpm settings — minimumReleaseAge
- npm 공급망 공격이 사라지지 않는 이유 (관련 글)