Get the Zoom link
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 - Compromised Maintainer Accounts → TruffleHog Sweep of the Developer's Machine → Self-Replication Across 500+ Packages → Secrets Committed to Public Repos

Beginning on September 14, 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. How it began is disputed. Unit 42 said it may have started with a credential-harvesting phishing campaign spoofing npm and targeting MFA-update pages; the maintainer of @ctrl/tinycolor wrote that no phishing was involved and his npm token was taken from a GitHub Actions secret in a shared repository; Wiz assessed the campaign as directly downstream of the late-August s1ngularity/Nx compromise. 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, including @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: Compromised Maintainer Accounts (Vector Disputed)
01
Maintainer publishing tokens were stolen, and the published accounts disagree on how
T1566.002 - Phishing: Spearphishing Link T1111 - Multi-Factor Authentication Interception

Unit 42 said the attack may have started with a credential-harvesting campaign spoofing npm that sent maintainers to fake multi-factor authentication update pages. That pretext would be well chosen: a message telling a maintainer that npm requires them to update their multi-factor authentication settings is not obviously suspicious, and it 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. A real-time relay defeats time-based one-time codes, because the attacker stands between the victim and the genuine site and uses the code within its validity window, and the same technique had hit the debug and chalk maintainers earlier in September 2025.

Other accounts point elsewhere. The maintainer of @ctrl/tinycolor, among the first packages hit, wrote that there was no phishing involved: his npm token was taken from a GitHub Actions secret in a shared repository. Wiz assessed the campaign as directly downstream of the late-August 2025 s1ngularity/Nx compromise, and CISA's alert names no initial access vector. What every account agrees on is the target: a maintainer's npm publishing token.

Phishing hypothesis (Unit 42): Fake npm MFA-update pages; credentials plus a live TOTP code relayed in real time
Maintainer's own account (tinycolor): No phishing; npm token taken from a GitHub Actions secret
Wiz: Directly downstream of the s1ngularity/Nx compromise
Precedent: The debug and chalk maintainer phishing earlier in September 2025
Control against the phishing route: Phishing-resistant FIDO2 or passkeys, which are origin-bound
Maintainer AccountVector DisputedT1111
📦 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 breaks the phishing route at step one. Where the entry is a relayed one-time code, as Unit 42 suggested here, 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