flawopen.com/Incidents/Shai-Hulud npm worm

Shai-Hulud: the npm worm that published itself

Critical — Ecosystem-wide CWE-506: Embedded Malicious Code September 2025
ELI5

Imagine a factory where any worker can ship a new batch of parts, and every downstream factory installs those parts automatically without inspecting them. Someone tricks one worker into handing over their badge. The thief doesn't just tamper with one batch — they hide a device in it that, when the box is opened at the next factory, steals that factory's badges and uses them to ship tampered batches onward. Nobody is driving it any more. It spreads on its own.

Key terms on this page
lifecycle script
A command npm runs automatically during installation — preinstall, install, postinstall. It executes with the privileges of whoever ran npm install, before any of the package's code is ever imported.
supply chain attack
Compromising a dependency in order to reach everyone who consumes it, rather than attacking the final target directly.
worm
Malware that propagates without an operator. Each new infection independently produces the next, which is why growth is exponential rather than linear.

What happened

In mid-September 2025 the npm registry was hit by a self-replicating supply chain worm that researchers named Shai-Hulud, after the sandworms of Dune. It compromised hundreds of packages — reporting at the time put the figure in the region of 500 — and it did so with no human operator directing each step.

The entry point was mundane: a phishing email dressed up as an npm security notice, which harvested a package maintainer's credentials. What made the incident significant was everything that happened after that first account fell.

This was a distinct event from the npm compromise roughly a week earlier, in which a separate maintainer was phished and widely-depended-upon packages were trojanised with cryptocurrency-stealing code. The two are often conflated because they occurred in the same month and both began with maintainer phishing.

The propagation loop

The worm's design is the whole story. Each infected install performed four steps, the last of which produced the next generation:

1. Execute on install, before any import

A malicious lifecycle script ran automatically when a developer or CI runner installed the package. Nothing needed to require() the package; merely installing it was sufficient.

2. Harvest every credential in reach

The payload bundled a legitimate open-source secret-scanning tool and pointed it at the machine — hunting npm tokens, GitHub tokens, and cloud provider credentials from environment variables, config files and the CI environment. Build machines are unusually rich targets here: they hold the credentials for everything the project publishes and deploys.

3. Exfiltrate via infrastructure that looks normal

Rather than calling out to an obvious command-and-control server, the worm pushed stolen data into attacker-visible locations on platforms the victim already used — including publicly-created repositories on the victim's own GitHub account and injected GitHub Actions workflows. Traffic to github.com from a build server is not anomalous, which is precisely the point.

4. Republish, using the credentials it just stole

With a harvested npm token, the worm enumerated the packages that victim maintained and published trojanised versions of them carrying the same payload. Every developer installing those packages became step 1 of a fresh cycle.

That fourth step is what separates this from a conventional malicious-package incident. The attacker did not need to compromise hundreds of maintainers. They needed to compromise one, and let the loop find the rest.

Why lifecycle scripts are the crux

A dependency you never call still runs code on your machine at install time. That is the design decision the whole attack rests on.

THE MECHANISM
// package.json of a trojanised release
{
  "name": "some-popular-util",
  "version": "4.1.3",
  "scripts": {
    // runs automatically on install,
    // with your shell privileges
    "preinstall": "node bundle.js"
  }
}
REDUCING THE BLAST RADIUS
# Install without running any
# lifecycle scripts:
npm ci --ignore-scripts

# Or make it the default for the
# project / CI environment:
npm config set ignore-scripts true

# Note: some packages with native
# builds genuinely need scripts.
# Allowlist those deliberately.

Defences that would have mattered

The transferable lesson

Most dependency-security tooling is built to answer "does this package have a known CVE?". A freshly trojanised version of a package you already trust has no CVE, a legitimate history, and a maintainer in good standing. Shai-Hulud is the argument for treating installation itself as the security boundary — controlling what executes, what credentials are within reach when it does, and how quickly new versions are adopted — rather than relying on after-the-fact vulnerability lists.

FAQ

How do I tell whether I was affected?

Check whether any affected package version appears in your lockfiles or CI logs for the exposure window, look for unexpected public repositories or new Actions workflows on developer and organisation GitHub accounts, and audit npm and GitHub token creation and package publication events. If a build machine ran an affected version, treat every credential that machine could reach as compromised and rotate it.

Does --ignore-scripts fully protect me?

It removes the install-time execution path, which is what this worm used. It does not protect you from malicious code inside a package you actually import and run. It is a strong control, not a complete one.

Was this a one-off?

No. Further variants using the same self-propagating pattern were reported in late 2025 and into 2026, and the technique has since been observed spanning multiple registries. The structural weaknesses it exploits — automatic script execution and long-lived publish tokens — are properties of the ecosystem rather than of any one package.

Sources