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?
| Situation | In 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 day | Yes — the image carries it |
| On an earlier version (1.106.x and below) or 1.107.0rc1/rc2 | No, not for this issue |
| Not sure which version CI resolved | Check the lockfile / image, below |
# 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.
# 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#!/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
- 1Isolate 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.
- 2Rotate 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.
- 3Rebuild from clean. Delete the virtualenv or rebuild the container from a known-good base — do not just remove the
litellmdirectory, because the droppers sit at the top level ofsite-packages, not inside the package. - 4Pin 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. - 5Check 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?+
I only imported litellm once in a notebook — is that enough to be hit?+
We build from the source tarball — are we safe?+
Can I just delete the litellm folder and keep the environment?+
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