ChainDrop Worm Steals Your Keys the Moment You Run npm Install

Ethical Hacking Complete Course Zero to Expert
Hack like black hat hackers. Penetration testing, Kali Linux, WiFi and web hacking, and the hacker mindset behind it.
→ Take the full courseA worm dumped 1,300 stashes of stolen developer keys into public GitHub repos in a day. You ran npm install. That was all. The poison sat in a library npm hands out 153 million times a week. ๐ง
There was no phishing email and nothing suspicious to download. You typed npm install, or your build server did it at three in the morning while you slept, and attacker code ran before the install had finished.
On August 4, 2026, someone got into the GitHub account of Jared Wray, the maintainer behind keyv and the cacheable family of caching libraries. These are the small utility packages you rarely put in your own package.json, and that land in your node_modules anyway because something you did install depends on them. keyv alone gets pulled about 153 million times a week. flat-cache and file-entry-cache sit in the same family, both above half a billion installs a month.
The attacker pushed straight to the main branch and cut a release, and the commit metadata records it down to the second.
At 09:02:37 the two malicious files went into the repository. Two minutes later, at 09:04:30, five more files followed that had nothing to do with npm at all, and I will come back to those, because they are the worst part of this story. At 09:23:50 he deleted a file he had written himself twenty-one minutes earlier. At 09:35:00 the poisoned keyv@6.0.0 went live on the registry, and at 09:39:45 he copied the payload into the @keyv packages across the monorepo, staged and waiting for whatever release came next.
Thirty-two minutes from the first commit to a poisoned package sitting on the registry.
The first issue landed on the repository at 09:49:31, fourteen minutes after publication. Two more followed within the next two minutes, and a fourth twenty minutes after that. That was quick, and it still was not quick enough, because for hours afterwards several of the poisoned packages were what npm handed you by default.
That last part was not luck. keyv decided which major version got the latest tag through a setting stored in GitHub Actions, kept outside the repository on purpose so that a commit could not move it. The attacker patched the release script so that a new field in the repository’s own package.json overrides that setting, and set it to 6. He moved the source of truth onto ground he could write to. Without that one patch, npm install keyv would have kept giving you 5.6.0.
By 18:10 UTC that day, 444 packages across 2,212 versions had been poisoned, together good for around 2 billion installs a month, and the count was still climbing. Packages belonging to Deliveroo, Picsart and Qlik were in there, republished by the worm using credentials it had stolen from their own maintainers.
Each infected package got two new files, setup.mjs and Math_Symbol.js, and one new line in package.json:
| |
A preinstall script is a command npm runs for you before it finishes installing a package. It exists so a library can compile something or check your platform. It runs with your permissions, in your terminal, before any of your own code executes and before any test or scanner gets a turn.
The library code under dist/ was never touched. keyv kept working exactly as it always had. That is what makes the diff so easy to skim past: two files added to an allowlist and one line added to scripts.
setup.mjs is an obfuscated dropper with one job. It checks whether Bun is on the machine, and if Bun is missing it downloads an official Bun 1.3.13 build straight from github.com/oven-sh/bun, uses it to execute Math_Symbol.js, and deletes the runtime afterwards.
Read that again, because this is the sharpest part of the design. At install time, the only host the machine talks to is GitHub. There is no attacker domain in the traffic, so egress allowlists and reputation-based monitoring have nothing to fire on. Searching setup.mjs for a command server returns nothing, because at that stage there is not one. And not having Bun installed is no protection, because the dropper brings its own copy and cleans it up on the way out.
Math_Symbol.js is a 727,680 byte obfuscated Bun bundle, and this is where the damage happens.
It knows where developers keep things. It reads the file where npm stores your publishing token, and the file where the GitHub command line tool stores your login. It reads your AWS credentials, including the profile you set up for an old project and forgot about. It goes through your SSH keys, your .env files, your Terraform state, your Docker logins, your KeePass database and your VPN configs, running roughly 200 different search patterns across the disk.
Then it does the thing that separates this from an ordinary password stealer. It does not only copy the keys out. It logs in with them.
A stolen npm token gets checked against the registry to see whose account it belongs to and which packages it may publish. A stolen AWS key gets used to call Secrets Manager and list out the secrets your company keeps there. A Kubernetes service account token gets used to query the cluster and read the secrets in your namespace. And if it finds no Vault token lying around, it logs into Vault by itself using the Kubernetes or AWS credentials it just took, then walks the key-value stores and reads them out.
On a build server it goes further, because a build server holds more. It reads the memory of the GitHub Actions runner process to pull out the secret store, and it takes the short-lived tokens GitHub hands to workflows so they can publish packages without a password.
The exact list, if you want to check your own exposure:
- โ npm tokens from
~/.npmrcand any other.npmrcon disk - โ GitHub tokens in four formats:
ghp_,gho_,ghs_and JWT OIDC tokens - โ AWS:
~/.aws/credentialswith its named profiles, the environment variables, the EC2 metadata service at169.254.169.254, the ECS endpoint at169.254.170.2, and Secrets Manager across regions - โ Kubernetes: the service account token at
/var/run/secrets/kubernetes.io/serviceaccount/ - โ HashiCorp Vault: six sources in order, then
/v1/sys/mountsand the KV stores - โ Stripe and Slack keys
- โ Around 200 filesystem patterns:
.env,id_rsa,*.pem, Terraform state,docker/config.json, KeePass databases, VPN configs
What it collects gets packed into one bundle and locked twice. The bundle is sealed with a randomly generated key, and that key is then sealed again with a public key belonging to the attacker. Your credentials end up parked on public infrastructure, in plain view, and only the attacker holds what opens them. The technical version: AES-256-GCM with a fresh 32 byte key and a 12 byte IV, wrapped with RSA-OAEP-SHA256.
Then it spreads by itself, and this is what makes it a worm rather than ordinary malware. With a stolen npm token it asks the registry one question: which packages is this token allowed to publish? Whatever comes back, it downloads, unpacks, bumps the patch version by one, adds the same preinstall hook, drops itself in, repacks and publishes. Your colleague’s token turns into hundreds of poisoned releases without the attacker touching a key.
There is a tell in the file names. Second generation infections carry math_init.js instead of Math_Symbol.js, so the name tells you whether a package came from the attacker directly or from a victim who passed it on without knowing.
This did not stay theoretical. StepSecurity swept about 44,000 public workflow runs and found ten hits, all from one place: backstage/backstage, the CNCF developer portal. Their lockfile was pinned and committed and it changed nothing, because the job that got hit builds a fresh app and installs outside that lockfile. They reported it as backstage/backstage#35100, and the runs are public, so you can open them and look yourself.
Now the part the coverage keeps leaving out.
At 09:04, two minutes after the first commit and half an hour before anything reached npm, the attacker committed two configuration files into the repository:
| |
The task is labelled Environment Setup. The two files point at each other on purpose. Open .claude/settings.json, go looking for .claude/setup.mjs, and the reference sends you out of the directory. Do it the other way around and the same thing happens. Reading either file on its own never shows you a complete loop.
What those files mean is that installing the package was never required. Cloning the repository is harmless. Opening it in VS Code, or starting a Claude Code session inside it, runs the payload.
The timing is the giveaway. Those hooks went in before the poisoned package ever reached npm. They were not aimed at the developers who would install it. They were aimed at the ones who would not: the careful reader who saw the advisory, decided to touch nothing, and pulled the source down to have a look for herself. The repository sits at more than three thousand stars. That is a second set of victims, built entirely out of people who did the right thing.
The commit that planted those hooks, d8c850c7, carries a valid GitHub signature. Green badge, verified. The author field says github-actions[bot]. The message says chore: update config, with a Co-authored-by: claude line underneath.
That badge means less than it looks like it means. GitHub signs anything created through its own API or web interface using its own key, and the author name next to that signature gets filled in by whoever made the commit. A stolen credential with write access is enough to produce a verified commit that appears to come from a bot.
The package on npm had its own stamp of approval, called provenance: a record proving which pipeline built a package and from which commit. It was valid, and it had to be, because the publish did run through the genuine release workflow. Provenance answers where a build came from. It was never built to answer whether what went into that build deserved trust.
The most malicious commit in the incident is the only one that looks official.
The exfiltration endpoint is npm-cache[.]com, a domain registered on May 22, 2026. That address is not in the code either. The payload fetches it live from an Ethereum smart contract at 0xE1f2395ee43e45A1556EC6438a88c31B83493103, so the attacker can rotate infrastructure without touching a single installed copy of the malware. Earlier addresses served by the same contract were pypi-get[.]com and js-mirror[.]com.
And if the contract goes quiet, there is a second place to look. The payload pulls the address out of a cryptographically signed GitHub commit, which it recognises by the marker thebeautifulmarchoftime. A signature like that is what makes a commit look trustworthy to a human reading it. He uses it to pass his own servers their next address.
If both of those fail, the payload creates a public GitHub repository, describes it as “Shai-Hulud: Here We Go Again”, and commits the encrypted bundle there as results-<timestamp>-<counter>.json. Around 1,300 of those repositories exist.
That description is his own joke. Shai-Hulud is the name this malware family has carried through earlier runs at npm, and Microsoft and Aikido both put this payload in that same family. He is not hiding that he has been here before. He writes it on each repository he fills with somebody else’s stolen keys.
Two more details.
The payload checks the system language and exits on Russian-language machines. It also writes itself into repositories it can reach with a stolen ghs_ token, up to 50 branches each, working through the most recently active first and skipping dependabot and copilot branches.
And the attacker wrote a unit test for his own malware. The commit that added the payload also added test/preinstall.test.ts, twenty-two lines asserting that the preinstall hook is supposed to be there and that setup.mjs and Math_Symbol.js belong in the package. Run the test suite and it executes the dropper for you, wrapped in an expectation that nothing goes wrong.
It was a cover story written in code, so the injection would read as a deliberate, tested feature. Twenty-one minutes later he deleted it, minutes before the tag was cut, in a commit that touched nothing else.
Jared Wray, whose account this was, is a victim here. He has said publicly since that he publishes to npm through OIDC and secures GitHub with one-time codes, so there were no long-lived static tokens lying around to steal. He got his account back later the same day and started a full audit. How the attacker got in has not been explained.
One more thing before the cleanup list, because it changes how the cleanup should go. On some machines the payload tries to leave a watcher behind, a small component whose job is to check whether the stolen token still works. Wired to that watcher is a destructive handler. Revoke the token, and something goes off.
Pulling your tokens is not a free move. Rebuild the machine instead of tidying it up, and do the revoking from somewhere else.
What to do, if any of this touched you:
- โ Pin
keyvto5.6.0, which has no lifecycle scripts at all. If you need the v6 API,6.0.0-rc.1has byte-identicaldistfiles without the hook - โ Search your lockfiles for the compromised versions, including transitive ones
- โ Check for a checkout of the repository on disk:
find . -path "*/.vscode/tasks.json" -newermt "2026-08-04" - โ If it ran on a workstation or in CI, treat that machine as compromised and rebuild it, do not just clean it
- โ Rotate from a clean machine: npm tokens, GitHub tokens, App installation credentials, cloud keys, Vault
- โ Purge npm and yarn caches on affected machines and shared CI caches
- โ Update npm CLI to
v11.10.0or later and turn onmin-release-age - โ Make
npm install --ignore-scriptsyour default
Indicators of compromise:
- โ
setup.mjs(npm tarball loader) | SHA-256:54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668 - โ
setup.mjs(.claudeand.vscodeloader) | SHA-256:fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb - โ
Math_Symbol.jsandmath_init.js| SHA-256:9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc - โ C2 domains:
npm-cache[.]com|pypi-get[.]com|js-mirror[.]com - โ C2 URL:
hxxps[:]//npm-cache[.]com:443/router - โ Ethereum dead drop:
0xE1f2395ee43e45A1556EC6438a88c31B83493103 - โ Any public GitHub repository described as “Shai-Hulud: Here We Go Again”
- โ Payload commit:
ee2681a9b62f3637b0eb5133c36c864d3376cc5b - โ IDE hook commit:
d8c850c7800e(verified, author spoofed)
Microsoft Defender detection names:
- โ
Trojan:NPM/ShaiLoader.BY - โ
Trojan:NPM/MalBun.A - โ
Trojan:NPM/ShaiWorm.DAY!MTB - โ
Behavior:Linux/SuspBunActivity.A - โ
Behavior:Win32/SuspBunActivity.A
The provenance was valid. The commit signature was valid. The badge was green. All three were telling the truth about the process and lying about the result, because none of them were designed to answer the question that mattered: is this code safe to run.
A payload that runs from a file which executes on its own, hides behind a signed binary from a trusted source, and survives because you never check the hash of what you pulled down. Persistence through files that run automatically, living off trusted signed tools instead of dropping malware, verifying a file against its hash, and why you never trust somebody else’s prebuilt image are all lessons in my ethical hacking course:
โ Join my complete ethical hacking course
Hacking is not a hobby but a way of life.
Sources:
Stay updated
Get the latest posts in your inbox every week. Ethical hacking, security news, tutorials, and everything that catches my attention. If that sounds useful, drop your email below.