TeamPCP's Mini Shai-Hulud Is Back: A Self-Spreading Supply Chain Attack Compromises TanStack npm Packages - StepSecurity
핵심 포인트
- 1TeamPCP 위협 그룹은 CI/CD 파이프라인을 악용하여 유효한 SLSA Build Level 3 provenance가 포함된 악성 버전을 게시함으로써 다수의 @tanstack 및 기타 npm 패키지를 손상시키는 새로운 "Mini Shai-Hulud" 웜을 시작했습니다.
- 2이 웜은 `pull_request_target` Pwn Request, GitHub Actions cache poisoning, 그리고 runner memory에서 OIDC token을 추출하는 방식을 연결하여 자체 전파되었으며, 손상된 maintainer가 관리하는 모든 패키지를 동적으로 찾아 감염시켰습니다.
- 3정교한 payload는 GitHub Actions runner의 메모리 내 모든 secrets를 스크래핑하고, 100개 이상의 파일 경로(클라우드, 암호화폐, AI 도구 포함)에서 credentials을 수집했으며, IDE 및 OS 서비스에 persistence hooks를 설치하고 데이터를 외부로 유출했습니다.
TeamPCP 위협 그룹이 "Mini Shai-Hulud"라는 자가 전파 npm worm의 새로운 공격 wave를 시작했습니다. 이 공격은 처음에 @tanstack npm packages를 손상시켰지만, 이후 UiPath, DraftLab 등 다른 maintainer의 packages로 확산되었습니다. 이 worm은 CI/CD secrets를 훔쳐 packages를 감염시키고, 합법적인 SLSA Build Level 3 provenance attestations와 함께 악성 버전을 배포하는 것이 특징입니다. StepSecurity AI Package Analyst에 의해 탐지되었으며, TanStack은 42개 이상의 @tanstack/* packages에 걸쳐 84개의 악성 버전이 게시되었음을 확인했습니다.
Core Methodology:
이 공격은 세 가지 핵심 단계를 통해 이루어졌습니다:
- Staging the Payload in a Fork:
voicproducoes라는 GitHub 계정을 사용했습니다. 이 포크에는 다음 두 파일이 포함된 commit 79ac49ee가 추가되었습니다:
package.json: 악성@tanstack/setuppackage를 정의합니다. 이 package는preparelifecycle hook에bun run tanstack_runner.js && exit 1이라는 script를 포함하여, 의존성 설치 시 자동으로tanstack_runner.js(주 악성 payload)를 실행하게 합니다.&& exit 1은 선택적 의존성(optional dependency)이 정상적으로 실패한 것처럼 보이게 하여 흔적을 최소화합니다.tanstack_runner.js: 2.3MB 크기의 난독화된(obfuscated) JavaScript 파일로, github: URL (예:github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c)을 통해 참조되어 합법적인 것처럼 보이게 합니다.
- Injecting the Payload into Published Packages:
package.json에 악성@tanstack/setuppackage를 참조하는 새optionalDependencies필드가 추가되었습니다.router_init.js(주 worm payload)라는 2.3MB 크기의 난독화된 파일이 package root에 직접 삽입되었습니다. 이 파일은package.json의files필드에 나열되지 않아 정상적인 빌드 프로세스를 우회했음을 나타냅니다.
- Publishing via the Legitimate CI/CD Pipeline:
pull_request_target Pwn Request와 GitHub Actions cache poisoning을 chaining하여 GitHub Actions runner process memory에서 OIDC tokens를 추출했습니다. 추출된 OIDC token (id-token: write 권한 포함)을 사용하여 합법적인 CI/CD pipeline을 통해 npm에 직접 package를 게시했습니다.또한, 이 악성 코드는 훔친 GitHub OIDC token을 사용하여 Sigstore의 Fulcio signing certificate를 얻고, 이를 통해 악성 packages에 대해 유효한 SLSA v1 provenance attestations를 생성했습니다. 이는 악성 packages가 합법적인 빌드 시스템에 의해 생성된 것처럼 보이게 만듭니다.
Worm Self-Propagation (Mini Shai-Hulud):
Mini Shai-Hulud는 훔친 credentials을 사용하여 추가 package들을 감염시키는 진정한 worm입니다.
- Finding a Publishable Token:
registry.npmjs.org/-/npm/v1/tokensAPI를 쿼리하여bypass_2fa가 true로 설정된 npm token을 찾습니다. - Enumerating Target Packages: 훔친 token으로 registry.npmjs.org/-/v1/search?text=maintainer:${username} API를 호출하여 동일한 maintainer가 게시한 모든 package를 나열합니다.
- OIDC Token Exchange for Publishing: CI/CD 환경에서 worm은 GitHub OIDC token을 package별 npm publish token으로 교환하여 기존 인증을 우회합니다.
registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${pkg}API를 사용합니다. - Infected Package Publishing: 감염된
tarball을 각 대상 package에 게시합니다.
Malicious Payload Analysis:
router_init.js payload (SHA-256: ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c)는 2.3MB 크기의 난독화된 JavaScript 파일로, 다음과 같은 정교한 기능을 포함합니다:
- Three Layers of Obfuscation:
- Layer 1 (obfuscator.io String Table): 11,516개의 custom-base64-encoded string이 shuffled array에 저장되며, startup 시 IIFE push/shift rotation을 통해 해독됩니다. 변수 이름은 16진수 접두사를 사용합니다.
- Layer 2 (Secondary Cipher -
beautify()): PBKDF2-SHA256 (salt:svksjrhjkcejg, 200,000 iterations)으로 파생된 master key를 사용하는 byte별 Fisher-Yates substitution cipher입니다. C2 domain, credential file paths 등 민감한 string을 해독합니다. - Layer 3 (AES-256-GCM Encrypted Payloads -
w8()): 11개의 보조 payload는 AES-256-GCM으로 암호화되어 있으며, Bun runtime을 통해 gunzip 압축 해제됩니다. 이 payload들은 Bun 설치, Runner.Worker memory scraping, GitHub Actions workflow injection, persistence configuration 등의 script를 포함합니다.
w8() 복호화 함수는 다음과 같습니다:import { createDecipheriv } from 'crypto';
function w8 (key, encryptedData) {
let keyBuf = Buffer.from(key, 'base64');
let dataBuf = Buffer.from(encryptedData, 'hex');
let iv = dataBuf.subarray(0, 12);
let authTag = dataBuf.subarray(12, 28);
let ciphertext = dataBuf.subarray(28);
let decipher = createDecipheriv('aes-256-gcm', keyBuf, iv);
decipher.setAuthTag(authTag);
let decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return new TextDecoder().decode(Bun.gunzipSync(decrypted));
}- Runner.Worker Memory Scraping:
/proc/{pid}/mem을 통해 GitHub Actions runner process memory를 직접 읽어옵니다. 이를 통해 workflow에 구성된 모든 secrets, 심지어 mask 처리된 것도 추출합니다. 이 스크립트는 sudo python3를 통해 실행되며, GitHub Actions runner의 in-memory secret representation을 grep -aoE '"[^"]+":{"value":"[^"]*","isSecret":true}' 정규식을 사용하여 찾아냅니다. 이는 특히 Linux 기반 GitHub Actions runners에서 활성화됩니다.- Cloud Credential Theft:
- AWS IMDSv2: IMDSv2 (Instance Metadata Service Version 2) 흐름을 사용하여 세션 token을 얻고, IAM roles를 열거하며, 임시 자격 증명(AccessKeyId, SecretAccessKey, Token)을 추출합니다.
- ECS containers:
169.254.170.2를 통해 자격 증명을 획득합니다. - HashiCorp Vault: 로컬 agent socket (
127.0.0.1:8200)에서 Vault 자격 증명을 수집하며, 다음과 같은 token pattern matchers를 사용합니다:
npmtoken: /npm_[A-Za-z0-9]{36,}/g, ghtoken: /gh[op]_[A-Za-z0-9]{36}/g, vaultToken: /hvs\.[A-Za-z0-9_-]{24,}/g, k8sToken: /eyJhbGciOiJSUzI1NiIsImtpZCI6[\w\-.]+/g, awsKey: /AKIA[0-9A-Z]{16}/g.- Credential File Harvest:
- Persistence:
- Claude Code Session Hook:
.claude/settings.json에SessionStarthook을 추가하여node .vscode/setup.mjs를 실행합니다. - VS Code Task Hook:
.vscode/tasks.json에folderOpen시node .claude/setup.mjs를 실행하는 task를 추가합니다. - OS-Level Service Persistence:
gh-token-monitor서비스를 설치하여 재부팅 후에도 지속적으로 GitHub token을 모니터링하고 재유출합니다. macOS에서는launchctl, Linux에서는systemd를 사용합니다.
- GitHub Actions Workflow Injection:
.github/workflows/codeql_analysis.yml로 위장)를 작성합니다.
- Variant 1 (Direct POST to C2):
toJSON(secrets)를 사용하여 모든 repository secrets를 C2 서버 (api.masscan.cloud)로 직접 POST합니다.on: deployment트리거를 사용합니다. - Variant 2 (Artifact Upload):
toJSON(secrets)를 사용하여 모든 secrets를 workflow artifact로 업로드하며,on: push트리거를 사용합니다. 이는actions/upload-artifact를 통해 이루어집니다.
- Exfiltration Architecture:
claude@users.noreply.github.com 계정으로 dead-drop commits를 생성하고, Dependabot-style의 Dune universe에서 가져온 branch 이름을 사용하여 위장합니다.