Skip to content
StepSecurity

StepSecurity

www.stepsecurity.io • October 21, 2026

On October 21, 2026, litellm 1.107.0 was published to PyPI with malware in every one of its compiled wheels, and the same payload shipped in the official LiteLLM Docker images. LiteLLM, maintained by BerriAI, is the Python SDK and proxy that puts more than 100 LLM providers behind one OpenAI compatible API. It runs on developer laptops, in CI pipelines, and on production AI gateways that hold provider API keys for OpenAI, Anthropic, Azure, Bedrock, and Vertex. This is the second time this year that LiteLLM has been compromised on PyPI. The first was the 1.82.7 and 1.82.8 incident in March .

All seven 1.107.0 wheels (macOS, Linux glibc and musl, and Windows) carry three extra files that pip copies into the root of site-packages: cook.pth , cook.py , and sitecustomize.py . The v1.107.0 Docker images on GHCR and Docker Hub carry byte identical copies, and for three hours the latest , main-latest , and main-stable tags pointed to the infected image. Python runs both payloads automatically on every interpreter start in that environment. Nobody has to import litellm . The first payload is a credential stealer that calls itself privcollect . It takes browser passwords, SSH and GPG keys, cloud credentials, the environment of every running process, the configs of more than 20 AI coding tools, and the provider API keys stored in the LiteLLM proxy database. It encrypts the bundle and posts it to the lookalike domain . The second payload forks a background daemon that checks a GitHub dead drop every hour and runs whatever Python the attacker serves.

The v1.107.0 git tag is clean. The release was built from a lookalike GitHub repository, Berri-AI/litellm , where the attacker had stacked ten commits on top of the tag that added the payload and a data = "cook" line to pyproject.toml . PyPI has removed the release and BerriAI has removed the infected Docker images.

If litellm 1.107.0 was installed from a wheel, or you ran a LiteLLM container pulled on October 21, treat that host as fully compromised.

This covers the v1.107.0 image and any pull of latest , main-latest , or main-stable for ghcr.io/berriai/litellm , litellm-database , litellm-non_root , and docker.io/litellm/litellm from 11:48 UTC until BerriAI removed the images that afternoon.

Every Python start on that host ran the stealer. Kill any detached Python process holding /tmp/remote_code_loader.lock , delete cook.pth , cook.py , and sitecustomize.py from site-packages, and pin litellm==1.106.2 (or the v1.106.2 image by digest). Then rotate every credential the host could reach: cloud keys and instance roles, SSH and GPG keys, browser passwords, AI tool tokens, CI and Vault secrets, and every provider API key stored in the LiteLLM proxy database. Block litellm.dev . Full recovery steps are below.

The Compromised Package

Because LiteLLM has shipped as maturin built platform wheels since the 1.10x line, almost every install pulls a wheel rather than the sdist. A wheel only payload therefore reached essentially every 1.107.0 user. LiteLLM also releases often (12 releases across four minor lines between October 17 and October 21), and many projects depend on it with an open ended litellm>= constraint, so a fresh install or lockfile refresh would have picked up 1.107.0 as soon as it was published. Proxy deployments that track the latest or main-stable image tag picked it up on their pull or restart.

The wheels carry files that are not part of LiteLLM. The first three entries in the manylinux x86_64 wheel's RECORD are litellm-1.107.0.data/purelib/cook.pth , cook.py , and sitecustomize.py . No earlier release contains them, and neither do the 1.107.0 release candidates.

The published build config does not match git. The sdist's pyproject.toml has a data = "cook" line under [tool.maturin] . That line is not present at the v1.107.0 tag (commit 66c8f5b1a9 ). Every Python source file under litellm/ in the wheel is byte identical to the sdist. The tampering is limited to the build config and the three injected files.

The release was built from a lookalike repository. The SLSA provenance attached to every v1.107.0 Docker image names the source as (with a hyphen) at commit 20535d8f . The same release workflow recorded the real BerriAI/litellm repository as the source for v1.106.2 , v1.106.1 , and v1.107.0-rc.2 .

The attacker infrastructure was staged a week ahead. litellm.dev was registered on October 13, 2026 at 00:45 UTC. The GitHub account letters9813 , which hosted the dead drop, was created six hours later on the same day.

Step 1: A one line change to the build config

LiteLLM builds its wheels with maturin, which compiles the Rust bridge ( litellm/rust_bridge/_native.abi3.so ) and bundles the Python sources. Maturin's data option copies a local directory into the wheel's .data/ tree. The attacker added one line pointing it at a cook/ directory. Here is the [tool.maturin] section of pyproject.toml at the git tag, to the same section in the sdist published to PyPI:

The cook/ directory existed in the build tree but was left out of the sdist. The result is a source tree on GitHub that looks clean, an sdist that looks almost clean, and seven wheels plus a set of Docker images that carry the payload.

How the payload reached the release: a lookalike repository

The build provenance on every v1.107.0 Docker image names the builder as BerriAI's own release workflow, BerriAI/project-releaser run 39587059944 (started 10:51 UTC), and the source as at 20535d8fa11f786a6c24df7488a0e449ed5f09ad . Only v1.107.0 was built from the hyphenated repository. Berri-AI and Berri-AI/litellm now return 404.

Because a fork shares objects with its parent, the attacker's commits are still readable through BerriAI/litellm , although they sit on no branch or tag. The chain starts at the v1.107.0 tag, passes one legitimate main commit, and then adds ten commits from Berri-AI :

The commit messages are GitHub web editor defaults, and the first nine show a "Verified" badge. That badge only means the commits were made in GitHub's web editor, which signs everything it creates. It says nothing who made them. Who triggered the release run, and how its source input came to point at Berri-AI/litellm , are questions only the maintainers can answer. BerriAI says it is still investigating how the release was published.

Step 2: Two Python startup hooks, no import needed

The wheel's WHEEL metadata says Root-Is-Purelib: false , so pip installs everything under litellm-1.107.0.data/purelib/ into the top level of site-packages:

In the Docker images the same three files sit directly in /app/.venv/lib/python3.13/site-packages/ . From there, two standard CPython startup mechanisms fire on every interpreter launch, whether that is pip , pytest , a Jupyter kernel, or the LiteLLM proxy itself:

cook.pth is 12 bytes: import cook . Python's site module executes any .pth line that starts with import . This is the same technique the March compromise used with litellm_init.pth .

sitecustomize.py is imported by site automatically after the .pth files are processed. Its last lines check for __name__ == "sitecustomize" and call main() . Some distribution Pythons, such as Debian and Ubuntu system Python, ship their own sitecustomize.py earlier on sys.path , which shadows this one. The .pth path is not affected by that.

Step 3: The privcollect loader

cook.py is a single 22,251 character line. It aliases zlib and base64 to random names and executes the decompressed blob:

There is only one layer of encoding. It decodes to 61,000 bytes and 1,236 lines of plain Python whose configuration block names the tool and its targets:

On start, the stealer returns immediately if the environment variable PRIVCOLLECT_ACTIVE is set. Otherwise it sets the variable, which child processes inherit and which stops it from running twice, and runs the collection on a daemon thread so the host Python process is not visibly slowed down. A marker file at /tmp/.pc_once_72129118c59f5636c0bb stops repeat collection, but it is only written after the exfiltration server answers with HTTP 200. While exfiltration fails, the stealer runs again on every Python start.

Step 4: What it steals

The stealer runs 16 collection modules. The list covers the usual developer and cloud secrets, plus two targets chosen specifically for LiteLLM users: AI coding tool credentials and the LiteLLM proxy's own database.

Browsers: Chrome, Chromium, Edge, Brave, Vivaldi, and Opera login, cookie, and web data databases. Passwords are decrypted on the spot on Linux and Windows (up to 3,000). Firefox logins.json and key4.db are shipped for offline decryption. The top 300 history URLs per profile are also taken.

Keys: every file in ~/.ssh/ , GPG private keys from ~/.gnupg/ , any .pem , .key , .pfx , .p12 , .pk8 , or .ppk file under the directory, and TLS private keys from /etc/ssl/private , nginx and Apache SSL directories, and Let's Encrypt.

Cloud and developer credentials: AWS, Azure, and gcloud credential files, ~/.kube/config , Docker config, GitHub CLI tokens, rclone, .netrc , .git-credentials , .npmrc , .pypirc , Maven, Gradle, Composer, RubyGems, and Cargo credentials, and .env files two levels deep under the and working directories.

Cloud instance metadata: AWS IMDSv2 (falling back to v1) role credentials, ECS task credentials, GCP service account access tokens, and Azure instance metadata. Stolen AWS credentials are checked with STS GetCallerIdentity before they are sent.

CI and infrastructure secrets: GitLab Runner config, Jenkins admin password and master key, Vault tokens, Terraform and Packer credentials, Kubernetes service account tokens, and everything in /run/secrets .

Every process environment: the current environment and, on Linux, /proc/ /environ for every process it can read. On a CI runner or gateway host this captures secrets passed to other processes, not just the one that triggered the stealer.

AI coding tools: Claude Code and Claude Desktop, Cursor, Windsurf and Codeium, Kiro, Zed, GitHub Copilot, VS Code MCP settings and the Cline, Roo, Cody, and Claude Code extensions, Gemini CLI OAuth credentials, Codex auth.json , Continue, Aider, Goose, opencode, Qwen, Crush, amp, Factory, Trae, Ollama, Amazon Q, and the Hugging Face token.

Shell and database history: bash, zsh, fish, Python and Node REPL, MySQL, psql, Redis, and SQLite history, .viminfo , and PowerShell PSReadLine.

The db_litellm module goes after LiteLLM proxy deployments directly. If DATABASE_URL is set, it starts a child Python process that connects with the proxy's own Prisma client and dumps the stored provider credentials and model configurations, which include the upstream API keys. In a container built from the infected litellm-database image, the variant built for proxies with a database, this fires on the first start:

After the queries finish, it kills any Prisma query-engine processes it started, to avoid leaving stray processes behind.

Step 5: Encryption and exfiltration

The collected data becomes a JSON document tagged "tool": "privcollect" . It is encrypted with AES-256-CBC under a random key, and the key is wrapped with an embedded RSA-4096 public key using OAEP-SHA256. The payload tries four crypto backends in turn (the openssl command line, the cryptography package, Windows CNG, and a bundled pure Python AES), so it works on almost any host. The result is packed as a tar.gz containing hdr.json , data.bin , and key.bin and posted over plain HTTP:

The destination is with the header X-Token: XWiA8gZNa7NcX2XRoAEb . The path is named to look like a routine health check, and the domain is a lookalike of LiteLLM's real domains. Only the holder of the RSA private key can decrypt captured traffic, so a packet capture cannot tell you what was taken. Plan rotation on the assumption that everything the host could reach was stolen.

Step 6: An hourly remote code loader

sitecustomize.py is the second payload. It is mostly readable source. When it runs, it takes a non blocking lock on /tmp/remote_code_loader.lock so only one copy runs per host, then forks, calls setsid() , redirects its output to /dev/null , and changes directory to / . The resulting daemon outlives the Python process that started it. Every hour it does the following:

The daemon reads a hostname from a file in a GitHub repository the attacker controls, downloads http:// /codepy , and runs it with exec() . All errors, including SystemExit , are swallowed. The file also embeds a second copy of the privcollect stealer as a fallback for environments where only sitecustomize runs.

The dead drop history shows the operator testing the loader before the release:

domain.txt was empty when the release went out, so the /codepy stage was never armed while the dead drop existed. After the disclosure, the letters9813 account and its pythonclient-test repository were deleted, and both now return 404. That cuts off the current channel, but infected hosts keep polling the same URL every hour. The channel is tied to the username, so anyone who later registers letters9813 and recreates pythonclient-test/main/domain.txt would get code execution on every host that is still infected. This is why killing the daemon is part of recovery, not just removing the package.

On Windows, fcntl and os.fork do not exist, so the remote code loader fails silently. The stealer still runs. Its marker file path also does not resolve on Windows, so the stealer likely runs again on every Python start there.

The attacker pushes ten commits to the lookalike Berri-AI/litellm repository, and BerriAI's release workflow builds v1.107.0 from it.

pip install litellm==1.107.0 installs any of the seven wheels and copies cook.pth , cook.py , and sitecustomize.py into site-packages. A container started from an infected image already has them.

The Python start of any kind runs cook.pth , which imports cook . The privcollect stealer starts on a background thread and sets PRIVCOLLECT_ACTIVE=1 .

The stealer runs 16 collection modules, including the LiteLLM proxy database dump when DATABASE_URL is set.

The bundle is encrypted with AES-256-CBC and RSA-4096 and posted to . On HTTP 200 it writes /tmp/.pc_once_72129118c59f5636c0bb .

In parallel, sitecustomize.py takes the lock at /tmp/remote_code_loader.lock and forks a detached daemon (Linux and macOS).

Every hour the daemon reads domain.txt from letters9813/pythonclient-test . If it holds a hostname, the daemon downloads http:// /codepy and executes it.

Indicators of Compromise

PyPI package: litellm==1.107.0 (all wheels)

Docker images: ghcr.io/berriai/litellm:v1.107.0 (index sha256:1bbcb1443bc9... , amd64 manifest sha256:bd1aeee419ea36da9aa2b657288da4101887be468f020ac63d22ba6ed84a3ea5 ), ghcr.io/berriai/litellm-database:v1.107.0 (index sha256:c00f9d92c427... ), ghcr.io/berriai/litellm-non_root:v1.107.0 (index sha256:7b00e91cd2fe... ), and docker.io/litellm/litellm:v1.107.0 (same index as GHCR)

Lookalike source repository: github.com/Berri-AI/litellm , malicious head commit 20535d8fa11f786a6c24df7488a0e449ed5f09ad

Attacker commit author: Berri-AI

Exfiltration URL: (plain HTTP POST)

Exfiltration domain: litellm.dev and (registered October 13, 2026, behind Cloudflare)

Exfiltration headers: X-Token: XWiA8gZNa7NcX2XRoAEb , X-SHA256 , and User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0)

Dead drop URL: (account now deleted)

Dead drop GitHub account: letters9813

Second stage path: http:// /codepy (any request to this path means remote code ran)

Historic dead drop values: localhost:18909 , test.test.com:18909

Files in site-packages: cook.pth , cook.py , sitecustomize.py

Stealer marker: /tmp/.pc_once_72129118c59f5636c0bb

Loader lock: /tmp/remote_code_loader.lock (a live holder process means the loader is running)

Temporary directories: .pc_sq_* and .pc_e_* in the system temp directory

Environment variable: PRIVCOLLECT_ACTIVE=1

Build config: data = "cook" under [tool.maturin] in pyproject.toml

RSA public key prefix: MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvxRBQMVXDXVskKS47haU

File hashes (SHA-256)

Wheel file names above are shortened. Each starts with litellm-1.107.0-cp310-abi3- .

Check your Harden-Runner organization baseline for outbound calls to or litellm.dev , and for requests to raw.githubusercontent.com from jobs that install LiteLLM or run the LiteLLM image. Any call to litellm.dev from a runner is a strong signal that the stealer ran. Because the stealer reads /proc/ /environ for every process, treat every secret exposed to that job as stolen, not only the ones passed to the Python step.

Developer laptops are a primary target. That is where browser passwords, SSH keys, cloud credentials, and AI coding tool tokens live. Look for cook.pth or cook.py in any site-packages directory, including virtual environments, and for a sitecustomize.py that did not come from your Python distribution. The files /tmp/.pc_once_72129118c59f5636c0bb and /tmp/remote_code_loader.lock are direct signs of infection:

Containers and Kubernetes

If you run the LiteLLM proxy from the official images, check which image digest each deployment actually pulled on October 21. A deployment on latest , main-latest , or main-stable that pulled or restarted between 11:48 and 14:32 UTC got the infected image. Check running containers and your internal registry for the payload files and the digests listed above:

LiteLLM Proxy and Gateway Hosts

If 1.107.0 ran on a LiteLLM proxy host or container with DATABASE_URL set, assume every credential in LiteLLM_CredentialsTable and every API key in LiteLLM_ProxyModelTable was taken, along with the master key and salt key from the environment. On cloud instances, assume the instance role credentials were taken from the metadata service. BerriAI states that its private enterprise registry was not affected.

your lockfiles and requirements for the bad version:

Also check projects that depend on LiteLLM with an open ended constraint such as litellm>=1.x , and Dockerfiles, Compose files, and Helm values that reference a floating LiteLLM image tag. Any install, lock refresh, or image pull between the 1.107.0 release and its removal could have resolved to the malicious version.

If litellm 1.107.0 was installed from a wheel, or a container ran from an infected image, treat that host as compromised:

Kill the remote code loader. Find the process holding /tmp/remote_code_loader.lock with lsof or fuser and kill it. Its parent is PID 1 and its working directory is / .

Remove the payload files. Delete cook.pth , cook.py , and sitecustomize.py from every affected site-packages directory. pip uninstall litellm removes them because they are listed in RECORD , but verify they are gone. Then delete the marker file and reinstall a clean version: pip install 'litellm==1.106.2' .

Redeploy containers on a pinned clean image. Replace any container built from an infected image instead of cleaning it in place. Pin v1.106.2 by digest rather than by a floating tag, and purge cached v1.107.0 images from internal registries and pull through caches.

Purge mirrors and proxies. Some PyPI mirrors kept serving cached 1.107.0 wheels after PyPI removed the release. If you run a PyPI mirror or a caching proxy, delete the 1.107.0 files by the hashes above.

Rotate every credential the host could reach. Cloud access keys and instance role sessions, SSH and GPG keys, saved browser passwords and session cookies, GitHub, npm, PyPI, and other registry tokens, Kubernetes and Vault tokens, CI runner secrets, and AI tool tokens for Claude, Cursor, Copilot, Gemini, Codex, and the others listed above.

On LiteLLM proxy hosts, rotate every provider API key stored in the proxy database and in model litellm_params , plus the LiteLLM master key and salt key.

Block the attacker infrastructure. Block litellm.dev at your DNS resolver or egress firewall, and alert on requests to raw.githubusercontent.com/letters9813/ .

Audit network logs. for requests to litellm.dev , to the dead drop URL, and to any path ending in /codepy . A /codepy request means attacker supplied code ran on that host, and the incident scope is larger than this analysis.

How StepSecurity Protects Against This

Global Block List: the exfiltration domains are already blocked

StepSecurity has added litellm.dev and to the Harden-Runner Global Block List, a curated list of indicators of compromise from real supply chain attacks. It applies to every workflow run automatically, alongside any policy you set, and it is enforced even when a workflow's egress policy is in audit mode. A CI job that installs litellm 1.107.0 or runs the infected image cannot reach the exfiltration server.

Harden-Runner detects and blocks the exfiltration in CI

Harden-Runner builds a baseline of the network destinations each workflow normally contacts. A job that installs LiteLLM normally reaches pypi.org and files.pythonhosted.org . A call to that has never appeared in that baseline is flagged as anomalous, along with the process that made it. Detection is based on what the code does at runtime, so it works on new attacker infrastructure that is not on any list yet. In block mode, any destination outside the allowlist is denied at the network layer, so the stolen bundle never leaves the runner.

Secure Registry stops the bad version at install time

StepSecurity Secure Registry is a policy enforced registry that sits between your existing package managers and the public PyPI and npm registries. Once a version is confirmed compromised, as litellm 1.107.0 is, Secure Registry blocks it from being downloaded across your organization. A developer running pip install , a lockfile refresh, or a CI job resolving litellm>= gets a clean version instead of the stealer, and the block holds even if a public mirror is still serving cached copies.

Dev Machine Guard covers developer laptops

The payload is built for developer machines, where browser passwords, SSH keys, and AI coding tool credentials live. Dev Machine Guard's suspicious files detection watches for unexpected .pth files and sitecustomize.py files appearing in Python environments, which is exactly how this release and the March LiteLLM compromise ran their code.

StepSecurity Enterprise customers can see this incident in the Threat Center, with the full IOC set, affected versions, and remediation steps, and receive alerts through their existing SIEM, Slack, and email integrations.

This compromise was reported by Lidor Machluf in issue #47826 on the BerriAI/litellm repository , including the analysis of the Docker images and the build provenance that traced the release to the Berri-AI/litellm lookalike repository. Thanks to Lidor for the detailed public write up.

GitHub Issue #47826, BerriAI/litellm: litellm 1.107.0 compromised

litellm v1.107.0 GitHub release

Commit 66c8f5b1a9 at the v1.107.0 tag

Malicious head commit 20535d8f from the lookalike repository

StepSecurity: litellm credential stealer hidden in PyPI wheel (March 2026)

Python documentation: the site module, .pth files, and sitecustomize

Check if litellm 1.107.0 is in your environment

StepSecurity checks your repositories, CI/CD pipelines, and developer machines for compromised packages like litellm 1.107.0.