Mini Shai-Hulud의 재공격: 314개의 npm 패키지 침해
요약
npm 관리자 계정 탈취를 통해 314개의 패키지에 악성 코드가 주입된 대규모 공급망 공격이 발생했습니다. 공격자는 Mini Shai-Hulud 툴킷을 사용하여 AWS, Kubernetes, GitHub 토큰 및 SSH 키 등 민감한 자격 증명을 탈취하며, Claude Code와 Codex 같은 AI 코딩 도구까지 하이재킹하는 정교한 수법을 사용합니다.
핵심 포인트
- npm 계정 탈취(Account Takeover)를 통한 317개 패키지 및 637개 악성 버전 게시
- Mini Shai-Hulud 툴킷을 활용한 AWS, Kubernetes, GitHub, SSH 키 등 광범위한 자격 증명 탈취
- Claude Code 및 Codex 세션을 하이재킹하여 악성 코드를 재실행하는 정교한 공격 기법
- GitHub 커밋 메시지를 C2 백도어로 활용하는 'dead-drop' 방식 및 Docker 컨테이너 탈출 시도
- preinstall 훅과 사칭 커밋을 이용한 자동화된 감염 및 전파 경로 구축
npm 상의 node-ipc 버전 9.1.6, 9.2.3, 12.0.1에 대한 침해 분석: 관리자 계정 탈취(Account Takeover)를 통해 100개 이상의 민감한 파일(SSH 키 등...)을 노리는 80KB 크기의 난독화된 자격 증명 탈취기(Credential Stealer)가 주입되었습니다.
npm 계정 atool ([email protected])이 2026년 5월 19일에 침해되었습니다. 공격자는 22분간의 자동화된 폭발적 공격을 통해 317개 패키지에 걸쳐 637개의 악성 버전을 게시했습니다. 영향을 받은 패키지에는 size-sensor (월간 다운로드 420만 회), echarts-for-react (380만 회), @antv/scale (220만 회), timeago.js (115만 회) 및 수백 개의 @antv 스코프 패키지가 포함됩니다. 페이로드(Payload)는 498KB 크기의 난독화된 Bun 스크립트로, 3주 전 SAP 침해 사고에서 사용된 Mini Shai-Hulud 툴킷과 일치합니다. 즉, 동일한 스캐너 아키텍처, 동일한 자격 증명 정규 표현식(Regex) 세트, 동일한 난독화 패턴을 사용합니다. 이 스크립트는 전체 AWS 체인(환경 변수, 설정 파일, EC2 IMDS, ECS 컨테이너 메타데이터, Secrets Manager), Kubernetes 서비스 계정 토큰, HashiCorp Vault, GitHub PAT(Personal Access Tokens), npm 토큰, SSH 키, 그리고 로컬 비밀번호 관리자 금고(1Password, Bitwarden, pass, gopass) 전반에서 자격 증명을 수집합니다. 탈취된 데이터는 두 개의 병렬 채널을 통해 유출됩니다: 침해된 토큰으로 생성된 공개 GitHub 저장소에 커밋된 Git 객체(User-Agent는 python-requests/2.31.0으로 위조됨), 그리고 OpenTelemetry 트레이스 데이터로 위장하여 t.m-kosche[.]com으로 전송되는 RSA+AES 암호화된 HTTPS POST 요청입니다. CI(지속적 통합) 환경에서 페이로드는 GitHub Actions OIDC 토큰을 npm 게시 토큰으로 교환하고, 탈취된 신원을 사용하여 Sigstore(Fulcio + Rekor)를 통해 아티팩트(Artifact)에 서명하며, .github/workflows/codeql.yml에 지속성(Persistence)을 주입합니다. 페이로드는 SessionStart 훅을 주입하여 Claude Code와 Codex를 하이재킹하며, 이를 통해 로컬 및 접근 가능한 GitHub 저장소에 대한 커밋을 통해 모든 AI 세션마다 악성코드를 재실행합니다. VS Code의 경우 동일한 효과를 위해 `
)는 GitHub 데드 드롭 (dead-drop) C2 백도어를 설치합니다. 이는 Python 데몬 (daemon)으로, firedalazer 키워드가 포함된 커밋 메시지 내에서 RSA-PSS로 서명된 명령을 찾기 위해 매시간 GitHub의 커밋 검색 API (commit search API)를 폴링 (polling)하며, 서명된 URL로부터 임의의 Python 코드를 다운로드하여 실행합니다. 별도의 gh-token-monitor 데몬은 60초 간격으로 탈취된 GitHub 토큰을 폴링합니다. 또한 페이로드 (payload)는 호스트 소켓 (host socket)을 통한 Docker 컨테이너 탈출 (container escape)을 시도하며, 다른 로컬 Node.js 프로젝트로 감염을 전파합니다.
이 공격은 두 가지 실행 경로를 사용합니다. 침해된 각 버전은 preinstall 훅 (hook, bun run index.js)을 추가합니다. 637개 버전 중 630개는 antvis/G2 GitHub 저장소의 사칭 커밋 (imposter commits)을 가리키는 optionalDependencies 항목을 주입합니다. 이들은 저작자가 위조된 고아 커밋 (orphan commits)으로, 저장소의 브랜치 히스토리 (branch history)에는 보이지 않으며, 대상 저장소에 대한 쓰기 권한 없이도 GitHub의 포크 객체 공유 (fork object sharing)를 악용하여 페이로드의 두 번째 복사본을 호스팅합니다. npm의 github: 의존성 해결 (dependency resolution) 방식은 SHA를 통해 해당 콘텐츠를 가져와 실행합니다.
영향 (Impact):
echarts-for-react의 ^3.0.6 버전이 침해된 버전으로 자동 해결 (auto-resolve)됩니다. python-requests/2.31.0의 SessionStart 훅, Codex 훅, 그리고 VS Code의 `
a68dd1e6a6e35ec3771e1f94fe796f55dfe65a2b94560516ff4ac189390dfa1c
antvis/G2
(orphan, forged author, message: “New Package”):1916faa365f2788b6e193514872d51a242876569
(626 versions)7cb42f57561c321ecb09b4552802ae0ac55b3a7a
(2 versions)dc3d62a2181beb9f326952a2d212900c94f2e13d
(1 version, garbage collected)@antv/setup: github:antvis/G2#<commit-sha>
{word1}-{word2}-{number}
여기서 word1은 다음 중 하나입니다: sardaukar, mentat, fremen, atreides, harkonnen, gesserit, prescient, fedaykin, tleilaxu, siridar, kanly, sayyadina, ghola, powindah, prana, kralizec; word2는 다음 중 하나입니다: sandworm, ornithopter, heighliner, stillsuit, lasgun, sietch, melange, thumper, navigator, fedaykin, futar, phibian, slig, cogitor, laza, ghola; number는 0-999입니다. 설명: “Shai-Hulud: Here We Go Again” (소스에서 역순)
hxxps://t.m-kosche[.]com/api/public/otel/v1/traces
(RSA+AES 암호화, OpenTelemetry 트레이스로 위장) 169.254.169.254
(EC2 메타데이터) 및 169.254.170.2
(ECS 컨테이너 메타데이터)chore/add-codeql-static-analysis
컴프로마이즈된 토큰으로 접근 가능한 저장소 내에서 .github/workflows/codeql.yml에 있는 워크플로우 이름 Run Copilot을 사용하여 toJSON(secrets)를 format-results.txt로 덤프하는 작업
.claude/settings.json
여기에 SessionStart가 포함되어 있고, 노드에서 node .claude/setup.mjs를 실행하는 후크
.vscode/tasks.json
그리고 `
만약 락파일 (lockfiles)을 감사하거나 영향을 받은 머신에서 재설치를 진행 중이라면, Package Manager Guard (pmg)를 사용할 수 있습니다. pmg는 preinstall 스크립트가 실행되기 전에 위협 인텔리전스 (threat intelligence)를 바탕으로 패키지를 평가하는 오픈 소스 설치 프록시 (install proxy)입니다. pmg의 의존성 쿨다운 (dependency cooldown) 기능은 설정 가능한 시간 범위 내에 게시된 버전을 거부할 수 있으며, 이는 semver 범위가 갓 게시된 악성 릴리스로 여전히 해결되던 2026년 5월 19일의 급증 사례와 같은 공격을 방어하는 데 도움이 됩니다.
atool npm 계정은 547개의 패키지를 유지 관리하고 있습니다. 공격자는 2026년 5월 19일에 두 차례의 자동화된 파동을 통해 해당 패키지 중 314개에 걸쳐 637개의 악성 버전을 게시했습니다:
| 파동 (Wave) | 시간 (UTC) | 게시된 버전 수 | 패턴 |
|---|---|---|---|
| 첫 번째 | 01:39 - 01:56 | 약 317개 버전 | 01:39-01:49 사이의 4개 초기 테스트 게시를 포함한 초기 급증 |
| 두 번째 | 02:05 - 02:06 | 약 314개 버전 | 동일한 패키지들에 대한 두 번째 버전 업데이트 (version bump) |
대부분의 패키지(309개)는 각 파동당 하나씩, 정확히 2개의 악성 버전을 받았습니다. 4개의 패키지 (size-sensor, echarts-for-react, jest-canvas-mock, jest-date-mock)는 3개의 버전을 받았으며, 이는 대량 게시 전에 초기 테스트용으로 사용되었음을 시사합니다.
영향을 받은 패키지 중 영향력이 가장 큰 샘플:
공격자는 대부분의 패키지에서 latest 배포 태그 (dist-tag)를 이동시키지 않았습니다. echarts-for-react의 경우, latest는 여전히 3.0.6을 가리키고 있습니다. 하지만 이는 아무런 보호를 제공하지 못합니다. npm의 semver 해결 (semver resolution) 방식은 latest 태그와 상관없이 범위에 매칭되는 가장 높은 버전을 선택하기 때문입니다. package.json에 "echarts-for-react": "^3.0.6"을 포함하는 모든 프로젝트는 다음 클린 설치 (clean install) 시 3.2.7 (악성) 버전으로 해결됩니다.
침해된 모든 버전은 package.json에 정확히 두 가지 변경 사항을 적용합니다:
preinstall 후크 (hook)는 모든 의존성 설치 전에 실행되며 런타임 (runtime)으로 Bun을 요구합니다. 637개의 악성 버전 중 630개는 또한 optionalDependencies 항목을 주입하여, 합법적인 antvis/G2 GitHub 저장소를 통해 페이로드 (payload)의 두 번째 복사본을 전달합니다 (아래 antvis/G2의 Imposter Commits 섹션 참조).
index.js
파일은 단일 행으로 구성된 498KB 크기의 난독화된 (obfuscated) Bun 번들입니다. 이 구조는 3주 전 SAP 침해 사고에서 발견된 Mini Shai-Hulud 페이로드 (payload)와 직접적으로 일치합니다. 즉, 동일한 Bun 런타임 (runtime) 요구 사항, 동일한 16진수 변수 난독화 패턴 (hex-variable obfuscation pattern), 100KB 플러시 임계값 (flush threshold)을 가진 동일한 스캐너 아키텍처 (scanner architecture), 그리고 동일한 자격 증명 정규식 세트 (credential regex set)를 사용하고 있습니다. 이 페이로드는 두 단계의 난독화를 사용합니다: 16진수 변수 문자열 조회 테이블 (_0x1169가 배열 _0x5e03에서 해결됨)과, 환경 변수 이름, 파일 경로, C2 URL과 같은 모든 민감한 문자열에 대해 base64 + XOR를 사용하는 암호화된 문자열 디코더 (fc2edea72)입니다.
임포트 (imports)를 통해 전체 기능 범위를 확인할 수 있습니다:
페이로드의 메인 함수인 J2()는 스캐너 아키텍처를 통해 공격을 조율합니다. 이 함수는 서로 다른 자격 증명 유형을 대상으로 하는 여러 스캐너 클래스 (scanner classes)를 인스턴스화하고, 100KB 플러시 임계값을 가진 배치 전송기 (Po)를 통해 결과를 전송합니다. CI 환경 탐지 모듈은 환경 변수를 통해 20개 이상의 플랫폼을 확인합니다: GitHub Actions (GITHUB_ACTIONS), Jenkins (JENKINS_URL, JENKINS_HOME), GitLab CI (GITLAB_CI), CircleCI (CIRCLECI), Travis (TRAVIS), Buildkite (BUILDKITE), Drone (DRONE), TeamCity (TEAMCITY_VERSION), AppVeyor (APPVEYOR), Bitbucket Pipelines (BITBUCKET_BUILD_NUMBER), Bitrise (BITRISE_IO), Semaphore (SEMAPHORE), CodeBuild (CODEBUILD_BUILD_ID), Azure DevOps (BUILD_BUILDURI), Cirrus CI (CIRRUS_CI), Netlify (NETLIFY), Vercel (VERCEL), CF Pages (CF_PAGES), Buddy (BUDDY_WORKSPACE_ID), Vela (VELA), Screwdriver (SCREWDRIVER), SailCI (SAILCI), Wercker (WERCKER_MAIN_PIPELINE_STARTED), Shippable (SHIPPABLE), Distelli (DISTELLI_APPNAME), 그리고 JetBrains Space (JB_SPACE_EXECUTION_NUMBER)입니다. GitHub Actions에서 실행될 경우, 워크플로 실행 (workflow runs), 아티팩트 (artifacts), 시크릿 메타데이터 (secrets metadata), 그리고 OIDC 토큰 교환 (OIDC token exchange)과 같은 추가 데이터 수집 기능이 활성화됩니다.
페이로드(Payload)는 80개 이상의 환경 변수(모든 이름은 fc2edea72를 통해 암호화됨)를 읽고 정규 표현식(regex) 패턴을 사용하여 파일 내용을 스캔합니다. 이 정규 표현식 세트를 통해 공격자가 무엇을 노리는지 알 수 있습니다:
스캐너는 또한 AWS STS 신원 응답을 파싱하여, GetCallerIdentity 호출로부터 <Account> 및 <Arn> XML 태그를 추출합니다.
별도의 파일 스캔 클래스(zo)는 홈 디렉토리에서 민감한 경로를 읽습니다. 대상 경로는 fc2edea72를 통해 암호화되어 있지만, 코드는 경로 맵(path map)에서 LINUX 키를 참조하고 os.homedir()를 통해 ~를 해석하여 다음과 같은 표준 자격 증명(credential) 위치를 타겟팅합니다: .ssh, .aws/credentials, .npmrc, .docker/config.json, .kube/config 및 유사한 경로들.
페이로드는 Docker 소켓을 확인하며, 존재할 경우 세 가지 순차적인 방법을 통해 컨테이너 탈출(container escape)을 시도합니다:
C2() 함수(
탈취된 GitHub 토큰을 검증한 다음, 조직(organizations)을 열거하기 위해 GET /user/orgs를 호출합니다. 이 과정에서 x-oauth-scopes 응답 헤더를 검사하여 repo 및 public_repo 권한이 있는지 확인합니다. repo 스코프(scope)를 가진 토큰은 _r 송신자 클래스(sender class, GitHub 기반 데이터 유출)를 활성화합니다. 충분한 스코프가 없는 토큰은 폐기됩니다.
2단계: 데이터 유출(Exfiltration) 저장소 생성. v1() 함수는 침해된 계정 아래에 새로운 공개 저장소(public repository)를 생성합니다:
저장소 설명(description)을 복호화하면 뒤집힌 문자열인 niagA oG eW ereH :duluH-iahS가 나타나며, 이를 정방향으로 읽으면 **“Shai-Hulud: Here We Go Again”**이 됩니다. 공격자는 저장소의 노출 면적(surface area)과 가시성을 줄이기 위해 이슈(issues), 위키(wiki), 토론(discussions) 기능을 비활성화합니다.
O2() 함수는 두 개의 하드코딩된 배열(X1 및 D1)에서 각각 하나의 단어를 선택하여 저장소 이름을 생성합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 HN AI Posts의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기