Guide

LiteLLM 1.107.0 was compromised: check your Python environments for stolen keys

By Aman Singh · Published · 8 min read

On 21 October 2026 a single LiteLLM release, version 1.107.0, shipped with malicious code on PyPI and in the official container images. LiteLLM is a widely used Python library and proxy for talking to LLM providers, so it tends to sit exactly where the secrets are: in services and notebooks that hold cloud keys, model-provider keys and database URLs. The bad release ran its payload at Python startup, before your own code, and used that moment to steal credentials — and it left behind a loader that fetches and runs more code every hour.

Only 1.107.0 is affected (the release candidates and every earlier line are clean). If you never installed that exact version, you are not in scope. If you might have — especially in a container image or CI job that pulls the latest release — this guide shows how to check without running Python in a possibly-compromised environment, and what to rotate if you did run it.

Think a key leaked? Get it rotated safely: Security patching →

What happened

The 1.107.0 wheels added three files to site-packages: cook.pth, cook.py and sitecustomize.py. A .pth file in site-packages whose line begins with import is executed every time the interpreter starts — and cook.pth is a 12-byte file containing exactly import cook. That means the payload loads on any Python launch, with no import litellm needed: a worker, a scheduled job, a notebook kernel, even an unrelated pip command.

There are two payloads. A credential stealer collects SSH keys, cloud credentials (AWS, Azure, GCP, Kubernetes), CI/CD secrets, registry tokens, browser passwords, shell history and AI-tool API keys, encrypts them and POSTs them to a lookalike domain, http://www.litellm.dev/healthcheck. Separately, sitecustomize.py forks a background daemon that wakes every hour (LOOP_INTERVAL = 3600), reads a hostname from a GitHub raw URL, fetches more code and runs it with exec() — a remote code loader. The dead drop was empty when the release shipped, but infected hosts keep polling it.

Am I affected?

SituationIn scope?
Installed litellm 1.107.0 wheel (pinned, or "latest" on 21 Oct)Yes — treat the environment as compromised
Pulled image v1.107.0 / latest / main-latest / main-stable that dayYes — the image carries it
On an earlier version (1.106.x and below) or 1.107.0rc1/rc2No, not for this issue
Not sure which version CI resolvedCheck the lockfile / image, below
Terminal
# Check what is installed from a CLEAN machine, or read the files directly.
# Do not run pip in an environment you think is compromised.
cat .venv/lib/python*/site-packages/litellm-*.dist-info/METADATA | grep -i '^Version:'

# Pin a known-good version everywhere
grep -rn 'litellm' requirements*.txt pyproject.toml poetry.lock uv.lock 2>/dev/null
# then: litellm==1.106.4  (backports: 1.105.5, 1.104.7, 1.103.9)

Scan your environments safely

This is a single Node file with no dependencies. It only reads files — it never imports Python, runs pip, or changes anything — so it is safe against a suspect machine. It finds every site-packages under a path, reports whether litellm 1.107.0 is installed, and flags the startup droppers — matching the published SHA-256 hashes where it can, and otherwise any .pth file that executes code on startup.

Terminal
# Node 18+; no dependencies, reads files only. Do NOT run python/pip here.
node litellm-scan.mjs                 # search under the current folder
node litellm-scan.mjs .venv           # or point it at a venv / environment
# exit 1 = indicators found, 0 = none in what was scanned
litellm-scan.mjs
#!/usr/bin/env node
// litellm-scan.mjs: DETECT-ONLY scanner for the compromised LiteLLM 1.107.0
// release, which ran credential-stealing code at Python startup. It reads
// files only — it never imports anything, runs pip, or changes the
// environment, so it is safe to run against a suspect machine.
//
// IMPORTANT: do not run python or pip inside an environment you think is
// compromised — startup hooks fire the moment the interpreter loads. This
// scanner is plain Node and touches nothing but the filesystem.
//
//   node litellm-scan.mjs                       # search under the current dir
//   node litellm-scan.mjs /path/to/venv         # or point it at a venv/env
//
// What it looks for inside any site-packages it finds:
//   1. litellm installed at version 1.107.0 (the compromised release).
//   2. The startup-execution droppers: cook.pth, cook.py, sitecustomize.py.
//   3. Any .pth file that runs code on startup (a .pth line beginning with
//      "import" executes) or references the exfiltration host.
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";

const BAD_LITELLM = new Set(["1.107.0"]);
const DROPPERS = new Set(["cook.pth", "cook.py", "sitecustomize.py"]);
// Published SHA-256 of the Linux/macOS droppers (StepSecurity). An exact match
// is definitive; Windows builds differ only by CRLF, so content checks also run.
const KNOWN_HASHES = new Set([
  "7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb", // cook.pth
  "9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25", // cook.py
  "34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa", // sitecustomize.py
]);
// Markers reported from the campaign; matching one in a .pth/.py is damning.
// The exfil POST goes to http://www.litellm.dev/healthcheck.
const EXFIL = /litellm\.dev\/healthcheck|PRIVCOLLECT_ACTIVE|remote_code_loader|import cook/i;
const PTH_EXEC = /^\s*import\s|\bexec\(|\bos\.system\(|subprocess/;

/** SHA-256 of a file, or "" if it cannot be read. */
function sha256(file) {
  try {
    return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
  } catch {
    return "";
  }
}

const findings = [];
const alert = (msg) => findings.push({ level: "alert", msg });
const review = (msg) => findings.push({ level: "review", msg });

/** Reads a small text file without throwing. */
function readText(file) {
  try {
    return fs.readFileSync(file, "utf8");
  } catch {
    return "";
  }
}

/** Reads a litellm *.dist-info/METADATA version, if present in a site-packages. */
function checkLitellmVersion(sitePackages) {
  let entries;
  try {
    entries = fs.readdirSync(sitePackages, { withFileTypes: true });
  } catch {
    return;
  }
  for (const e of entries) {
    if (!e.isDirectory() || !/^litellm-.*\.dist-info$/i.test(e.name)) continue;
    const meta = readText(path.join(sitePackages, e.name, "METADATA"));
    const version = /^Version:\s*(.+)$/im.exec(meta)?.[1]?.trim();
    if (version && BAD_LITELLM.has(version)) alert(`${sitePackages}: litellm ${version} installed (compromised release)`);
    else if (version) review(`${sitePackages}: litellm ${version} installed — confirm it is not a compromised build`);
  }
}

/** Inspects one site-packages directory for droppers and executable .pth files. */
function checkSitePackages(dir) {
  checkLitellmVersion(dir);
  let entries;
  try {
    entries = fs.readdirSync(dir, { withFileTypes: true });
  } catch {
    return;
  }
  for (const e of entries) {
    if (!e.isFile()) continue;
    const file = path.join(dir, e.name);
    if (DROPPERS.has(e.name)) {
      if (KNOWN_HASHES.has(sha256(file))) {
        alert(`${file}: ${e.name} matches a known compromised hash`);
        continue;
      }
      const body = readText(file);
      const hot = EXFIL.test(body) || PTH_EXEC.test(body);
      (hot ? alert : review)(`${file}: ${e.name} present${hot ? " and runs/exfiltrates on startup" : " — review its contents"}`);
      continue;
    }
    if (e.name.endsWith(".pth")) {
      const body = readText(file);
      if (body.split(/\r?\n/).some((l) => PTH_EXEC.test(l)) || EXFIL.test(body)) {
        alert(`${file}: .pth file executes code on interpreter startup`);
      }
    }
  }
}

/** Walks a tree (bounded) and inspects every directory named site-packages. */
function walk(root, depth = 0) {
  if (depth > 8) return;
  let entries;
  try {
    entries = fs.readdirSync(root, { withFileTypes: true });
  } catch {
    return;
  }
  for (const e of entries) {
    if (!e.isDirectory()) continue;
    const dir = path.join(root, e.name);
    if (e.name === "site-packages") {
      checkSitePackages(dir);
      continue; // no need to recurse further into installed packages
    }
    if (e.name === ".git" || e.name === "node_modules") continue;
    walk(dir, depth + 1);
  }
}

function main(argv) {
  const root = path.resolve(argv[0] ?? ".");
  console.log(`litellm-scan — detect only, nothing is changed\nscanning: ${root}\n`);
  walk(root);

  const alerts = findings.filter((f) => f.level === "alert");
  const reviews = findings.filter((f) => f.level === "review");
  if (alerts.length) {
    console.log("INDICATORS FOUND — treat the environment as compromised:");
    for (const f of alerts) console.log(`  [!] ${f.msg}`);
    console.log(
      "\nDo NOT run python/pip here. Rebuild the environment from a clean base,\n" +
        "and rotate every credential it could reach (cloud keys, model-provider\n" +
        "keys, database URLs, tokens in its environment variables).",
    );
  }
  if (reviews.length) {
    console.log("\nWorth a look (not proof on their own):");
    for (const f of reviews) console.log(`  [?] ${f.msg}`);
  }
  if (!alerts.length && !reviews.length) {
    console.log("No indicators of the LiteLLM 1.107.0 compromise found in what was scanned.");
    console.log("This is not a guarantee — scan every machine, container and CI runner that installed LiteLLM.");
  }
  return alerts.length ? 1 : 0;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  process.exitCode = main(process.argv.slice(2));
}

What to do if you ran it

  1. 1
    Isolate the environment. Stop the affected services, workers and jobs, and take the host off the network if you can — the hourly loader keeps reaching out.
  2. 2
    Rotate everything it could reach. Model-provider API keys (OpenAI, Anthropic, Azure and so on), cloud credentials, database connection strings, registry tokens, and anything that lived in the environment. Assume all of it leaked.
  3. 3
    Rebuild from clean. Delete the virtualenv or rebuild the container from a known-good base — do not just remove the litellm directory, because the droppers sit at the top level of site-packages, not inside the package.
  4. 4
    Pin a safe version. litellm==1.106.4 (or the backports 1.105.5, 1.104.7, 1.103.9), then reinstall from the clean environment.
  5. 5
    Check for follow-on access. Review provider and cloud audit logs for use of those keys around and after the install window, and look for resources or spend you did not create.

Reduce the next blast radius

  • Pin exact versions and use a lockfile. Floating to "latest" is how a single bad release reaches you within minutes.
  • Add a release cooldown. Hold brand-new versions for a few days before adopting them in build pipelines.
  • Keep secrets out of long-lived env vars where you can. Short-lived, least-privilege credentials limit what a startup hook can steal.
  • Separate environments. The key that talks to one provider should not also unlock your database and your cloud account.

Common questions

Which LiteLLM version is safe?+
Only 1.107.0 is compromised. Pin litellm==1.106.4 (or the backports 1.105.5, 1.104.7, 1.103.9), and reinstall from a clean environment. The 1.107.0 release candidates and every earlier line are clean. Confirm the installed version from a clean machine rather than running pip inside a suspect one.
I only imported litellm once in a notebook — is that enough to be hit?+
Yes. The payload ran at Python startup via cook.pth (just "import cook"), so any process that started the interpreter in that environment could have triggered it, even before your import line, and an hourly loader keeps running. Treat the environment as compromised and rotate its secrets.
We build from the source tarball — are we safe?+
Not automatically. The sdist contains the injected build line but not the payload, while all seven published wheels do carry it. What matters is what actually got installed: check for cook.pth / cook.py / sitecustomize.py in site-packages.
Can I just delete the litellm folder and keep the environment?+
No. The droppers sit at the top level of site-packages, not inside the litellm package, so removing litellm leaves them behind. Rebuild the environment from a clean base and rotate every credential it could reach.

Think a key leaked? Get help rotating safely

I can help you scan your Python environments and images, rebuild clean, and rotate exposed keys in the right order. See security patching. Related: the tensorlake npm worm and rotating leaked environment variables.

Sources

Next steps

Keep reading

Need a hand with this?

Tell me what you are working on. Free scoping call, fixed quote within 48 hours.

Book a call

← All guides