Table of contents
Compromised LiteLLM 1.107.0 on PyPI Ships a Credential Stealer and Code Loader
On October 21, 2026, a malicious release of litellm, the Python library and proxy for calling LLM provider APIs, was published to PyPI as version 1.107.0. All seven compiled wheels carried three extra files that install straight into the root of site-packages: a .pth hook that runs a credential stealer every time Python starts, and a sitecustomize.py that starts a background loader built to fetch and run remote Python code every hour. The package has had more than 92 million downloads over the past month. PyPI quarantined the version in under two hours, and the maintainers removed the malicious Docker images for the same release by 14:32 UTC.
How the payload got into the release
The v1.107.0 git tag doesn’t contain the payload. The released package matches a legitimate main-branch commit made later that morning, a8113b0d (08:50 UTC), plus a set of edits that aren’t in the tag. Those edits come from ten commits layered over a8113b0d between 08:56 and 10:46 UTC on October 21. None of them sits on a branch or tag of BerriAI/litellm, but each one still opens by hash in the repository. Nine are authored as Berri-AI <ckdi82fkoos@proton.me> and the tenth under a Berri-AI GitHub noreply address. Berri-AI is a hyphenated lookalike of the project’s real organization name, BerriAI.
The first three commits add the payload files under a new cook/purelib/ directory, with messages such as “Add import statement” and “Change greeting”. Four more adjust package versions across the repository’s pyproject.toml files and uv.lock. The tagged commit already declared litellm as 1.108.0, and the attacker’s commit set it back to 1.107.0, the version the tag carries. The first nine commits were created through GitHub’s web interface, which signs them, so they show a “Verified” badge. The badge only confirms that the commit was created on github.com.
As of the maintainers’ 14:32 UTC update, they had not yet identified how the attacker got the release published, and they asked open source users to pin 1.106.2 or another explicit version published before the incident.
The pyproject.toml change
The commit that makes the files ship is a one-line addition to the maturin build configuration:
[tool.maturin]
manifest-path = "litellm-rust/crates/python-bridge/Cargo.toml"
module-name = "litellm.rust_bridge._native"
python-source = "."
+data = "cook"
python-packages = ["litellm_cli"]Figure 1: The data = "cook" line added to [tool.maturin] in pyproject.toml, absent from the v1.107.0 tag
The data option bundles the cook directory into the built wheels, even though the directory doesn’t exist in the tagged source. Each 1.107.0 wheel ends up with three extra entries under litellm-1.107.0.data/purelib/, and pip installs them into the root of site-packages, next to litellm itself rather than inside it:
cook.pth 12 bytes
cook.py 22,252 bytes
sitecustomize.py 25,509 bytes
Figure 2: Files added to every litellm 1.107.0 wheel
The location matters. A .pth file and a module named sitecustomize in site-packages both run when the interpreter starts, so nothing has to import litellm for the payload to run. The sdist carries the modified pyproject.toml but not the payload files.
What runs at startup
The incident report filed in the LiteLLM GitHub repository walks through both files in detail. cook.pth holds a single line, import cook. From then on, cook.py loads whenever Python starts in that environment, pip included, so reinstalling a clean version from inside the affected environment runs both files again.
cook.py: the credential stealer
cook.py is one line that decompresses and runs a zlib-compressed, base64-encoded blob. The code inside marks itself with the environment variable PRIVCOLLECT_ACTIVE=1 and runs its collection in a background daemon thread.
Collection is split into 16 modules. The targets include host and network details, saved browser passwords (which it decrypts) and browsing history, Firefox credential stores, SSH keys, shell history, AWS, Azure, Google Cloud, Kubernetes and Docker credentials, Git credentials, package registry tokens, private key files, CI system secrets, and configuration and tokens for AI coding tools including Claude, Codex, Gemini, Cursor, Windsurf and Copilot. It also reads the environment of other processes through /proc/<pid>/environ and queries cloud instance metadata endpoints for credentials. The full target list is in the Credential targets table below.
One module is written specifically for LiteLLM deployments. When DATABASE_URL is set, it reads LiteLLM_CredentialsTable and LiteLLM_ProxyModelTable. On a LiteLLM proxy, the credentials table holds the provider API keys the proxy stores.
The collected data is encrypted with AES-256-CBC under a random key, and that key is in turn encrypted with an RSA-4096 public key hardcoded in the payload, using OAEP padding. The result is sent as a tar.gz archive in an HTTP POST to hxxp://www[.]litellm[.]dev/healthcheck. The domain was registered on October 13 and has nothing to do with the project’s real domain, litellm.ai.
After a successful upload, the stealer writes /tmp/.pc_once_72129118c59f5636c0bb and doesn’t collect again on that host. Its absence doesn’t prove a host was spared, since /tmp doesn’t survive a container restart.
sitecustomize.py: the remote code loader
sitecustomize.py also runs at startup. It takes an exclusive lock on /tmp/remote_code_loader.lock, forks, detaches with setsid, points its standard streams at /dev/null, and changes directory to /. The lock keeps a second loader from starting on the same host. What remains is an orphaned python process with / as its working directory.
Every hour, the loader reads domain.txt from the GitHub repository letters9813/pythonclient-test. If the file holds a domain, it runs a second copy of the stealer, this one without the marker file, then pulls /codepy from that host and runs the response as Python code in the loader’s own process. Whatever Python the attacker serves runs with the privileges of the process that started the interpreter.
The letters9813 account and its repository have since gone offline. Both, along with domain.txt, now return 404.
The Docker images
The final commit in the chain, 20535d8f, adds COPY cook ./cook to the project’s backend, gateway and migrations Dockerfiles. The v1.107.0 images on Docker Hub (litellm/litellm) and GHCR (berriai/litellm, berriai/litellm-database and berriai/litellm-non_root) carried the payload, and the latest, main-latest and main-stable tags pointed to them, so pulling any of those tags before the removal delivered the stealer. The maintainers confirmed at 14:32 UTC that the malicious images were removed, and the v1.107.0 tag now returns 404 on both registries.
In a proxy container with DATABASE_URL set, the LiteLLM module reads the proxy’s credential tables on the container’s first start.
Timeline
| Time (UTC) | Event |
|---|---|
| 2026-10-13 00:45 | litellm[.]dev registered |
| 2026-10-21 06:59 | v1.107.0 tag commit (66c8f5b1), declaring version 1.108.0 |
| 2026-10-21 08:50 | Legitimate main-branch commit a8113b0d |
| 2026-10-21 08:56 to 10:46 | Ten attacker commits layered over a8113b0d |
| 2026-10-21 10:44 | 1.106.2, the last clean release, published to PyPI |
| 2026-10-21 11:39 to 11:40 | litellm 1.107.0 wheels and sdist uploaded to PyPI |
| 2026-10-21 12:06 | Compromise reported in the LiteLLM GitHub repository |
| 2026-10-21 13:23 | Reporter confirms 1.107.0 is no longer served by PyPI |
| 2026-10-21 13:50 | Maintainers acknowledge the incident as SEV-0 |
| 2026-10-21 14:32 | Maintainers confirm the malicious Docker images are removed |
Indicators of compromise
Network
| Indicator | Value | Description |
|---|---|---|
| Data upload URL | hxxp://www[.]litellm[.]dev/healthcheck | Receives the encrypted archive via HTTP POST |
| Upload domain | litellm[.]dev | Registered 2026-10-13; not the project’s domain (litellm.ai) |
| Dead-drop URL | hxxps://raw[.]githubusercontent[.]com/letters9813/pythonclient-test/main/domain.txt | Polled hourly for the second-stage host; now returns 404 |
| Dead-drop account | letters9813 | GitHub account that hosted domain.txt; now returns 404 |
| Second-stage path | hxxp://<domain>/codepy | Python code fetched and run by the loader once a domain is set |
| Request header | X-Token: XWiA8gZNa7NcX2XRoAEb | Static token on upload requests |
| User-Agent | Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0) | Sent with upload requests |
File system and process
| Indicator | Value | Description |
|---|---|---|
| Startup files | site-packages/cook.pth, site-packages/cook.py, site-packages/sitecustomize.py | 12, 22,252 and 25,509 bytes |
| Once-marker | /tmp/.pc_once_72129118c59f5636c0bb | Written only after a successful upload |
| Loader lock | /tmp/remote_code_loader.lock | Held by the detached loader process |
| Environment variable | PRIVCOLLECT_ACTIVE=1 | Set by the stealer |
Packages and images
| Indicator | Value | Description |
|---|---|---|
| PyPI release | litellm==1.107.0 | All seven wheels; the sdist carries the modified pyproject.toml |
| Container images | docker.io/litellm/litellm, ghcr.io/berriai/litellm, ghcr.io/berriai/litellm-database, ghcr.io/berriai/litellm-non_root | Tag v1.107.0, and latest, main-latest and main-stable as served on 2026-10-21 before the removal |
Wheel hashes (SHA256)
| File | SHA256 |
|---|---|
litellm-1.107.0-cp310-abi3-macosx_10_12_x86_64.whl | 7598727f209d717041db37f7dca271d3a7d328a279890e0e1d55375729aeb86d |
litellm-1.107.0-cp310-abi3-macosx_11_0_arm64.whl | 0aaf7ba346d41ef2f40390436738b001180d5a1c03aa093f121078a9969d9a1a |
litellm-1.107.0-cp310-abi3-manylinux_2_28_aarch64.whl | 668169d35b87bf51324080390b1b1c5ab12bd335cfe5f1a99e3689abd5e02ae6 |
litellm-1.107.0-cp310-abi3-manylinux_2_28_x86_64.whl | 462d0c9f67ca522d9e0e4acd245e90b36ce0d5353604a66bb7c43a1ec4a6fcf7 |
litellm-1.107.0-cp310-abi3-musllinux_1_2_aarch64.whl | c2e32d0ce2548aa662c18bf5001911a79fe5b30cf8357132275add02fdcde054 |
litellm-1.107.0-cp310-abi3-musllinux_1_2_x86_64.whl | 1bbce8caa94f01c0aaa0b7ddae51553a8a32e1ce53d96d44fe583272aff7ad00 |
litellm-1.107.0-cp310-abi3-win_amd64.whl | 12d4ccc5c6f8d35cf4f2cf233b7f3e418e863e657d81ff5f5fc9e210005b9484 |
Credential targets
| Category | Targets |
|---|---|
| Browsers | Saved passwords and history from Chromium-based browsers; Firefox logins.json and key4.db |
| Developer machine | ~/.ssh/*, shell history, Git credentials, .netrc, private key files such as *.pem, *.key and *.pfx |
| Package registries | .npmrc, .pypirc and pip configuration |
| Cloud and containers | AWS, Azure and Google Cloud credentials, kubeconfig, Docker configuration, gh and rclone credentials, Kubernetes service account tokens |
| Cloud metadata | Instance metadata endpoints on AWS, Google Cloud and Azure |
| CI and infrastructure | GitLab runner configuration, Jenkins secrets, Vault tokens, Terraform and Packer credentials |
| AI developer tools | Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, Windsurf, Copilot, VS Code mcp.json, Qwen and Kiro configuration, Hugging Face tokens |
| Runtime | Environment variables, including other processes’ /proc/<pid>/environ |
| LiteLLM proxy | LiteLLM_CredentialsTable and LiteLLM_ProxyModelTable via DATABASE_URL |
| Keys | GPG and TLS private keys |
Detection
Search for cook.pth, cook.py and sitecustomize.py at the top level of every site-packages directory on a host. Each virtual environment and container image has its own. Compare file sizes against the IOC table.
For exposure, check lockfiles, requirements files and build logs for litellm 1.107.0, and check package caches and artifact stores against the wheel hashes above, since those identify the malicious files even now that PyPI no longer serves them. For containers, look for anything started from v1.107.0, or from latest, main-latest or main-stable pulled on October 21 before the removal, since those tags don’t say 1.107.0. On running hosts, look for the lock file, a detached python process with / as its working directory, and outbound requests to the upload domain or the dead-drop URL. A missing once-marker doesn’t prove a host was spared.
Remediation
- Remove the startup files before running Python again. Any interpreter start, including
pip, re-runs both hooks. Using shell tools only, removecook.pth,cook.py,sitecustomize.pyand their compiled__pycache__copies from each affectedsite-packages, then stop the detachedpythonprocess holding/tmp/remote_code_loader.lock. - Block the infrastructure. Block the upload domain at DNS and the dead-drop URL at the egress proxy (both in the IOC table).
- Find every place
1.107.0ran. Developer machines, CI runners, build caches, and any container started fromv1.107.0or fromlatest,main-latestormain-stablepulled on October 21 before the images were removed. - Rotate everything the host could read. If it had outbound network access, treat the upload as successful. On a LiteLLM proxy, start with the provider keys held in its database, its master and virtual keys, and the database password. Then move to cloud role credentials, SSH keys, registry and CI tokens, AI tool tokens, and any value that sat in a process environment on that host.
- Review cloud activity. Check for use of the rotated role credentials from outside your infrastructure after the install time.
- Rebuild from a pinned version. Recreate virtual environments and images from scratch on
litellm==1.106.2, and pin container images by digest rather than by tag, since the maintainers haven’t yet identified how the malicious release was published.
Conclusion
The source shipped in litellm 1.107.0 matches attacker commits layered over a legitimate main-branch commit rather than its tagged source, and it was published under the project’s real names on PyPI and in its container images. On a proxy with a database configured, the stealer also reads the provider API keys stored in that database, on top of the host’s own credentials. With the dead-drop account offline as of October 21, the loader no longer has a source for a second-stage host.
Mend.io coverage
Mend.io has issued 1 MSC advisory covering the affected package:
- MSC-2026-13023
References
- LiteLLM GitHub issue #47826: https://github.com/BerriAI/litellm/issues/47826