Mini Shai-Hulud Reads Its C2 Address From an Ethereum Smart Contract: How One npm Maintainer Compromise Poisoned keyv, cacheable, and 800+ Packages
The payload that shipped in keyv@6.0.0 on August 4 does something we have not seen in a supply-chain worm before. When its second stage needs a callback destination it calls an Ethereum smart contract at 0xE1f2395ee43e45A1556EC6438a88c31B83493103, function selector 0x53ed5143, iterates through 73 public Ethereum RPC endpoints until one answers, and reads the current C2 domain out of the return value, as Datadog Security Labs documented every byte of. Whoever holds the private key rewrites that pointer with a single transaction; nobody else can quietly take it down, because there is no take-down button on the Ethereum mainnet.
That is worth an evening on its own, but the reason it is a fleet-wide problem today is what wrapped around it. Between 09:35 and 10:28 UTC on August 4, one compromised maintainer account pushed malicious releases across eleven npm packages together pulling hundreds of millions of weekly downloads, and by end of day the self-propagating worm those releases carried had spread to more than 800 more, per the SCWorld tally. Every release came from the real maintainer's identity on the real npm registry, and on the OpenSearch-JS path some tarballs even carried valid SLSA provenance. The trust chains held, the account behind them did not, and everything downstream inherited the consequence.
One maintainer, eleven packages, fifty-three minutes
The compromise began with an unsigned commit at 09:02:37 UTC on the GitHub account of Jared Wray, who maintains keyv (roughly 127 million weekly downloads) along with cacheable, flat-cache (about 565 million monthly), file-entry-cache (about 557 million monthly), and half a dozen other caching primitives most Node applications pull transitively. Thirty-three minutes later keyv@6.0.0 hit the registry at 09:35:00.763 UTC, followed within the hour by cacheable@2.5.1, @cacheable/net@2.1.1, @cacheable/node-cache@3.1.2, nine more Cacheable-family versions between 10:09 and 10:14 UTC, and finally ecto@5.0.1 at 10:28:01.451 UTC. Every tarball carried the same 11 KB preinstall loader. flat-cache and file-entry-cache both sit underneath ESLint, so any developer or build runner that fetched a fresh copy of ESLint through a caret range during the window pulled the poisoned tree without ever naming keyv or cacheable in their own package.json. NHS Digital's CC-4781 alert and Chainguard's writeup on the Mini Shai-Hulud campaign land in the same sentence: assume any Node.js project that installed dependencies that morning is a candidate.
The 11 KB loader whose only job is to fetch Bun
The preinstall hook is lightly obfuscated and short. It runs bun --version, and if Bun is not on the host, it fetches a Bun v1.3.13 release for the host platform, follows up to five HTTP redirects with a 121-second per-hop timeout, and drops the archive into a temp directory prefixed bun-dl-. No checksum comparison. The loader spawns Bun synchronously against the second-stage file with environment and working directory inherited. Using Bun buys the operator two things: it moves the payload off Node's V8 (the second stage relies on timing and heap tricks V8 handles inconsistently), and bun spawned by node during an install is not a signature most EDR baselines have written yet.
What the second stage takes, and where it looks
Stage two is packed onto two physical lines with a 4,613-entry Base91 string table and per-call alphabet permutations, so static scanners see gibberish. Once decoded it sweeps the filesystem for secret-shaped files against 290 Linux, 129 macOS, and 50 Windows patterns (SSH keys, git configs, npm rc files, PyPI tokens, Docker auth, cloud credentials, Terraform state, cryptocurrency wallets), reads the full process environment plus gh auth token, walks the AWS credential chain across seventeen commercial regions against Secrets Manager and Parameter Store, decodes every non-system Secret from the in-cluster Kubernetes API, and asks HashiCorp Vault for tokens through its env, file, Kubernetes, and AWS auth helpers. On a GitHub Actions Linux runner with sudo it goes further: an embedded Python program reads /proc/<pid>/mem for the Runner.Worker process and pulls every serialised record flagged isSecret: true. Actions secrets get redacted from workflow logs, sure, but nothing stops another process on the same runner from reading them out of the worker's memory. Where the harvested GitHub token carries workflow scope, the worm drops a codeql_analysis.yml workflow on a temporary branch called dependabot/github_actions/format/setup-formatter, assigns the entire secrets JSON expression to an environment variable, writes it to an artifact, downloads it, and cleans up the run and branch behind itself; the JSON blob is already gone by the time anyone reads the audit log.
The C2 address lives on Ethereum, with a GitHub search fallback
Harvested data goes out through the strangest part of the machine. The worm serialises to JSON, gzip-compresses, encrypts under AES-256-GCM, wraps the key with a 4,096-bit RSA-OAEP public key baked into the binary (fingerprint dc1e6a7ddb29390dd53cf1e5aac40ad9204ea7c6b83ef5656e7cb7a796808b67), and posts the blob to whatever host the Ethereum contract points at. Known destinations at time of writing are https://awqhnjewqjkl[.]icu:443/router, https://pypi-get[.]com:443/router, https://js-mirror[.]com:443/router, and https://npm-cache[.]com:443/router, each serving a 400 or 404 on /router as a health check. When Ethereum resolution fails, the worm falls back to a GitHub commit search for thebeautifulmarchoftime and pulls a signed domain out of matching commit bodies. After HTTP 200 the payload runs eval(responseBody.code) on whatever the operator returns, no body signature verified, so a follow-on stage is one round trip away.
It puts itself back tomorrow morning through your IDE
Where the compromised host has a GitHub Actions checkout token available, the worm uses that identity to write five files onto up to fifty branches it can push to (skipping refs prefixed dependabot/ or copilot/): .claude/math_init.js and .vscode/setup.mjs for loader redundancy, .vscode/tasks.json configured to run node .claude/setup.mjs when the folder opens, .claude/settings.json installing a SessionStart hook, and .claude/setup.mjs as the second loader. The next developer to open the repo in VS Code, or to start a Claude Code session against it, re-executes the whole chain under their own credentials. Persistence, for this variant, is the morning ritual you go through anyway.
Why your existing npm defences did not catch it
Every hardened npm workflow we have written up in the last year leans on four assumptions: the maintainer's account is real, the registry's signature checks are meaningful, SLSA provenance ties an artifact to a build worth trusting, and npm ci --ignore-scripts is what CI runs. Three of those burned through in the same morning. The account was real because it had been taken over, signatures were valid because the real account minted them, and along the OpenSearch-JS path the worm forged fresh SLSA provenance by asking GitHub for an OIDC token, trading it with Sigstore for an ephemeral P-256 key, submitting the signed envelope to Rekor, then trading a second OIDC token for a short-lived npm publish token. The provenance bundle was authentic; it just bound malicious source to an attacker-controlled build. Only the fourth assumption did any work, which is why every team we have spoken to since Wednesday is chasing a lockfile they pinned to a caret range on something in this dependency graph.
What to look for on developer laptops and CI runners
The signals are concrete. On the filesystem, hunt for a single-instance lock at tmp.dpkg_14527.lock in the OS temp directory, a temp folder prefixed bun-dl-, stage-two math_init.js or Math_Symbol.js at SHA-256 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc, and setup.mjs at SHA-256 fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb. Where the worm reached repo branches, .vscode/tasks.json and .claude/settings.json match 927387d0cfac1118df4b383decc2ea6ba49c9d2f98b47098bcbcba1efc026e1f and 14eb4ce01dd4307759887ff819359b70d7d9ff709ecde039a5abc1aac325b128 respectively. In the process tree, watch for bun spawned as a child of node during an install with environment variable _NODE_RUNTIME_INIT=1. Network telemetry keys on outbound HTTPS to any of the four /router domains above, JSON-RPC calls to Ethereum public RPC endpoints from hosts that have never touched Ethereum before, and GitHub commit search API traffic containing thebeautifulmarchoftime. Audit logs will show repository creations described Shai-Hulud: Here We Go Again, and that codeql_analysis.yml workflow appearing on a dependabot/github_actions/format/setup-formatter branch that ran once and vanished. The ATT&CK chain covers T1195.002, T1078, T1550.001, T1059.007, T1105, T1552.001, T1552.005, T1547, T1041, and T1102; a BAS run that walks the preinstall-plus-Bun-plus-Ethereum-resolver path tells you which of your controls fire before the next maintainer account gets popped.
Cleaning up in the right order
If any workstation, laptop, or CI runner in your environment executed npm install or npm ci that could have resolved these packages between 09:35 and roughly 10:30 UTC on August 4, treat every secret reachable from that host as attacker property. Rotate in sequence, because later steps depend on earlier ones being trustworthy: npm publish tokens for any package your compromised accounts own; then GitHub PATs and OAuth tokens; then cloud credentials; then Kubernetes service account tokens; then Vault tokens; then Actions secrets and browser sessions on affected repos. Rotating cloud credentials before GitHub tokens is a false safety, because the worm's self-propagation writes fresh workflows using PATs it still holds and a rotated cloud key walks right back out through one. Pin affected packages to their pre-compromise clean releases (Datadog and Socket maintain the per-package list, and the keyv 5.x line is safe), then drop node_modules, npm cache clean --force, and npm ci --ignore-scripts. Constrain trusted publishing to tagged releases from protected branches only, and default your CI to --ignore-scripts; the next Shai-Hulud variant is not going to warn you.
This is the loop BlueTeamAutomation was built to run continuously, coupling BAS emulation of the preinstall-into-Bun credential sweep and the Ethereum C2 resolver against your controls, then correlating what fires and what stays silent across your EDR, SIEM, and SOAR. Detection and the proof it works stay continuous, so the next time a maintainer account walks a signed tarball into your fleet, the runbook is already in muscle memory.
See BASzy emulate this chain against your controls
Continuous BAS validation, EDR and SIEM correlation, and SOAR-driven response on hardware you control. Prove the patch actually held, on every run.
Explore BASzy →