# ChainDrop Poisoned the npm Supply Chain

Source: https://www.egnworks.com/blog/chaindrop-poisoned-the-npm-supply-chain  
Author: Jacob Strix  
Published: 2026-09-03  
Updated: 2026-09-03  
Category: Security  
Tags: npm, Supply Chain, Node.js, Malware, Shai-Hulud

> ChainDrop turned trusted npm releases into a self-propagating credential stealer across Keyv, Cacheable, and hundreds of downstream packages.

---

On August 4, 2026, a compromised maintainer identity turned legitimate Keyv and Cacheable releases into a credential-stealing worm. The packages came from trusted namespaces, passed through real release automation, and executed before application code imported them. Teams that treated package provenance as a verdict rather than evidence inherited attacker-controlled code with developer or CI privileges.

ChainDrop was a descendant of Mini Shai-Hulud. It combined npm lifecycle execution, secret discovery, IDE persistence, encrypted exfiltration, and automated propagation. The initial Keyv compromise produced eleven confirmed malicious releases across Keyv, Cacheable, and Ecto. Later snapshots counted more than 400 distinct packages and more than 1,300 compromised package versions as the worm spread.

This incident was not a remote vulnerability in Keyv's API. The failure occurred in the source and release chain. That distinction matters because a CVE scanner can report a clean dependency graph while a malicious package runs during installation.

## The Initial Compromise

Datadog reconstructed the earliest malicious Keyv state at 09:02 UTC. Unauthorized changes added `setup.mjs`, a 727,680-byte second stage named `Math_Symbol.js`, a `preinstall` entry, release configuration, and execution hooks for developer tooling. The account then created branches and pull requests while GitHub Actions produced additional signed commits.

At 09:35 UTC, `keyv@6.0.0` reached npm through the project's legitimate GitHub Actions workflow. Its provenance bound the artifact to the workflow and the compromised source commit. Nine Cacheable-family versions followed between 10:09 and 10:14 UTC. `ecto@5.0.1` followed at 10:28 UTC with byte-identical payload files.

The initial access point was a compromised GitHub maintainer account. The attacker controlled enough of the normal development path to produce releases that looked operationally routine. The namespace, workflow identity, and provenance were real. The source they certified was not trustworthy.

## The Confirmed Releases

Snyk independently queried the maintainer's packages, inspected every version published on August 4, and compared registry metadata and tarballs. Its initial sweep confirmed eleven malicious releases. The Cyber Security Agency of Singapore later published the same notable package set.

| Package | Malicious version | Publication UTC |
| --- | --- | --- |
| `keyv` | `6.0.0` | 09:35 |
| `@cacheable/net` | `2.1.1` | 10:09 |
| `@cacheable/node-cache` | `3.1.2` | 10:10 |
| `cacheable` | `2.5.1` | 10:10 |
| `flat-cache` | `6.1.24` | 10:10 |
| `cacheable-request` | `13.0.20` | 10:11 |
| `@cacheable/memory` | `2.2.1` | 10:11 |
| `file-entry-cache` | `11.1.6` | 10:13 |
| `@cacheable/utils` | `2.5.1` | 10:14 |
| `cache-manager` | `7.2.10` | 10:14 |
| `ecto` | `5.0.1` | 10:28 |

The larger counts describe later observations. Wiz reported more than 400 distinct npm packages after propagation. CSA Singapore reported more than 1,300 package versions with roughly two billion combined monthly downloads on August 6. The figures use different units and snapshots.

Registry deletion did not remove copies stored in lockfiles, private mirrors, dependency caches, container layers, or CI artifacts. Incident scoping has to use exact versions and installation records rather than the current state of the public registry.

## Install Time Was Execution Time

The malicious tarballs modified `package.json` so the first stage ran during installation. Applications did not need to import Keyv or call a vulnerable function. A package manager with lifecycle scripts enabled was enough.

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

`setup.mjs` loaded the obfuscated `Math_Symbol.js` second stage. The process inherited the permissions, environment variables, filesystem access, and network access of the developer shell or CI runner. On a release runner, that context can include source access, cloud identities, registry credentials, signing material, and deployment secrets.

The package installation was both the compromise event and the discovery phase for the next target. Once the malware recovered credentials with repository or publishing access, it could modify additional projects and distribute itself through another trusted release.

## Credentials Drove Propagation

Researchers observed collection logic for GitHub, npm, AWS, Google Cloud, Azure, Kubernetes, Terraform, SSH, CI systems, private registries, cryptocurrency wallets, and developer tools. Wiz reported expanded targeting for Claude, OpenAI, Codex, Cursor, and Gemini configuration. The payload also inspected the host to identify automation environments.

ChainDrop attempted persistence through Claude Code hooks, Visual Studio Code `tasks.json`, user services, and platform-specific launch mechanisms. Removing `node_modules` did not undo those changes. Opening a repository or starting a configured task could execute attacker-controlled code again.

The malware encrypted collected data and used multiple exfiltration paths. One path created GitHub repositories under compromised identities with the description `Shai-Hulud: Here We Go Again`. Another resolved command-and-control infrastructure through an Ethereum smart contract, allowing the operator to change destinations without publishing a new payload.

## Provenance Recorded a Compromise

The `keyv@6.0.0` artifact had valid SLSA provenance. The attestation correctly proved that the artifact came from a specific workflow and source state. Both were already inside the attacker's control.

| Control | What it establishes | What it does not establish |
| --- | --- | --- |
| Package integrity | Bytes match the registry record | The recorded bytes are benign |
| Provenance | A named workflow produced the artifact | The source and workflow were safe |
| Trusted publishing | An approved identity published it | Every input was authorized |
| Lockfile pinning | Builds resolve a fixed artifact | The selected artifact was safe |

Provenance remains useful because it identifies a source revision, workflow, and build identity. It also rejects releases created outside approved automation. It fails only when teams ask it to prove whether authenticated inputs were semantically safe.

Release security needs controls around the attested path. Protected branches, restricted workflow permissions, isolated release jobs, independent review of manifest changes, and publication monitoring reduce the chance that one account can convert valid automation into malware distribution.

## Find the Compromised Versions

The following Node.js script checks an npm `package-lock.json` without executing dependencies. Save it as `check-chaindrop.mjs` and run it from the project root.

```js
import { readFile } from "node:fs/promises";

const affected = new Map([
  ["keyv", new Set(["6.0.0"])],
  ["@cacheable/net", new Set(["2.1.1"])],
  ["@cacheable/node-cache", new Set(["3.1.2"])],
  ["cacheable", new Set(["2.5.1"])],
  ["flat-cache", new Set(["6.1.24"])],
  ["cacheable-request", new Set(["13.0.20"])],
  ["@cacheable/memory", new Set(["2.2.1"])],
  ["file-entry-cache", new Set(["11.1.6"])],
  ["@cacheable/utils", new Set(["2.5.1"])],
  ["cache-manager", new Set(["7.2.10"])],
  ["ecto", new Set(["5.0.1"])],
]);

const lock = JSON.parse(await readFile("package-lock.json", "utf8"));
const matches = [];

for (const [path, entry] of Object.entries(lock.packages ?? {})) {
  const marker = "node_modules/";
  const index = path.lastIndexOf(marker);
  if (index === -1 || !entry?.version) continue;
  const name = path.slice(index + marker.length);
  if (affected.get(name)?.has(entry.version)) {
    matches.push({ name, version: entry.version, path });
  }
}

if (matches.length === 0) {
  console.log("No confirmed ChainDrop versions found");
  process.exit(0);
}

console.table(matches);
process.exitCode = 1;
```

A match proves dependency resolution, not execution. Installation logs, runner history, container build records, and endpoint telemetry determine whether the lifecycle script ran. If installation occurred with scripts enabled, assume every credential reachable from the process was exposed.

Known indicators support investigation, but their absence is not proof of safety. The payload and infrastructure changed during the campaign.

| Indicator | Type | Context |
| --- | --- | --- |
| `npm-cache[.]com` | Domain | Exfiltration endpoint |
| `pypi-get[.]com` | Domain | Network infrastructure |
| `js-mirror[.]com` | Domain | Network infrastructure |
| `54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668` | SHA-256 | `setup.mjs` |
| `9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc` | SHA-256 | `Math_Symbol.js` |

## Rebuild the Trust Boundary

Deleting the dependency is insufficient because ChainDrop attempted persistence outside the package directory and used credentials to reach other systems. Isolate the workstation or runner first and disable its ability to publish packages, modify repositories, or deploy infrastructure.

| Phase | Required action | Reason |
| --- | --- | --- |
| Contain | Isolate hosts and revoke active sessions | Stops exfiltration and propagation |
| Preserve | Capture logs, workflows, processes, and files | Maintains evidence for scoping |
| Eradicate | Rebuild workstations and replace runners | Removes user-level persistence |
| Recover | Rotate credentials from a clean system | Protects replacement secrets |
| Validate | Review registry, repository, and cloud logs | Finds secondary compromise |

Private registries and caches need separate handling. Remove compromised tarballs, regenerate lockfiles against known-good versions, and rebuild containers or artifacts that embedded them. A clean result from public npm does not invalidate an internal cached copy.

GitHub describes modern supply chain incidents as chained failures rather than one exploitable control. The corresponding defense is layered: short-lived trusted publishing, restricted Actions permissions, read-only caches for untrusted workflows, delayed dependency updates, lifecycle-script controls, and monitoring for unexpected releases.

## What ChainDrop Changed

ChainDrop removed a useful assumption from package review: valid provenance plus a trusted namespace is enough to approve a release. Both signals remain useful, but both can stay valid while an attacker controls the authenticated source path.

The durable detection point is the transition itself. Teams should alert on new lifecycle scripts, unexpected files in tarballs, workflow changes near a release, identity changes, unusual branch writes, and versions published outside normal cadence. Those events are visible before vulnerability databases assign anything, and malicious packages often receive no CVE.

Developer identity is part of production security. A workstation credential can become a source commit, workflow execution, provenance record, npm release, and then code execution across downstream environments. Package security starts before the registry and continues after installation.

## References

[Ongoing npm Supply Chain Attack Affecting Keyv](https://www.csa.gov.sg/alerts-and-advisories/advisories/ad-2026-009/)

[Inside the Keyv npm Supply Chain Compromise](https://snyk.io/blog/inside-keyv-npm-compromise-preinstall-malware-trusted-provenance-ide-hooks/)

[Worm Compromises Hundreds of npm Packages](https://securitylabs.datadoghq.com/articles/npm-worm-compromises-popular-npm-packages/)

[Keyv and Cacheable npm Packages Hijacked](https://www.wiz.io/blog/keyv-and-cacheable-npm-supply-chain-attack)

[Disrupting Supply Chain Attacks on npm](https://github.blog/security/supply-chain-security/disrupting-supply-chain-attacks-on-npm-and-github-actions/)
