ChainDrop blocked at the kernel: keyv@6.0.0 got its runtime and still went nowhere
On 4 August 2026, packages in the keyv and cacheable namespaces were republished with a malicious preinstall hook. Microsoft names the campaign ChainDrop; other writeups place it in the Shai-Hulud lineage. Aikido counts at least 444 packages across 1,381 versions, Microsoft more than 400, across multiple unrelated publishers. Microsoft published an anatomy of the worm, Socket documented the staging and an early timeline, and Aikido and Wiz covered the blast radius.
This post is not a malware analysis. For what the payload is and does, read those. This post is one thing: the same sample, run twice in our detonation sandbox, once with the Leitwacht agent observing and once with it enforcing, and the raw difference between the two.
The shape that matters: it ships the payload but not the interpreter
keyv@6.0.0 ships its payload in the tarball. Alongside the real dist/, the package we captured carries setup.mjs, a 30 KB loader wired to preinstall, and Math_Symbol.js, a 727 KB obfuscated bundle. Nothing about that is hidden from a reviewer willing to open the tarball.
What the package does not ship is the thing that can run it. Math_Symbol.js is a Bun bundle, and CI runners have Node, not Bun. So setup.mjs fetches one: a standalone Bun 1.3.13 binary from a GitHub release, unpacked into /tmp and used to execute the bundle already sitting on disk.
That is a more awkward dependency for the attacker than shipping a Node payload would have been, and it is the seam this post is about: before any of the interesting behaviour can run, the job has to fetch an interpreter over the network.
The runtime comes from a GitHub release, so assume throughout that GitHub is reachable. Most CI runners that build from source have it open, ours included, and a control that depends on closing it is not one many teams will deploy. We work mostly with GitLab runners and the shape is the same: the forge a build clones from is typically permitted, and any payload that fetches from it inherits that permission for free.
So grant it. The interesting question is not whether the dropper gets its runtime. It is what the second stage can do once it has one.
Our firehose ingested the wave as it happened. The scoped @keyv/* packages went out in a burst from 09:30:01 to 09:32:24 UTC, keyv@6.0.0 at 09:35:00 UTC, and the first cacheable family burst between 10:09:44 and 10:11:29 UTC.
One detail from our own ingest worth flagging: every version in the 4 August keyv and cacheable wave we ingested carries the publisher identity npm-oidc-no-reply@github.com, not a maintainer’s own address, so these went out through npm’s OIDC trusted publishing. We did not pull the attestations; Microsoft documents that packages published by that route can carry valid provenance, the compromise sitting upstream of the signing. We have written before about why valid provenance is not a safety property.
Run one: the agent observing
First, a normal detonation, agent in observe mode, nothing blocked. The install completes (npm_exit_code: 0) and the second stage runs. The agent and sandbox recorded 576 events.
What the payload reached for, via the agent’s credential LSM hook. Our sandbox seeds decoy credential files across the paths that hook watches, and deliberately runs it in observe mode (credwatch: observe) so reads are recorded and permitted. That is the only reason the following list can exist at all. The sweep touched 17 distinct paths, /etc/rancher/k3s/k3s.yaml first in both of our runs:
/etc/rancher/k3s/k3s.yaml
/home/<user>/.ssh/id_rsa
/home/<user>/.ssh/id_ed25519
/home/<user>/.ssh/id_ecdsa
/home/<user>/.ssh/config
/home/<user>/.ssh/authorized_keys
/home/<user>/.vault-token
/home/<user>/.git-credentials
/home/<user>/.aws/credentials
/home/<user>/.azure/accessTokens.json
/home/<user>/.kube/config
/home/<user>/.docker/config.json
/home/<user>/.config/gcloud/credentials.db
/home/<user>/.config/gcloud/application_default_credentials.json
/home/<user>/.bitcoin/wallet.dat
/home/<user>/.config/solana/id.json
/home/<user>/.electrum/wallets/default_wallet Kubernetes, SSH, Vault, AWS, Azure, GCP, Docker, git, and three cryptocurrency wallets. The cloud and registry entries are the ones a build runner routinely holds, and one of them is enough. Treat it as a floor rather than a closed set: it is what our seeded paths caught, and the agent excludes a few registry credential files by default, so anything it read there we would not see.
That list is also where this run stops being representative of a deployment. credwatch is an LSM hook on file_open, and enforcing it is not a logging mode: the BPF program returns -EPERM, then userspace kills the container and detaches. One touch of k3s.yaml and the job is over, which is why the other sixteen paths would never be reached. We keep it in observe for detonations precisely because a capture that dies on the first file tells us nothing about the rest. We have not run the enforcing variant, so treat that as documented behaviour rather than a capture.
Where it tried to send it. The payload does not carry a fixed C2 address. It resolves one, by querying Ethereum JSON-RPC endpoints for an on-chain value, and it is stubborn about it: our capture recorded attempts against 67 distinct public RPC providers, cycling through them one after another, plus a fallback to the GitHub API.
RPC failed https://eth.llamarpc.com: missing result
RPC failed https://go.getblock.io/...: missing result
RPC failed https://eth-mainnet.nodereal.io/...: missing result
RPC failed https://ethereum-rpc.publicnode.com: missing result
... and on through the list Its fallback channel is GitHub’s own API, used as a dead drop (record trimmed to the request):
{
"host": "api.github.com",
"method": "GET",
"path": "/search/commits?q=thebeautifulmarchoftime%20&sort=author-date&order=desc",
"tls": true,
"headers": { "User-Agent": "node", "Accept": "application/vnd.github+json" }
} The payload also knew where it was. Its own stdout, from the second line on:
Repo is <redacted>
Setting up quick results!
Runner matches!
Fetching domains from contract... That is the intended kill chain, with the agent watching rather than intervening: fetch a runtime, sweep the credential store, resolve a C2 over a blockchain, exfiltrate over HTTPS, fall back to a public API if that fails. It does not complete even here. Our sandbox answers no RPC provider for real, so every contract read came back missing result, the payload never resolved a C2 and it exfiltrated nothing. Run one shows what it reaches for, not a compromise that succeeded.
Run two: the agent enforcing, with GitHub open
Same sample, same sandbox configuration, same seeded credentials. The agent is now enforcing, default-deny, with the npm registry and GitHub on the allowlist. That is enough for the dropper to get its runtime, and it does: install_ok: true, npm_exit_code: 0, and the second stage executes.
221 events. Two domains allowed, sixty-eight denied. The allowed pair is the build’s: the registry, and GitHub. Everything else it wanted is in the other sixty-eight.
Because here is the thing about giving the dropper its runtime: it still does not know where to send anything. ChainDrop carries no C2 address. It derives one, by reading a value off an Ethereum contract, and to read that value it has to reach a public JSON-RPC provider. Those are not GitHub, they are not your registry, and we have yet to see a build that needs one:
eth.llamarpc.com cloudflare-eth.com rpc.flashbots.net
ethereum-rpc.publicnode.com 1rpc.io rpc.ankr.com
eth.drpc.org api.zmok.io 0xrpc.io ... 67 in total The agent refused all of them, 140 DNS denials in a run lasting a few seconds. The payload cycles the whole list and gets nowhere, which it narrates itself:
Fetching domains from contract...
RPC failed https://eth.llamarpc.com: Unable to connect. Is the computer able to access the url?
RPC failed https://go.getblock.io/...: Unable to connect. Is the computer able to access the url?
... and on through the list This is the part that survives contact with a real runner. An allowlist is permissive about the handful of hosts your build needs and silent about everything else, and “everything else” is where a C2 lives by construction. The attacker’s own design choice, resolving the address at runtime instead of hardcoding it, is what puts sixty-seven separate names in front of a policy that answers none of them. The sixty-eighth denial is the payload’s fallback dead drop on the GitHub API, refused for the same unremarkable reason: nothing asked for it in the allowlist. That one is worth being honest about, because plenty of pipelines do allow api.github.com for gh or the Actions API, and on those the fallback stays reachable. It is the RPC set that carries the block here, and the dead drop is a bonus you keep only if your allowlist is written narrowly.
Even with the network wide open
Suppose you disagree with all of that and your runner egresses anywhere. The exfil still needs something to exfiltrate, and that is a separate control.
The credential sweep ran here: 34 opens across the 17 seeded paths, recorded because our sandbox pins the hook to credwatch: observe so samples finish. Enforcing, the first of those opens is denied at the kernel and the job is killed on the spot. The sweep never gets past k3s.yaml, and a payload that has collected nothing has nothing to exfiltrate, whatever the network policy says. That is documented behaviour rather than a run we captured, and it is the one capture still missing from this post.
Two independent failures, then, and the attack needs to beat both: it has to find a route out, and it has to have collected something worth the trip. Egress policy denies the first. The credential hook denies the second, and it does not care whether the network was open.
Where the block lands, and why the kernel filter still matters
Both of those network denials, the RPC set and the dead-drop fallback, are the agent’s DNS proxy: the lookup is refused, getaddrinfo fails, and the payload raises before a socket exists. That is the cheap outcome, since the process fails fast on an ordinary resolver error rather than stalling on a connection that will never complete.
The cgroup_skb/egress program underneath it ends in a default deny, and in these runs it had little to do, because a name that is never resolved produces no packet to drop. It is the gate for the shape DNS cannot see: a payload that connects to a hardcoded IP with no lookup at all. We captured exactly that in the Mastra compromise, where the C2 was an IP literal and the DNS layer never saw the reach. ChainDrop is the other shape. A CI egress policy needs both, because you do not get to choose which one arrives.
Either way the event names the culprit: process, parent, and binary path, so an operator learns which job and which executable tried to leave rather than that “something in the build touched the network”. How that mapping survives the trip from resolver to packet is in the data-plane writeup.
If you run npm in CI
In order of effort:
- Default-deny egress on any job that runs
npm install. Keep your forge and registry on the allowlist and let the default handle the rest. It needs no feed to have named the package first, which matters because these packages were hours old. - Deny the credential read as well as the route out. Egress policy and the credential hook fail independently, and a payload has to beat both. If you only deploy one, you are relying on never mis-writing an allowlist entry.
- Split install from anything credential-bearing. Run
npm ciin a job with no cloud, registry, or git tokens in scope, and release from a job that has them. The 17-file sweep then runs against an empty set. - Do not treat provenance as clearance. These went out through a legitimate workflow identity, which is the route that can carry valid provenance. Signing proves the pipeline published it, not that the pipeline was honest when it did.
Indicators, for blocking
From our own runs on 5 August 2026, against keyv@6.0.0. Cross-package behaviour, the full package list, attacker infrastructure and file hashes are in the vendor writeups below.
- Entry point:
keyv@6.0.0,preinstallrunsnode setup.mjs. The vendor writeups report the same shape across the other@keyv/*and@cacheable/*republishes; we detonated this one. - Files in the tarball, from our own capture of the published artifact:
package/setup.mjs, 29,918 bytes,sha256 54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668package/Math_Symbol.js, 727,680 bytes,sha256 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc
- Stage-1 fetch: a standalone Bun runtime (1.3.13, musl baseline build) over HTTPS. The absence of any checksum or signature check is from reading the dropper, not the capture.
- C2 resolution: Ethereum JSON-RPC, cycling 67 distinct public endpoints for an on-chain value. There is no single domain to block here, which is the point of the design.
- Fallback channel:
api.github.com,GET /search/commits?q=<marker>, used as a dead drop. - Credential targets: the 17 paths listed above.
Blocking the RPC provider list is not a defence. Denying the runner egress it does not need is, and that is one rule rather than sixty-eight.
Sources:
- Microsoft: ChainDrop supply chain compromise, anatomy of a self-propagating worm
- Socket: keyv and cacheable namespaces compromised (staging detail and timeline)
- Aikido: keyv and friends compromised
- Wiz: keyv and cacheable package hijack
- Our kernel capture against the Mastra compromise (the IP-literal C2 shape)
- Our earlier capture against an npm wave