Endor Labs detected ai-sdk-ollama as part of an active self-replicating npm worm named ‘Miasma’ that uses binding.gyp to execute on install, steal cloud credentials, and spread itself.
Endor Labs detected four malicious versions of ai-sdk-ollama published to npm inside a single 17-second window. Ai-sdk-ollama is a popular community-maintained npm package that connects Ollama, a widely used tool for running open-source LLMs locally, to the Vercel AI SDK. The package is not affiliated with Ollama or Vercel, but serves as an unofficial bridge between the two. It receives over 120,000 downloads per month.
The library's real code is untouched. The attacker added two files to the published tarball and used npm 's native-build behavior to run a payload at install time, before any application code imports the package.
ai-sdk-ollama is one of dozens of packages confirmed as part of Miasma, an active self-replicating npm worm spreading across maintainer accounts using the same binding.gyp technique. The incident described here is a single node in a larger, ongoing campaign.
Three things stand out this incident:
The publishing pattern is the tell. Legitimate releases were weeks apart, with 3.8.4 shipping on 2026-05-11. The four malicious versions landed within 17 seconds of each other, each one targeting a different major line. The one-per-major trick is the clever part: whatever range you have pinned, the install pulls a poisoned version.
Comparing 3.8.5 against the clean 3.8.4, the entire dist/ build output is byte-for-byte identical. The declared entry point ( "main": "./dist/index.js" ) is the real, unmodified library. The attacker did not patch the package's code. They bolted a payload onto the side of it. Version 3.8.5 adds exactly two files the clean version does not have:
There is no preinstall or postinstall script in package.json, only ordinary dev scripts. The execution vector is the binding.gyp. npm treats the presence of a binding.gyp as a signal to run node-gyp rebuild during install, even with no install script. The file weaponizes gyp's command substitution syntax:
When gyp evaluates the sources list, it runs node index.js on the host. Output goes to /dev/null to stay quiet, and && echo stub.c returns a fake source filename so gyp does not error. The payload fires on npm install, not on import. No install script, and no visible sign of compromise.
The root index.js is one large call that wraps an array of character codes in a Caesar-cipher decoder and then evals the result:
Decoding the array is a plain string transform and does not require running anything. The decoded result starts with an async block.
The ROT shift is different across versions published in the same window. 3.8.5 decodes with a shift of 15, and 2.2.1 decodes with a shift of 18. We do not see legitimate packages rotate an obfuscation key between point releases minted the same minute. It points to deliberate evasion aimed at static signatures keyed on a single decoded form.
The decoded layer imports node:crypto, defines an AES-128-GCM helper, and decrypts inline blobs whose keys, IVs, and auth tags are embedded in the script:
Two ciphertext blobs are present: a small loader of roughly 900 bytes and a main payload of 668KB.
The smaller blob downloads a standalone Bun runtime and uses it to run the main payload, instead of running it under Node:
A Node package has no reason to do that. The usual reason is to get past tooling that only watches Node. The runtime itself is pulled from the real Bun GitHub release URL, so it blends into normal developer traffic and slips past simple blocklists.
The 668KB main payload is heavily obfuscated, in obfuscator.io style. Its embedded strings show a collector built for specific secrets, including:
These collectors are keyed to the exact token names and paths each platform uses, not a random scrape of environment variables. In CI, that puts deployment keys, cloud role credentials, and pipeline secrets in scope.
The payload also collects npm, GitHub, and RubyGems registry tokens. Combined with the simultaneous back-fill across four major lines, that gives the operator what it needs to republish poisoned packages from any account it compromises, which is the core behavior of a self-propagating worm. No plaintext command-and-control host shows up in the strings. The exfiltration endpoint is built at runtime inside the obfuscated bundle.
Full deobfuscation of the stage-2 bundle confirms and extends what StepSecurity documented for the broader Miasma campaign. The payload runs as a single async routine with four distinct phases.
Phase 1: Environmental checks. It scans /proc for a Runner.Worker process to confirm it is inside a GitHub Actions runner, then exits immediately if step-security harden-runner is active. The harden-runner check runs before any credential collection so the worm leaves no trace on protected runners.
Phase 2: Credential harvesting. It fans out across every secrets surface reachable from CI: AWS IMDS at 169.254.169.254 and the ECS task metadata endpoint at 169.254.170.2, STS AssumeRoleWithWebIdentity, Secrets Manager, and SSM Parameter Store; Azure Graph and Key Vault; GCP Secret Manager; a local Vault server at 127.0.0.1:8200 via AWS and Kubernetes auth; the Kubernetes service-account token at /var/run/secrets/kubernetes.io/serviceaccount/token; and local password managers including 1Password and Bitwarden. It also reads runner process memory to extract GitHub Actions masked secrets specifically — the ones GitHub obscures with *** in logs. The extraction uses a grep against the runner's internal JSON format ("isSecret":true) to pull those values from the raw memory dump.
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
