Cloud Security Office Hours Banner

Shai-Hulud npm Worm 2025

Step-by-step kill chain mapped to MITRE ATT&CK Cloud, sourced from official post-mortems and primary technical analyses.

September 2025 Critical npm

Shai-Hulud - npm Phishing → TruffleHog Sweep of the Developer's Machine → Self-Replication Across 500+ Packages → Secrets Committed to Public Repos

Detected on September 15, 2025, Shai-Hulud was the first genuine worm in the npm ecosystem: malware that spread from maintainer to maintainer with no attacker involvement after the first push. It began with a credential-harvesting phishing campaign spoofing npm and targeting MFA-update pages. Once it held a maintainer's publish token, a 3.6MB bundled payload running as a postinstall script downloaded TruffleHog - a legitimate secret-scanning tool - and turned it on the developer's own machine, harvesting npm tokens, GitHub personal access tokens, AWS, GCP and Azure keys, and SSH keys. Then came the part that made it a worm: it authenticated to npm as that maintainer, queried the registry for up to twenty other packages they owned, injected itself into each, and force-published. It also created a public GitHub repository named "Shai-Hulud" in the victim's own account and committed the stolen secrets into it. More than 500 packages were compromised, starting with @ctrl/tinycolor at over two million weekly downloads. CISA issued an alert on September 23.

500+ packages compromised, starting with 40+ across several maintainers
Up to 20 packages re-published per compromised maintainer, automatically
TruffleHog - a defender's tool, run against the developer
Returned as Shai-Hulud 2.0 in November 2025, then Mini Shai-Hulud in 2026
📄 Unit 42 - Shai-Hulud worm compromises the npm ecosystem ↗ 📄 CISA - Widespread supply chain compromise impacting the npm ecosystem ↗
Initial Access - Phishing the MFA Update Page
01
A credential-harvesting campaign spoofing npm targeted maintainers with fake multi-factor authentication update pages
T1566.002 - Phishing: Spearphishing Link T1111 - Multi-Factor Authentication Interception

The pretext was well chosen. A message telling a maintainer that npm requires them to update their multi-factor authentication settings is not obviously suspicious, arrives in a plausible register, and drives the victim to a page where entering both a password and a live one-time code is exactly what the workflow appears to call for. That is the specific weakness of time-based one-time codes: a real-time relay defeats them, because the attacker is standing between the victim and the genuine site and using the code within its validity window. The same technique had hit the debug and chalk maintainers earlier in September 2025, so this was a proven approach against this exact community before Shai-Hulud used it.

Pretext: npm requires an MFA settings update
Captured: Credentials plus a live TOTP code, relayed in real time
Why TOTP fails here: A real-time relay uses the code inside its validity window
Precedent: The debug and chalk maintainer phishing earlier in September 2025
Control that breaks it: Phishing-resistant FIDO2 or passkeys, which are origin-bound
MFA PhishingTOTP RelayMaintainer AccountT1111
📦 Supply Chain - A 3.6MB Bundle in a Postinstall Hook
02
Trojanized versions carried bundle.js, a 3.6MB webpack-bundled payload executed by a postinstall script on every install
T1195.002 - Compromise Software Supply Chain T1059.007 - JavaScript

The first packages hit included @ctrl/tinycolor, which serves over two million downloads a week, alongside more than forty others spanning several maintainers. The payload was a single minified webpack bundle of roughly 3.6MB, internally modular across reconnaissance, credential harvesting and propagation. Size is worth noticing as a detection signal in its own right: a colour-manipulation utility does not need a multi-megabyte bundle, and a sudden order-of-magnitude jump in package size between patch versions is a cheap, high-signal check that almost nobody runs. Because it executed as a postinstall script, installation alone was sufficient - the package never had to be imported or called.

First packages: @ctrl/tinycolor (2M+ weekly downloads) and 40+ others across several maintainers
Payload: bundle.js, ~3.6MB minified webpack bundle, modular internally
Trigger: postinstall script - installing was enough (2.0 later moved to preinstall)
Cheap detection nobody runs: Package size jumping an order of magnitude between patch versions
Eventual scale: 500+ packages
bundle.jspostinstall@ctrl/tinycolorT1195.002
🔑 Credential Access - TruffleHog Pointed the Wrong Way
03
The payload fetched TruffleHog, a legitimate secret-scanning tool, and ran it against the developer's own filesystem and environment
T1552.001 - Unsecured Credentials: Credentials In Files T1105 - Ingress Tool Transfer

TruffleHog exists so defenders can find secrets accidentally committed to their repositories. Shai-Hulud downloaded it and pointed it at the victim. This is living off the land applied to the security toolchain, and it is efficient for the same reason the tool is useful: someone has already done the hard work of enumerating what every cloud provider's credential format looks like and where it tends to be stored. It also frustrates detection, because a widely used open-source binary with a legitimate purpose is a poor signature. The haul was comprehensive - npm tokens from .npmrc, GitHub personal access tokens, AWS, GCP and Azure keys, and SSH private keys usable for lateral movement into networks the developer could reach.

Tool abused: TruffleHog, downloaded to the victim host and run locally
Harvested: npm tokens from .npmrc, GitHub PATs, AWS/GCP/Azure keys, SSH private keys
Why it is effective: The tool already knows every credential format and where they live
Why it evades detection: A legitimate, widely used binary makes a poor signature
Detection angle: Unexpected download and execution of security tooling by a build or install process
TruffleHogLiving Off the LandMulti-Cloud KeysT1105
🐛 Propagation - The Part That Made It a Worm
04
Holding the maintainer's npm token, the malware queried the registry for up to twenty packages they owned, injected itself into each, and force-published new versions
T1195.002 - Compromise Software Supply Chain T1078 - Valid Accounts

Every earlier npm compromise on this site required a human at each hop: someone had to steal a token, decide what to publish, and push it. Shai-Hulud removed the human. Using the token it had just harvested, the malware authenticated to npm as that maintainer, asked the registry which packages they owned, took up to twenty of them, injected its own payload, and published. Each of those packages then infected the next developer who installed it, whose tokens produced the next twenty packages, and so on. Growth is compounding rather than linear, and the propagation speed is bounded only by how often people run npm install - which, in a modern CI-driven ecosystem, is constantly. Reaching 500+ packages did not require 500 attacker decisions. It required one.

Mechanism: Authenticate to npm as the compromised maintainer with the stolen token
Selection: Query the registry for packages owned by that account, take up to 20
Action: Inject the payload and force-publish new versions
Attacker involvement per hop: None
Growth: Compounding, bounded only by how often the ecosystem installs
Self-ReplicationWormAutomated PublishT1195.002
🔐 Persistence - A Workflow That Leaks Every Secret on Push
05
A planted .github/workflows/shai-hulud-workflow.yml triggered on push and exfiltrated the entire secrets context using ${{ toJSON(secrets) }}
T1543 - Create or Modify System Process T1552.007 - Container API

Where the malware could reach a repository, it wrote a GitHub Actions workflow carrying a base64-encoded bash payload, triggered on push. The expression ${{ toJSON(secrets) }} serializes the job's entire secrets context in one go, so the workflow did not need to know what any particular repository's secrets were called - it took all of them and shipped them to command and control. This is durable in a way the package payload is not: removing the malicious dependency does nothing to a workflow file sitting in the repository, which will keep firing on every push by whoever commits next. Any response that stopped at "downgrade the package" left this running.

Artifact: .github/workflows/shai-hulud-workflow.yml, carrying base64-encoded bash
Trigger: push
Payload expression: ${{ toJSON(secrets) }} - the whole secrets context, whatever it contains
Why it survives cleanup: Removing the npm package does not remove a committed workflow file
Hunt: Search every repository for that filename and for toJSON(secrets) in any workflow
Workflow BackdoortoJSON(secrets)Survives DowngradeT1543
📤 Exfiltration - Stolen Secrets, Published Publicly
06
Harvested credentials were serialized to JSON and committed to a newly created public repository named "Shai-Hulud" in the victim's own GitHub account
T1567.001 - Exfiltration to Code Repository

Using the stolen GitHub token and the /user/repos endpoint, the malware created a public repository in the victim's own account and committed the harvested secrets into it as JSON. As with Nx / s1ngularity the same month, the destination is GitHub itself, so there is no attacker infrastructure to blocklist and no unusual egress to notice. And as there, the repositories were public, which converted a theft into a broadcast: every secret was readable by anyone who found the repo, including the many automated scrapers that watch GitHub for exactly this. Some variants also exfiltrated to a webhook.site endpoint. Two independent groups of npm incidents in one month settling on the same trick is worth reading as a pattern rather than a coincidence.

Method: GitHub /user/repos API with the stolen token, creating a public repo
Repo name: "Shai-Hulud" (2.0 used "Sha1-Hulud: The Second Coming")
Content: Harvested credentials serialized as JSON
Secondary channel: A webhook.site endpoint in some variants
Consequence: World-readable secrets, exposed to every credential scraper watching GitHub
Public Repo Exfil/user/reposwebhook.siteT1567.001
💥 Impact - CISA Alert, Then Two Descendants
07
CISA issued an alert on September 23, 2025; the worm returned in November as Shai-Hulud 2.0 and again in 2026 as the TanStack variant
T1195 - Supply Chain Compromise

CISA published an alert on the npm ecosystem compromise on September 23, 2025, which is the correct signal for a supply-chain event of this shape: the affected population is everyone who installed, not everyone who was targeted. The lineage since is the reason this entry matters more than its own package count. In November 2025 the worm returned as Shai-Hulud 2.0, or "Sha1-Hulud: The Second Coming", moving execution to preinstall, hitting roughly 796 packages, spawning more than 25,000 malicious repositories across about 350 accounts, and adding a discussion.yaml workflow that registered infected machines as self-hosted runners for arbitrary command execution. In 2026 the technique appeared again in Mini Shai-Hulud / TanStack, which needed no stolen token at all - it poisoned the Actions cache and scraped OIDC from runner memory, publishing packages with valid SLSA provenance. Each generation needed less stolen from the maintainer than the last.

September 23, 2025: CISA alert on the npm ecosystem compromise
November 2025: Shai-Hulud 2.0 - preinstall execution, ~796 packages, 25,000+ repos across ~350 accounts
2.0 addition: discussion.yaml registering infected machines as self-hosted runners
2026: Mini Shai-Hulud / TanStack - no stolen token, cache poisoning and OIDC theft instead
Trajectory: Each generation required less stolen from the maintainer than the last
CISA AlertShai-Hulud 2.0Self-Hosted RunnerT1195

🛡 How to Defend Against This Chain

Phishing-resistant MFA on maintainer accounts is the control that breaks this at step one. The whole worm rests on a relayed one-time code. FIDO2 keys and passkeys are bound to the origin, so a lookalike domain cannot relay them no matter how convincing the pretext. If you maintain a package that anyone else depends on, this is the single highest-leverage thing you can do, and registry-level enforcement for high-download packages is the ecosystem version of the same argument.
Disable install scripts, and pin dependencies by hash. Everything the payload did began with a postinstall hook, so npm install --ignore-scripts in CI and on developer machines neutralizes it without knowing anything about this specific worm. Pair that with a lockfile whose integrity hashes are enforced, so a force-published version cannot silently enter your build, and add a delay policy - not installing anything published in the last 24 to 48 hours would have skipped most of this worm's usable window.
Replace long-lived publish tokens with short-lived, scoped, provenance-backed publishing. The worm propagated because a stolen token was a standing licence to publish anything the account owned. npm Trusted Publishing with OIDC removes the stealable object entirely, SLSA provenance ties a release to a specific workflow run, and granular tokens scoped to one package cap what any single theft can reach. Revoke tokens that bypass 2FA.
Hunt the persistence, not just the package. Search every repository for .github/workflows/shai-hulud-workflow.yml, for any workflow containing toJSON(secrets), and for public repositories named "Shai-Hulud" or "Sha1-Hulud" in your organization and your developers' personal accounts. For the 2.0 wave, check for discussion.yaml and for unexpected self-hosted runners registered against your repositories. Downgrading a package removes none of these.
Alert on your own security tooling being fetched and run by a build. A postinstall script downloading and executing TruffleHog is a strong signal precisely because the tool is legitimate: nothing about your dependency install should be pulling a secret scanner off the internet. More generally, treat unexpected ingress of any security binary into a build environment as suspicious, and default-deny egress from build runners so the fetch fails in the first place.
Assume anything a developer machine could reach is compromised, and rotate on that basis. The haul spanned npm, GitHub, all three major clouds and SSH keys, so the response scope is every credential present on or reachable from an affected machine, not just the npm token. Then check GitHub audit logs for repositories you did not create and for private repositories that changed visibility, because that is how the same month's s1ngularity second wave played out.

Related defense topics