Anatomy of the Shai-Hulud npm Supply-Chain Attack


Part 1 of 4 in the series Defending Against npm Supply-Chain Attacks. Full series ↓

The technical details here come from Aikido Security's writeup of the attack. This post is my walkthrough of what happened and what it means for a stack like this one.

On August 4th, somebody logged into the GitHub account of the person who maintains keyv and turned it into a weapon. keyv is a tiny key-value caching wrapper that almost nobody thinks about and almost everybody depends on: roughly 127 million downloads a week, pulled in transitively by half the tools in a typical Node project. Within a day the attacker had used that foothold to poison around 444 packages across 1,381 versions, adding up to more than two billion installs a month.

The payload calls itself "Shai-Hulud: Here We Go Again." It is a worm. It doesn't wait for you to run the app it infected. It runs the moment you type pnpm install.

That last line is the whole reason this series exists, so it's worth saying plainly: a routine pnpm install can execute attacker-controlled code on your machine before a single line of your own application has run. This post is about how that happened. The later parts are about making sure it can't happen to us.

The blast radius

The first wave hit the caching corner of the npm ecosystem: the packages that sit quietly underneath the tools you actually chose to install. Some of the named early victims, with the exact poisoned versions:

  • keyv@6.0.0 (~604M installs/mo)
  • flat-cache@6.1.24 (~580M installs/mo)
  • file-entry-cache@11.1.6 (~571M installs/mo)
  • cacheable-request@13.0.20 (~137M installs/mo)
  • cacheable@2.5.1 (~30M installs/mo)

If those names don't look familiar, that's the point. You don't pnpm add file-entry-cache. ESLint does it on your behalf, three dependencies deep. The blast radius of a compromised package isn't the people who chose it. It's everyone who chose something that chose it.

The releases were published with valid GitHub Actions signatures, so from the outside they looked exactly like every other legitimate release. The supply chain worked as designed. The one thing that had changed was the person sitting at the top of it.

The mechanism

The attacker added two files to each package and one line to its package.json:

{
  "scripts": {
    "preinstall": "node setup.mjs"
  }
}

preinstall is an npm lifecycle script. It runs automatically, before the package is even finished installing, on any machine that pulls the package in: your laptop, a CI runner, a teammate's dev container. You don't have to import it. You don't have to start your app. Installing the dependency is the trigger.

setup.mjs is a small bootstrapper. Its job is to fetch a full JavaScript runtime (it downloads a Bun binary) and then hand off to the real payload, a 728 KB file named Math_Symbol.js (also seen as math_init.js). Shipping its own runtime is a clever touch. The malware doesn't have to care what's installed on your machine, and running under Bun sidesteps a lot of the Node-based tooling a defender might have watching.

Here's the shape of the whole thing, from stolen account to the next victim:

Notice the loop at the bottom. That's what makes it a worm rather than a one-off. Every developer who runs the install and happens to hold an npm publish token becomes the launch point for the next round of infected packages.

What it steals

Once Math_Symbol.js is running, it goes looking for anything it can turn into access. The payload sweeps across roughly 200 file globs and a dozen credential systems:

  • npm tokens from ~/.npmrc and anywhere else on disk, validated against the registry so the worm knows which ones still work.
  • GitHub credentials: classic PATs, OAuth tokens, app tokens, and OIDC tokens. On a GitHub Actions runner it goes further and reads the runner process memory directly to dump the entire secret store.
  • AWS credentials from config files, environment variables, the EC2/ECS instance metadata service, and Secrets Manager (it enumerates with ListSecrets).
  • Kubernetes service-account tokens and API access.
  • HashiCorp Vault tokens, including VAULT_TOKEN from the environment.
  • Stripe and Slack tokens, matched by pattern.
  • A general filesystem scan for .env files, SSH keys, Terraform state, Docker configs, KeePass databases, VPN configs, and IDE settings.

Put differently: if your laptop or CI runner can reach a system, this worm tried to copy the keys to it. The scope isn't just your npm account. It's every credential a working developer keeps within arm's reach.

How it spreads and phones home

Stealing is only half of it. The worm also propagates through two channels.

Through npm, using a stolen publish token, it lists every package that token can publish, bumps the patch version, and republishes an infected tarball. That's the loop from the diagram: one compromised developer machine can seed dozens of freshly poisoned packages.

Through GitHub, using a stolen ghs_ token, it commits into every branch it can reach, up to 50 branches per repository. The commits are authored as "claude" with the message chore: update config, and what they add are malicious hooks in two specific files: .claude/settings.json and .vscode/tasks.json. Both are files that editors and agent tooling execute more or less automatically, which turns a plain code checkout into another execution trigger.

For exfiltration, the harvested secrets are encrypted and pushed out. The primary channel is a public GitHub repo whose description contains the string Shai-Hulud: Here We Go Again, and around 1,300 of them were created. When that fails, it falls back to npm-cache[.]com:443/router, a lookalike domain. The infrastructure addresses aren't hard-coded either: the worm pulls its current instructions from an Ethereum smart contract, so the operators can rotate servers without shipping a new payload. Take down the domain and the malware just reads the next one off-chain.

Why this one hits close to home

It would be comfortable to file this under "someone else's outage." It isn't, and here's the specific reason.

This site is a Node app built with pnpm. It has a pnpm-lock.yaml. It runs lifecycle scripts on install. And the repository has a .claude/ directory and .vscode/ config, the exact two files the worm writes its hooks into. Every ingredient the attack needs in order to matter to us is already sitting in this repo. That's the part that made me stop scrolling.

I'm not claiming we were hit. As far as I can tell we weren't, and Part 2 is about actually checking rather than assuming. The point is that the distance between "a caching library three levels down got compromised" and "attacker code ran in our install step" is one pnpm install of an unpinned transitive dependency. That distance is smaller than most of us are comfortable admitting.

Indicators of compromise

If you want to check your own machines and logs, these are the concrete markers from this campaign:

setup.mjs (sha256)        54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668
Math_Symbol.js (sha256)   9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc
Network callback          npm-cache[.]com:443/router
Ethereum contract         0xE1f2395ee43e45A1556EC6438a88c31B83493103
Exfil repo marker         GitHub repos described "Shai-Hulud: Here We Go Again"
Suspicious commits        author "claude", message "chore: update config"
Touched files             .claude/settings.json, .vscode/tasks.json

A fast first pass on any repo: look for an unexpected preinstall (or postinstall) script, a stray setup.mjs, or those two config files showing up in a commit you don't remember making.

The takeaway, and what's next

Strip away the specifics and one uncomfortable fact remains. Installing a dependency runs code, and that code runs with your privileges, before your application does anything at all. We've all typed pnpm install a thousand times without a second thought. This attack is what it looks like when that reflex is turned against you.

Knowing the mechanism is step one. Knowing whether you personally are exposed is step two.

In Part 2, we assess our own exposure: reading a pnpm-lock.yaml for the affected packages and versions, working out which lifecycle scripts actually run in this project, and checking whether anything in our own .claude/ or .vscode/ config has been touched. We stop reading about the incident and start auditing our own machine.

Source: Aikido Security, "Keyv and friends compromised in npm supply-chain attack". The package versions, hashes, and other indicators of compromise above are drawn from their analysis.

Comments

No comments yet. Be the first to comment.

Leave a comment