Skip to content

Split View: axios 계정 탈취 3시간 — provenance는 켜져 있었고, 아무도 확인하지 않았다

|

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

들어가며 — 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-jstime 맵에는 4.2.02026-03-30T05:57:32.867Z, 4.2.12026-03-30T23:59:12.278Z로 남아 있고, 현재 dist-tags.latest0.0.1-security.0입니다. repositorynpm/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차 자료부터 보세요. 레지스트리는 대체로 거짓말을 하지 않습니다.

참고 자료

The 3-Hour axios Account Takeover — Provenance Was On, and Nobody Checked It

Introduction — March 31, 2026, 00:21 UTC

Supply-chain security posts tend to come in two flavors: news that an incident happened, or generic advice to "adopt an SBOM and verify signatures." Neither is much help. The first never tells you which control failed and why; the second can't explain why someone who had already turned that control on still got hit.

The axios incident on March 31, 2026 is squarely the second case. axios was already using npm's trusted publishing. The last legitimate release carried SLSA provenance. The maintainer's account had 2FA turned on. And it still got compromised.

What sets this piece apart is that I pulled most of the timestamps here directly from the npm registry API myself, and you can verify them the same way right now. The incident is over, but the receipts are still sitting in the registry.

The Receipts the Registry Left Behind

Package metadata on the npm registry carries a time map. It has one interesting property: even when a version gets unpublished, its time entry stays. So when the deleted malicious version was first published is still public knowledge.

Try it yourself.

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), '| currently exists:', v in d['versions'])
"

Here's what I got when I ran this on July 16, 2026:

1.14.0 2026-03-27T19:01:40.915Z | currently exists: True
1.14.1 2026-03-31T00:21:58.168Z | currently exists: False
0.30.3 2026-02-18T17:19:20.081Z | currently exists: True
0.30.4 2026-03-31T01:00:57.285Z | currently exists: False

1.14.1 and 0.30.4 have a recorded publish time but don't exist now. That's the fingerprint of an unpublish, and both timestamps line up to the second with the timeline in the official post-mortem written by maintainer Jason Saayman.

The same holds for the injected dependency.

curl -s https://registry.npmjs.org/plain-crypto-js | python3 -m json.tool | head -20

plain-crypto-js's time map still shows 4.2.0 at 2026-03-30T05:57:32.867Z and 4.2.1 at 2026-03-30T23:59:12.278Z, and its current dist-tags.latest is 0.0.1-security.0. The repository field reads npm/security-holder — a placeholder npm parks on names after it removes a malicious package. That placeholder went up at 2026-03-31T03:25:11.636Z.

Putting the sequence together: the attacker first published an innocuous plain-crypto-js@4.2.0 at 05:57 on March 30 to stake out the name, followed it with a malicious postinstall in 4.2.1 at 23:59 that same night, and then, less than an hour later at 00:21 on March 31, published axios@1.14.1 with that package wired in as a dependency. The decoy went in first; the real target came second.

The Entry Point — Not a Token, a Person's PC

According to the post-mortem, this started not with the registry but with a person. Over roughly two weeks of targeted social engineering, the attacker planted a RAT (remote access trojan) on the lead maintainer's PC and harvested the npm account credentials from there.

What matters here is that 2FA was turned on (per Datadog Security Labs' analysis). Of course it was — once an attacker is sitting inside the maintainer's own machine, 2FA is just a formality to click through. 2FA is a control against "someone else logging in from elsewhere," not against "someone who has become your computer."

The payload was a cross-platform RAT targeting macOS, Windows, and Linux, with C2 at sfrclak[.]com (port 8000). The post-mortem advises anyone who might be affected to grep their lockfiles for axios@1.14.1, axios@0.30.4, and plain-crypto-js, treat any hit as a compromised machine, and rotate every credential on it — and if a CI runner is hit, rotate every secret injected into that build.

Trusted Publishing Was On

Now for the most interesting part of this incident. axios was already using trusted publishing. Pulling the per-version publisher and provenance status from the registry makes this immediately visible.

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, '| publisher:', ver['_npmUser']['name'], '| provenance:', 'yes' if att else 'no')
"

The result:

1.13.0 | publisher: jasonsaayman   | provenance: yes
1.14.0 | publisher: GitHub Actions | provenance: yes
1.18.1 | publisher: GitHub Actions | provenance: yes
0.30.3 | publisher: jasonsaayman   | provenance: no

Two things stand out here.

First, the switch to trusted publishing is visible right in the record, between 1.13.0 and 1.14.0. The publisher name flips from a personal account to GitHub Actions. That means the last legitimate release, 1.14.0, went out four days before the attack, on March 27, published by GitHub Actions over OIDC, with provenance attached automatically.

Second, the legacy 0.x line had no provenance at all. It was still going out manually from a personal account. It may not be a coincidence that the attacker targeted 1.14.1 and 0.30.4 together — the 0.x line was a soft target to begin with, with no signal to even miss.

That reframes the question: trusted publishing was turned on, so why did a publish still go out from a personal account?

The answer is right there in npm's own documentation. When you set up trusted publishing, npm accepts publishes from the approved workflow, but — to quote the docs directly — it does so "in addition to traditional authentication methods like npm tokens or manual publishing." In other words, trusted publishing is, by default, an additional path, not an exclusive one. To close off the token and manual-publish paths, you have to separately select "Require two-factor authentication and disallow tokens" in the package settings.

So, to be precise, trusted publishing wasn't breached. It wasn't cryptographically bypassed either. An unlocked side door was simply still there, and the attacker walked through it. That's what the maintainer meant by writing "Proper adoption of OIDC flow for publishing" in the post-mortem's "what's changing going forward" table. The emphasis lands on proper — not turning it on, but making it the only path.

Orca Security got this structural point exactly right, too. In their words: "Trusted Publishing only works if it's the sole publishing path." That's correct, and npm's own documentation backs it up.

Where One Vendor's Explanation Doesn't Match the Timeline

That said, the same Orca post also states: "The likely entry point was a long-lived classic npm access token, which can sidestep 2FA requirements entirely." That's a hedge — a hypothesis that a lingering classic token was likely the entry point. It's a plausible-sounding story: a legacy token bypassing modern controls through a back door is a clean narrative, and one the security industry tends to like.

The problem is the date. On December 9, 2025, GitHub stated flatly in its changelog: "We've permanently revoked all existing npm classic tokens. They can no longer authenticate, be recreated, or be recovered." Every classic token was permanently revoked, with no path to authenticate, recreate, or recover it. The same announcement switched npm login to issue 2-hour session tokens instead of long-lived ones.

The axios attack happened on March 31, 2026 — roughly three and a half months later. At that point, classic tokens could not authenticate at all. So Orca's hypothesis is hard to sustain. The maintainer's own post-mortem describes a far simpler path — social engineering planted a RAT on a PC, and the credentials were harvested from there.

To be fair, Orca hedged with "likely," and their structural conclusion ("it needs to be the sole path") was correct. I'm not saying they lied. But this is a common failure mode in incident analysis — fitting an incident to a known failure pattern can land on a conclusion that happens to be right while sending the response in the wrong direction. Believing this hypothesis produces an action item like "audit for lingering classic tokens," which is a problem npm already solved globally four months earlier — while the actual problems, "the maintainer's workstation was compromised" and "the manual-publish path was left open," go untouched.

The lesson is simple. Vendor blog posts are useful for IOCs and technical analysis, but check any narrative about the entry point against the primary post-mortem and the registry timeline. In this case, that cross-check took five minutes.

What Actually Stopped This Was a Person

So what actually stopped it? Not provenance. Not trusted publishing, not 2FA, not any scanner.

The post-mortem's own words say it most precisely: "There was no automated way to detect an unauthorized publish. Detection depended entirely on the community noticing."

Here's what actually happened. PR #10591, confirmed via the GitHub API, was opened by axios collaborator DigitalBrainJS at 2026-03-31T01:38:47Z and merged at 01:42:15Z. Its title is plain: chore(ci): add deprecate action;. That's 77 minutes after the malicious publish at 00:21. He deprecated the compromised versions, alerted the community, and contacted npm directly. The malicious versions came down from the registry at 03:15.

Two details here are easy to miss.

The attacker was deleting reports. Per the post-mortem, community members started filing issues reporting the compromise around 01:00, and the attacker, still in control of the hijacked account, deleted those issues. Account takeover doesn't just hand over publish rights — it hands over moderation rights too. The attacker could ship the malicious release and erase the reports about it at the same time.

The responder had lower privileges than the attacker. In the acknowledgments, the maintainer wrote that DigitalBrainJS moved quickly "while the compromised account held higher privileges than his own" and got npm to act. It's a scenario almost nobody plans for when drafting an incident response plan — what can the rest of the team do when the account with the highest privileges is the adversary?

A three-hour window sounds short, but that shortness wasn't the payoff of any control — it was the payoff of one collaborator who happened to be awake. Had he been asleep that night, the window would have been several times longer. Detection that depends on a person doesn't scale.

What Provenance Actually Proves

This is worth pinning down precisely, because people tend to misread provenance as a "safety certificate."

Opening 1.14.0's attestation bundle directly shows exactly what's in it.

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))
"

Here's what comes out:

{
  "workflow": {
    "ref": "refs/tags/v1.14.0",
    "repository": "https://github.com/axios/axios",
    "path": ".github/workflows/publish.yml"
  }
}

In other words, what provenance signs and states is this: this tarball was built from the v1.14.0 tag in the axios/axios repository, using the publish.yml workflow, on a GitHub-hosted runner. Provenance and build path.

That's all it is. npm's own documentation states it directly: "When a package in the npm registry has established provenance, it does not guarantee the package has no malicious code." Having provenance is not a guarantee of absence of malicious code.

Why this matters here: this attacker had control of the maintainer's PC. In that state, it's plausible they could have grabbed GitHub credentials too. Had the attacker pushed a commit to the repository and tagged it, instead of publishing directly to npm, CI would have built it and produced a malicious package with perfectly valid provenance. The signature would be genuine, the origin would be genuine — only the code would be malicious. Provenance would not have caught that. It was never built to.

So turning trusted publishing on as the exclusive path would have blocked this specific attack — but that wouldn't have eliminated the attack, only moved it to a more expensive path. The route of repo push → review → tag → CI has branch protection and review sitting in front of it, and above all, it leaves a commit as a trace. That's the honest value of a control: not making something impossible, but making it expensive and loud.

Actually Running npm audit signatures

But there's a more painful angle to this incident. The signal was sitting right there in the registry.

1.14.0 had provenance, and its publisher was GitHub Actions. 1.14.1 had no provenance, and its publisher was a personal account. That difference was exposed on the public API the instant it was published. If a consumer had simply enforced "axios must have provenance originating from the axios/axios repository," 1.14.1 would have been rejected automatically.

The problem is that nobody enforces that, because it isn't the default.

I checked this myself. I installed a package with provenance alongside one without and ran npm audit signatures.

npm init -y
npm i axios@1.14.0 left-pad@1.3.0
npm audit signatures

Here's the result:

audited 24 packages in 0s

24 packages have verified registry signatures

1 package has a verified attestation

The exit code was 0. It passed.

Only 1 out of 24 packages has a verified attestation, and it still passes. In other words, npm audit signatures only verifies what's present, and doesn't flag what's absent. Missing provenance is not treated as an error. That means if the attacker had published 1.14.1 without any provenance, this command would have emitted the exact same 0.

That's the central irony of this incident. The ecosystem succeeded at producing the signal, and failed at consuming it. Provenance is a publisher-side artifact, and it's worth zero unless a consumer enforces verification. The badge looks nice, but CI never asks it a single question.

There's a chicken-and-egg problem underneath this. Provenance coverage is low, so "fail if no provenance" can't become the default; and because nobody turns it on, provenance's value never rises. So the realistic form of enforcement isn't a global policy — it's pinpoint verification on the handful of high-risk packages you already know about. axios, build tools, CI actions — start with the ones you can count on one hand.

The Control That Would Actually Have Worked — Waiting

So as a consumer, what control could have actually stopped this attack? Not a signature. Time.

pnpm has a minimumReleaseAge setting. Per the official docs, it delays installation of newly published versions to reduce the risk of installing a compromised package. The value is in minutes, and it applies to every dependency, including transitive ones. And the sentence the docs cite as justification lines up exactly with this incident: "In most cases, malicious releases are discovered and removed from the registry within an hour."

The default matters here. Starting with pnpm v11, minimumReleaseAge defaults to 1440 minutes — 24 hours (it was 0 before v11).

The window during which axios's malicious version was live was roughly three hours. A 24-hour wait covers that entire window. Anyone installing under pnpm v11's default at the time of the incident would never have encountered 1.14.1 at all. No signatures, no scanner, no threat intel required. Just a day of waiting.

That's a somewhat deflating conclusion. Set against everything the ecosystem has poured into sigstore and SLSA over the past three years, "just wait a day" looks almost too modest. But confined to this specific incident, it's what actually worked, and provenance wasn't. As long as the dominant shape of supply-chain attacks is "publish in a short window, get discovered, come down," waiting is asymmetrically powerful — it targets directly the one asset the attacker actually has: that window.

There are trade-offs here, and they deserve to be stated honestly.

  • Security patches arrive late. Even a genuinely urgent CVE fix waits 24 hours. That's why there's a minimumReleaseAgeExclude mechanism to carve out exceptions for specific dependencies.
  • It doesn't stop long-dormant attacks. If an attacker publishes a malicious version and simply waits a week, the delay is powerless. Waiting only works against "attacks that get discovered quickly." The later discovery comes, the less this is worth.
  • It's evasion, not detection. Waiting doesn't tell you anything. It just lets everyone else step on the mine first. Whether that's a fair strategy at the ecosystem level is a separate question — if everyone waits 24 hours, nobody discovers anything first. This is the kind of control that works best precisely when other people aren't using it.

Nobody Knows the Blast Radius

One last point, on the numbers. Most pieces covering this incident cite "roughly 100 million weekly downloads" and "over 174,000 dependent packages." Both Orca and Datadog write "roughly 100 million weekly downloads and over 174,000 dependent packages."

I re-measured this myself against npm's official downloads API.

curl -s "https://api.npmjs.org/downloads/point/last-week/axios"
{"downloads":85473581,"start":"2026-07-09","end":"2026-07-15","package":"axios"}

From July 9 to July 15, 2026, the measured figure is 85,473,581. "Roughly 100 million" is a generous rounding, and either way, it's a present-day figure, not a figure at the time of the incident. The gap isn't huge, but it's worth flagging that the source is a 30-second check away before repeating a cited number as-is.

More important is this: how many times the malicious version was actually installed is not publicly knowable. Looking at npm's per-version downloads API, 1.14.1 and 0.30.4 don't even appear in the list.

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, 'none'))
"
1.14.0 1735295
1.14.1 none
0.30.4 none

Download counts for unpublished versions are not exposed. So any statement along the lines of "100 million people were at risk" is entirely a measure of potential exposure, not actual harm. Even for a package with 85 million weekly downloads, how many of those, within a three-hour window, ran a fresh install and how many of those tracked latest are separate questions entirely. If you were pinned in a lockfile and didn't run a fresh install during that three-hour window, you were fine — the post-mortem says exactly that.

Orca's framing, noting that the package was "live for under three hours" to qualify that actual exposure was limited, is a fair way to put it. But the number that survives in headlines is always the 100 million. Security vendors' tallies tend to lean toward the maximum potential exposure, and while that isn't always false, it's worth remembering it's always a ceiling. The honest answer here is: we don't know. The registry doesn't publish that data.

So What Should You Actually Do

The actions that actually follow from this incident depend on your role.

If you publish packages

  • Don't stop at turning trusted publishing on. You have to close tokens and manual publishing in the package settings for it to become the exclusive path. Trusted publishing that's merely turned on leaves a side door, as axios proved.
  • Don't forget your legacy release lines. axios's 0.x line was going out without provenance from a personal account. Attackers pick the least-guarded line, not the best-guarded one.
  • Build an alert path for unauthorized publishes. Detection here was entirely down to human luck.

If you consume packages

  • Turn on minimumReleaseAge (already defaulted to 24 hours as of pnpm v11). For this specific incident, this is the only control that would actually have worked.
  • Pin your lockfile, and don't run fresh installs at arbitrary times. People who were pinned were simply safe.
  • Force verification of provenance origin for a handful of high-risk packages. npm audit signatures doesn't flag missing provenance, so it can't be trusted alone.
  • Consider blocking install scripts. This payload ran via postinstall.

Either way

Most registry-level controls go dark in front of a maintainer workstation with a RAT planted on it. The root cause of this incident wasn't npm — it was two weeks of social engineering. And that isn't a problem signatures solve.

Closing

To sum up: axios had trusted publishing turned on, was issuing provenance, was using 2FA — and still shipped a cross-platform RAT for three hours. Not because trusted publishing was breached, but because it wasn't exclusive; not because provenance failed, but because nobody enforced it. And what stopped it wasn't any control — it was one collaborator who happened to be awake that night.

If there's one thing to take from this piece, it's this: a control only earns its value once it's made exclusive and enforced, not merely turned on. A control that's merely on will pass an audit checklist. It won't stop an attacker outside the dashboard.

One more thing. Most of the facts in this piece came not from vendor blog posts but from curl and the primary post-mortem, and along the way I found that one widely cited explanation of the entry point doesn't match the registry timeline. When you read an incident, start with primary sources. The registry, mostly, doesn't lie.

References