ChainDrop: three minutes to spread, and no clock on getting it out

On 4 August a poisoned keyv hit npm at 09:35 UTC and the first autonomous downstream republish landed three minutes later. Dave's framing when we first talked about this was that lockfiles and CI caches shorten the gap between a malicious publish and a build artefact. Having read the sources, that is the wrong way round, and the right way round makes a sharper point: lockfiles and caches do very little to slow arrival, and a great deal to slow eviction.

What happened

The entry point was a maintainer's GitHub account, not a stolen npm token. The attacker pushed malicious commits straight to keyv's main branch at around 09:00 UTC with no pull request, and the project's own legitimate release workflow built and published the result (src: Snyk). Infrastructure had been staged for months: the earliest compromised repository dates to 11 May, and three C2 domains were registered within eight seconds of each other on 22 May (src: Unit 42).

The propagation clock, reconstructed consistently by two independent sources, is the part worth internalising (src: StepSecurity):

Scale is genuinely disputed and I am not going to pick a number. Reported counts run from "over 400" (src: Unit 42) through 444 packages across 2,212 versions (src: StepSecurity) to 868 packages from Aikido and upper-bound reporting of 1,300 plus. Counts were taken at different moments against a worm that was still spreading, and researchers variously counted packages or versions (src: Infosecurity Magazine).

How it works

Three mechanisms matter for anyone who owns dependency policy.

Provenance passed. Because the legitimate GitHub Actions workflow did the building, keyv@6.0.0 shipped with genuine SLSA Build Level 3 attestation. Snyk's phrasing is exact: "the legitimate workflow built and attested the malicious artifact" (src: Snyk). Provenance proves a commit was built, not that it was authorised. Any gate checking attestation presence waved this through. One variant against opensearch-project went further and abused npm's trusted-publishing OIDC flow to mint real publishing credentials and valid Sigstore provenance.

Execution needs no import. The payload runs from an npm preinstall hook ("preinstall": "node setup.mjs"), firing during installation. Nothing has to import the package and no application has to start. The 29,918-byte loader pulls down the Bun 1.3.13 runtime and detaches a roughly 727 KB obfuscated payload (src: Snyk). It harvests over 300 credential patterns, including scraping GitHub Actions runner memory via /proc/<pid>/mem for short-lived OIDC tokens, and exfiltrates under AES-256-GCM with an RSA-wrapped key (src: Elastic Security Labs).

Propagation is at tarball level. With a stolen npm token the payload enumerates every package the account can publish, rebuilds each tarball with the preinstall hook injected, and republishes. Source repositories stay clean, so repository-oriented scanning sees nothing (src: The Register). C2 resolves dynamically from an Ethereum contract, and on the day the operators rotated the entire C2 estate in a single transaction with no malware update (src: Unit 42).

Why it matters

Start with who was exposed. The three headline packages, keyv, flat-cache and file-entry-cache, are mutual dependencies that ship transitively with ESLint. Almost nobody who installed them asked for them. That is the shape of this risk: it arrives through indirect requirements, and the second wave then took enterprise SDK namespaces within about two hours, including @servicetitan at 141 packages (src: StepSecurity).

Now the asymmetry. Strict lockfiles and npm ci are what every source recommends to prevent arrival, because floating ranges under npm install are precisely what pulled poisoned latest releases onto machines during the window. But once a poisoned version is in a lockfile, it is sticky. Unit 42 puts it plainly: "Even after updating the latest tag to point to a secure version, machines that installed the package during the compromise will not automatically receive the fix. Because lockfiles retain the compromised version, these systems remain vulnerable until administrators actively clear the lockfiles and fetch the updated release" (src: Unit 42). Add CI caches, shared build images and private mirrors and you have several layers that each keep serving a hash the registry has already deleted. Nobody has published how long npm's unpublish took to reach CDN edges and downstream mirrors, which is the single most useful unanswered question here.

So pinning does not buy time. There is peer-reviewed work arguing it can be actively worse: a counterfactual simulation of npm resolution over historical time points found that pinning direct dependencies "even increases the risk of exposure to malicious package updates in larger dependency graphs due to the specifics of npm's dependency resolution mechanism" (src: Pinning Is Futile). That is simulation work from February 2025 and predates ChainDrop, so treat it as a general mechanism rather than a measurement of this incident.

Two things I will not claim. First, there is no public number for how many installs of the poisoned versions actually occurred during the live window. Every download figure in circulation is a pre-compromise baseline, the cleanest being Snyk's 619,682,667 for keyv between 5 July and 3 August. The propagation lag is well evidenced at the publish layer and unmeasured at the consumption layer. Second, Zscaler's "roughly one infected package per second" is a peak, not a rate to extrapolate; 2,212 versions across the 09:40 to 13:20 window averages about ten versions a minute (src: Zscaler ThreatLabz).

One more thing, aimed at my own pipeline. There is no KEV entry for ChainDrop and there never will be. This is a maintainer account compromise and a worm, not a software defect, so it has no CVE and is out of scope by construction. "Check KEV" is exactly the reflex that misses this class of event. Our own daily briefs #4195 to #4197 do not mention ChainDrop at all, and two of them describe the period as quiet and suggest using the lull for backlog triage, while four ChainDrop-related items moved through the exploit feed over the same fortnight. Registry poisoning falls between CVE-shaped categories. That is a gap in my reporting, not in the ecosystem's noise level.

What to do about it

Detection budgets should be sized against observed propagation, and the observed numbers are three minutes to autonomous spread, forty-three minutes to public alarm and sixty-four minutes to registry response. A weekly dependency review is not in the same units as this problem.

The control that actually matches those units is a minimum release age gate. If you refuse to consume anything younger than the ecosystem's observed detection latency, a 24-hour hold clears 43 minutes comfortably and this incident never reaches your builds. pnpm 11 is reported to ship a one-day cooldown by default, but I could not confirm that against pnpm's own documentation and the article it was attributed to did not contain the claim on direct fetch, so verify before you rely on it. The logic stands regardless of which tool implements it.

Beyond that, the consensus remediation across Unit 42, Zscaler, Snyk and StepSecurity:

Finally, stop treating attestation presence as an authorisation signal. ChainDrop's artefacts were properly attested by a properly configured pipeline that had been handed a malicious commit. The failure class is the same one as the Snowflake GitHub Actions workflow injection we covered in brief #4195: trusted CI producing attacker-controlled output. If your policy gate cannot tell the difference between "built by us" and "approved by us", it will pass the next one too.

Sources