Zod 4.6.6의 악성 커밋은 npm에 배포되지 않았습니다: 공격자의 배포 시도 5번이 실패한 경위
2026-09-21 저장소 main에 들어간 설치 로더는 같은 날 되돌려졌습니다. 공격자는 배포 승인 단계를 지우고 저장소 비밀값 NPM_TOKEN으로 배포하는 경로까지 추가했지만, 다섯 번의 배포 실행은 테스트 실패, 승인 대기, npm의 404 응답으로 모두 중단됐습니다. npm의 4.6.6은 정상 커밋 284243f에서 빌드됐습니다
목차
npm에 올라간 zod 4.6.6(2026-10-12 01:18 KST 공개)에는 악성 설치 로더가 없습니다. Hacker News에서 「Zod 4.6.6 compromised by malware?」로 160점을 받은 커밋 d821685는 3주 전인 9월 21일 저장소 main에 푸시됐다가 같은 날 되돌려졌습니다. 그날 공격자가 일으킨 배포 실행 다섯 번은 GitHub Actions 기록상 모두 실패하거나 취소됐습니다.
핵심 요약
zod·@zod/mini 모두 4.6.6이 최신, 4.6.7·4.6.8 기록 없음
테스트 실패 2, 사람 승인 대기 중 취소 1, npm이 404로 거절 2
preinstall을 실행한 패키지 관리자npm·pnpm·bun·yarn 1, 무해한 시험 패키지로 측정
한눈에
- 4.6.6 배포본: 설치 스크립트 없음,
meta*.mjs없음. 4.6.5와 다른 곳은 소스 파일 3개와 테스트 - 빌드 커밋: provenance의
gitCommit= 284243f. 이 커밋에는 로더 파일이 없음 - 공격 방식: 메인테이너는 "git push 자격 증명의 일종이 유출된 것으로 보이고, npm이나 GitHub 토큰은 아니다"라고 설명
- 같은 날 공격받은 곳: standard-schema, nub. nub만 npm 배포 단계까지 갔고 본 패키지는 npm 검사에서 차단
1. 떠도는 주장과 기록이 보여 주는 것
저희가 추적한 공개 기록에서는 2026-10-11 GitHub 이슈 #6658(「Suspicious preinstall script in v4.6.6 commit history」)에서 이 문제가 처음 제기됐고, 다음 날 Hacker News 글로 퍼졌습니다. 아래 표는 그 주장들을 공개 기록과 맞대 본 결과입니다.
| 주장 | 기록이 보여 주는 것 | 상태 | 근거 |
|---|---|---|---|
| "Zod 4.6.6이 악성코드에 감염됐다" | npm 4.6.6 압축 파일에 로더 파일과 설치 훅이 없습니다. provenance는 커밋 284243f를 가리키고, 그 커밋의 트리에는 packages/zod/meta.mjs·meta.test.mjs가 없습니다(GitHub API 404) | 성립 안 함 | npm 배포본, npm attestations, GitHub API |
| "릴리스 페이지에 악성 커밋이 있다" | 있습니다. v4.6.6 릴리스 노트가 d821685, ce1e11b와 "ci: drop the npm environment approval gate" 등 공격 커밋을 그대로 나열합니다(2026-10-12 조회). 4.6.5와 4.6.6 사이 git 기록에 들어 있어서입니다 | 사실, 다만 배포본과는 별개 | GitHub 릴리스 v4.6.6 |
| "4.6.7·4.6.8이 배포됐다가 내려갔다" | npm 레지스트리의 time 기록에 두 버전이 없고 unpublished 표시도 없습니다. JSR도 4.6.6까지입니다. 저장소 태그도 v4.6.6까지입니다 | 성립 안 함 | npm 레지스트리, JSR, GitHub 태그 |
| "npm의 악성코드 검사가 Zod를 구했다" | GitHub Community에 올라온 보안 공지(#208477, 2026-09-21)는 Zod 변경이 "npm 배포 파이프라인에 들어가지 않았다"고 적었습니다. 메인테이너는 다음 날 "Zod의 배포 CI가 설계대로 작동했고, npm 검사가 막은 것은 없다"고 답했습니다. npm 검사가 막은 것은 같은 날 공격받은 @nubjs/nub@0.9.4입니다 | Zod에는 해당 안 함 | GitHub Community 토론 #208477 |
| "설치만 해도 로더가 실행됐을 것이다" | 두 공격 커밋 모두 "preinstall"을 scripts 안이 아니라 package.json 최상위에 넣었습니다. 같은 방식으로 만든 무해한 시험 패키지는 npm·pnpm·bun·yarn 1 어디서도 훅을 실행하지 않았습니다(4절) | 이 위치 그대로였다면 실행 안 됨 | 커밋 diff, 저희 측정 |
표의 마지막 항목은 공격 코드의 훅 위치가 틀렸다는 뜻일 뿐입니다. 이 줄을 scripts 안으로 옮겼다면, 저희 시험 결과로 보아 npm과 yarn 1은 설치할 때 로더를 실행했을 것입니다. 배포본에 로더가 들어가지 않은 것은 배포 단계에서 막혔기 때문입니다(2·3절).
2. 9월 21일 하루: 공격자는 배포 경로를 세 번 바꿨습니다
시각은 한국 시간이고, GitHub Actions 실행이 만들어진 시각(=푸시가 도착한 시각) 기준입니다. 커밋에 적힌 시각은 쓰지 않았습니다. 공격 커밋 6개의 커밋 시각은 모두 푸시 시각보다 53~55분 늦게 적혀 있어, 커밋을 만든 컴퓨터의 시계가 어긋나 있었던 것으로 보입니다. #208477 보안 공지의 "11:56:52 UTC"도 커밋 시각입니다.
- 로더 커밋 푸시d821685, 이어서 20:08 ce1e11b2시간 26분
- 승인 단계 삭제84f79a737분
- 4.6.8 배포·토큰 배포 시도npm이 두 번 404로 거절3시간 33분
- 대기 중인 실행 취소배포 실행 2건 강제 종료3시간 36분
- 정리 PR 병합#6620 「Cleanup bad commits」
다섯 번의 배포 실행을 커밋 순서대로 놓으면 공격자가 무엇을 시도했는지 보입니다. 원래 Zod의 배포 워크플로(release.yml)는 메인테이너가 Actions 화면이나 명령으로 직접 실행할 때만(workflow_dispatch) 실행됐습니다. 첫 커밋은 이를 "main에서 package.json 버전이 바뀌면 자동 실행"되도록 바꿨고, 이후 커밋들은 이 방식이 막힐 때마다 다른 배포 방식을 추가했습니다.
| 푸시(KST) | 커밋 | 바꾼 것 | 배포 실행 결과 |
|---|---|---|---|
| 20:01 | d821685 「Fix bugs and improve performance」 | 로더 meta.test.mjs 추가, 버전 4.6.6, 배포 워크플로를 main 푸시에 자동 실행되게 변경 | 테스트 실패 → 배포 단계 건너뜀 |
| 20:08 | ce1e11b 같은 메시지 | 로더 이름을 meta.mjs로 변경(테스트 파일로 잡히지 않게) | 테스트 통과 → 배포 단계가 npm 환경의 사람 승인을 기다리며 멈춤. 승인 없이 02:57에 취소 |
| 22:27 | 84f79a7 「ci: drop the npm environment approval gate from publish」 | 배포 단계의 environment: npm 삭제 | (이 커밋은 배포 실행 없음) |
| 22:54 | c7eb014 「4.6.7」 | 버전 4.6.7 | 테스트 실패 → 배포 단계 건너뜀 |
| 23:04 | b1970f6 「4.6.8」 | 버전 4.6.8, 잠금 파일 갱신 | 승인 없이 배포 단계 시작. 23:09 publish --provenance가 404 Not Found. 이후 "레지스트리에 올라오기를 기다리는" 단계가 225분 넘게 돌다 02:57에 취소 |
| 23:22 | 9488631 「ci: publish to npm with NPM_TOKEN」 | 새 워크플로 publish.yml: main 푸시마다 저장소 비밀값 NPM_TOKEN으로 배포 | 테스트 8,320개 통과 후 23:24 publish가 404 Not Found |
표에서 주의해서 볼 것은 마지막 두 줄입니다. 승인 단계를 지운 뒤에도, 토큰으로 경로를 바꾼 뒤에도 npm이 배포를 받지 않았습니다. 테스트 실패 두 번은 공격자 커밋이 우연히 테스트를 깨뜨린 결과라 방어 장치로 셀 수 없습니다.
3. npm이 404를 반환한 유력한 이유: 배포 권한이 GitHub Actions 환경에 묶여 있었습니다
GitHub Actions의 환경(environment)은 배포 단계에 이름을 붙이고 "지정한 사람이 승인해야 실행" 같은 보호 규칙을 거는 기능입니다. Zod는 npm이라는 환경을 만들어 npm 배포 단계에 걸어 두었습니다.
Zod의 4.6.5 시점 release.yml에는 배포 단계 바로 위에 이런 주석이 있습니다. "모든 배포는 지정된 검토자가 승인하고, 이 환경은 main만 배포한다. npm의 trusted publisher가 이 환경에 묶여 있어 다른 브랜치나 워크플로는 배포할 수 없다."
trusted publishing은 저장해 둔 npm 토큰 대신, 실행 중인 워크플로에 GitHub Actions가 그때마다 발급하는 일회용 신원 증명(OIDC 토큰)으로 배포하는 방식입니다. npm 패키지 설정에 "이 저장소의 이 워크플로 파일, 이 환경 이름"을 등록해 두면 그 조건에 맞는 실행만 배포할 수 있습니다. npm 문서는 환경 이름을 선택 항목으로 두고 "모든 칸은 대소문자까지 정확히 일치해야 한다"고만 적었고, 불일치할 때 어떤 응답이 오는지는 적지 않았습니다.
| 배포를 막는 장치 | 9월 21일 결과 | 공격자가 할 수 있었던 것 |
|---|---|---|
배포는 사람이 직접 실행(workflow_dispatch) | 첫 커밋에서 "main 푸시에 자동 실행"으로 바꿔 우회 | 워크플로 파일은 git 저장소 안에 있어 푸시 권한만으로 고칠 수 있었습니다 |
GitHub npm 환경의 검토자 승인 | ce1e11b의 배포가 여기서 6시간 46분 대기 후 취소 | 워크플로에서 environment 줄을 지우는 것(84f79a7)까지는 할 수 있었습니다 |
| npm trusted publisher의 환경 조건 | 환경 없이 실행된 4.6.8 배포를 npm이 404로 거절 | npm 쪽 설정은 git 저장소 밖이라 푸시 권한으로 바꿀 수 없습니다 |
| 토큰 배포 | NPM_TOKEN으로 바꾼 배포도 404로 거절 | 로그상 토큰 값은 비어 있지 않았습니다. 거절 이유는 공개 기록으로 알 수 없습니다 |
표의 앞 두 장치는 워크플로 파일 안에 있어 저장소에 푸시할 수 있으면 지울 수 있었고, 세 번째 장치(trusted publisher의 환경 조건)는 npm 계정 설정에 있었습니다. 승인 단계를 지운 뒤의 배포를 멈춘 것은 이 세 번째 장치로 보입니다. 다만 4.6.8의 404가 환경 조건 불일치 때문이라는 것은 워크플로 주석이 밝힌 설계와 맞는 해석이고, npm 응답에는 거절 사유가 없습니다.
같은 날 막히지 못한 곳: nub
같은 로더가 같은 날 standard-schema와 nubjs/nub에도 들어갔습니다. Zod 메인테이너는 "Nub의 배포 CI는 태그를 만들면 실행됐다"고 적었습니다. npm 레지스트리에는 @nubjs/nub 본 패키지의 0.9.4가 없고(0.9.3 다음이 0.9.5), #208477 공지는 이 버전을 npm의 배포 시점 악성코드 검사가 차단했다고 밝혔습니다. 한편 운영체제별 실행 파일 패키지 8개(@nubjs/nub-linux-x64 등)의 0.9.4는 9/21 22:09~22:11 KST에 공개돼 지금도 레지스트리에 있습니다. #208477 공지에 따르면 로더는 본 패키지의 preinstall.js에 있었고, 저희는 실행 파일 패키지의 내용을 분석하지 않았습니다.
사건 뒤 Zod가 바꾼 것
메인테이너는 9월 24일 "내 모든 npm 패키지를 workflow_dispatch로만 시작되고 환경 승인을 거치는 단계 배포(staged publishing)로 옮겼다"고 덧붙였습니다. 4.6.6을 빌드한 커밋 284243f의 release.yml 머리 주석은 이유를 이렇게 적습니다. "workflow_dispatch에는 쓰기 권한이 있는 GitHub 세션이나 토큰이 필요하고, 훔친 git push 자격 증명에는 둘 다 없다." 단계 배포는 npm 기능으로, 워크플로가 올린 버전이 바로 공개되지 않고 대기하다가 메인테이너가 2FA로 승인해야 공개됩니다. npm 문서는 단계 배포의 승인 명령이 "대화형 인증이 필요하고 OIDC 토큰을 쓸 수 없다"고 적었습니다.
4. 저희가 직접 한 것
- 배포본 비교. npm에서 zod 4.6.5·4.6.6 압축 파일을 받아 실행하지 않고 풀어 비교했습니다. 4.6.6의
package.jsonscripts는 4.6.5와 같았고 설치 훅이 없었습니다. 달라진 소스는compile.ts·schemas.ts·util.ts와 테스트였습니다 - provenance 대조. npm이 패키지마다 공개하는 attestations(SLSA 형식의 빌드 출처 기록)에서 빌드 워크플로(
.github/workflows/release.yml,refs/heads/main)와 커밋(284243f)을 읽고, 그 커밋에 로더 파일 세 개가 없는지 GitHub API로 조회했습니다 - Actions 로그. 9월 21일 배포 실행 5건의 단계별 결과와, 실패한 두 배포 단계의 로그 원문(404 줄)을 받았습니다
- 설치 훅 시험. 아무 일도 하지 않고 표시 파일에 한 줄만 적는 시험 패키지 두 개를 만들었습니다(
"preinstall"을 최상위에 둔 것,scripts안에 둔 것). 이를 npm 11.11.0, pnpm 10.34.5, bun 1.3.12, yarn 1.22.22로 각각 설치했습니다. 악성 로더는 실행하지 않았습니다 - 저희 프로젝트. 이 맥의 프로젝트 잠금 파일 10개에 든 zod 버전은 3.22.3~4.4.3이었고 4.6.x는 없었습니다
| 패키지 관리자 | 최상위 "preinstall" | scripts.preinstall |
|---|---|---|
| npm 11.11.0 | 실행 안 됨 | 실행됨(--ignore-scripts를 주면 실행 안 됨) |
| yarn 1.22.22 | 실행 안 됨 | 실행됨 |
| pnpm 10.34.5 | 실행 안 됨 | 실행 안 됨. "Ignored build scripts … pnpm approve-builds" 안내 |
| bun 1.3.12 | 실행 안 됨 | 실행 안 됨 |
시험 패키지는 로컬 압축 파일로 설치했고, 레지스트리에서 받은 패키지와 동작이 같은지는 따로 보지 않았습니다. pnpm과 bun의 결과는 의존성의 설치 스크립트를 기본으로 막는 설정 때문이고, 허용 목록에 넣으면 실행됩니다.
5. 내 프로젝트의 zod를 직접 대조하는 법
프로젝트 최상위에서 실행합니다. 첫 명령이 보여 주는 해시와 잠금 파일에 적힌 integrity(설치한 압축 파일의 해시) 값이 같으면 npm 4.6.6 배포본 그대로입니다.
# npm 레지스트리가 기록한 4.6.6 배포본의 해시
npm view zod@4.6.6 dist.integrity
# sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==
# 내 잠금 파일에 적힌 zod 버전과 해시 (npm / pnpm)
grep -A3 '"node_modules/zod"' package-lock.json
grep -A2 "^ zod@4" pnpm-lock.yaml
# 설치된 패키지의 레지스트리 서명과 provenance 검증 (npm 프로젝트)
npm audit signatures
# → "verified registry signature", "verified attestation"이 나오면 통과
# 로더 파일이 설치본에 있는지 (없어야 정상)
ls node_modules/zod | grep -i meta
# 9/21 무렵 git 주소로 zod를 받은 적이 있는지 (레지스트리가 아닌 설치)
grep -rn "colinhacks/zod" package.json package-lock.json pnpm-lock.yaml bun.lock 2>/dev/null
저희가 빈 프로젝트에 zod 4.6.6을 설치해 실행했을 때 npm audit signatures는 서명과 provenance를 모두 통과했고, node_modules/zod에 meta로 시작하는 파일은 없었습니다.
6. 체크리스트
| 할 일 | 왜 |
|---|---|
| npm에서 받은 zod의 해시·provenance가 맞다면 이 사건 때문에 업데이트를 멈출 이유는 없습니다 | 4.6.6 배포본은 정상 커밋에서 빌드됐고 악성 버전은 레지스트리에 공개된 적이 없습니다. 자격 증명 유출 경위가 밝혀지지 않았으니 다음 버전도 5절 방법으로 대조할 수 있습니다 |
| "릴리스 노트에 이상한 커밋이 있다"는 경보를 보면 배포본과 provenance부터 대조하세요 | 릴리스 노트는 두 태그 사이 git 기록을 나열할 뿐입니다. 위 5절 명령이면 몇 분 안에 끝납니다 |
npm·yarn 1을 쓴다면 CI에서 --ignore-scripts(또는 .npmrc의 ignore-scripts=true)를 검토하세요 | 저희 시험에서 두 도구는 의존성의 scripts.preinstall을 그대로 실행했습니다. 네이티브 모듈처럼 설치 스크립트가 필요한 의존성이 있으면 깨지므로 목록부터 봅니다 |
| 패키지를 배포한다면 trusted publisher에 환경 이름까지 등록하세요 | Zod에서 승인 단계를 지운 공격을 막은 것은 git 밖에 있는 이 조건이었습니다 |
| 배포 워크플로를 푸시·태그로 자동 실행하지 마세요 | 워크플로 파일은 푸시 권한만 있으면 고칠 수 있습니다. 태그를 만들면 배포가 시작되던 nub는 공격 당일 실행 파일 패키지 8개의 0.9.4가 사람 승인 없이 공개됐습니다(3절) |
| npm 패키지 설정에서 "2FA 필수 + 토큰 금지"와 단계 배포를 켜세요 | npm 문서가 trusted publishing과 함께 권하는 설정입니다. 단계 배포 승인은 OIDC 토큰으로 할 수 없어 사람의 2FA가 마지막 관문이 됩니다 |
7. 아직 결론이 아닌 것
- 자격 증명이 어떻게 유출됐는지는 밝혀지지 않았습니다. 메인테이너는 9월 22일 "git push 자격 증명의 일종"이라고만 했고, 2026-10-12 토론에 같은 질문이 올라왔지만 답은 아직 없습니다.
- 4.6.8과 토큰 배포의 404 사유는 추정입니다. 워크플로 주석의 설계(trusted publisher를 환경에 묶음)와 맞는 결과이지만, npm 응답은 사유를 담지 않았습니다. 토큰이 어떤 상태였는지도 알 수 없습니다.
- 로더가 받아 오려던 내용은 모릅니다. #208477 공지는 조사 중 받은 응답이 "game over"만 출력하는 내용이었다고 적고, 그것이 이전이나 이후의 응답을 말해 주지 않는다고 덧붙였습니다. 저희는 로더를 실행하거나 그 주소에 접속하지 않았습니다.
- nub 실행 파일 패키지 0.9.4는 분석하지 않았습니다. 레지스트리에 공개돼 있다는 것만 조회했습니다.
- 하지 않은 것. 설치 훅 시험은 로컬 압축 파일로만 했고 yarn 2 이후와 Deno의 npm 설치는 시험하지 않았습니다. 다른 컴퓨터의 저희 프로젝트는 세지 않았습니다.
참고자료
원문과 기록은 2026-10-12에 대조했습니다. 이 블로그는 Zod·GitHub·npm과 관계(광고·협찬·제공)가 없습니다. 출처 증명만으로는 코드 검토를 대신할 수 없다는 점은 AI 코딩 공급망 보안 글에서 다뤘습니다.
| 출처 | 이 글이 가져온 것 | 구분 |
|---|---|---|
| GitHub Community #208477, Security advisory: malicious npm package changes affecting zod, nub, and standard-schema (2026-09-21) | 세 저장소의 공격 커밋·파일 경로·지표, nub 차단 경위, 메인테이너 답글(9/22, 9/24 수정) | 1차(GitHub·메인테이너) |
| 커밋 d821685 · PR #6620 Cleanup bad commits · v4.6.5…v4.6.6 비교 | 공격 커밋 6개의 변경 내용, 정리 시각 | 1차(저장소) |
| Actions 실행 35592436622 · 35609668087 · 35611709915 | 승인 대기·취소, 404 로그 원문, 푸시 시각 | 1차(실행 기록) |
| release.yml @284243f · 릴리스 v4.6.6 | 사건 뒤 배포 설계와 그 이유, 릴리스 노트의 커밋 목록 | 1차(저장소) |
| npm attestations zod@4.6.6 · zod 버전 기록 | 빌드 커밋 284243f, 배포 시각, 4.6.7·4.6.8 부재 | 1차(npm) |
| npm Docs, Trusted publishing · npm Docs, scripts | 환경 이름 설정, 토큰 금지·단계 배포, 설치 훅은 scripts에 정의 | 1차(npm) |
| GitHub Changelog, npm publish-time malware scanning (2026-07-28) | 새 버전을 설치 가능해지기 전에 검사하고 보류·차단한다는 설명 | 1차(GitHub) |
| JSR @zod/zod 버전 기록 | JSR 최신 4.6.6, 4.6.7·4.6.8 부재 | 1차(JSR) |
| 이슈 #6658 · Hacker News 토론 | 논란의 시작과 점수(2026-10-12 조회) | 반응 |
메인테이너나 GitHub가 유출 경위를 밝히면 7절을 고치고 개정 날짜를 적겠습니다.