Table of contents

Compromised LiteLLM 1.107.0 on PyPI Ships a Credential Stealer and Code Loader

LiteLLM Supply Chain Attack

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:45litellm[.]dev registered
2026-10-21 06:59v1.107.0 tag commit (66c8f5b1), declaring version 1.108.0
2026-10-21 08:50Legitimate main-branch commit a8113b0d
2026-10-21 08:56 to 10:46Ten attacker commits layered over a8113b0d
2026-10-21 10:441.106.2, the last clean release, published to PyPI
2026-10-21 11:39 to 11:40litellm 1.107.0 wheels and sdist uploaded to PyPI
2026-10-21 12:06Compromise reported in the LiteLLM GitHub repository
2026-10-21 13:23Reporter confirms 1.107.0 is no longer served by PyPI
2026-10-21 13:50Maintainers acknowledge the incident as SEV-0
2026-10-21 14:32Maintainers confirm the malicious Docker images are removed

Indicators of compromise

Network

IndicatorValueDescription
Data upload URLhxxp://www[.]litellm[.]dev/healthcheckReceives the encrypted archive via HTTP POST
Upload domainlitellm[.]devRegistered 2026-10-13; not the project’s domain (litellm.ai)
Dead-drop URLhxxps://raw[.]githubusercontent[.]com/letters9813/pythonclient-test/main/domain.txtPolled hourly for the second-stage host; now returns 404
Dead-drop accountletters9813GitHub account that hosted domain.txt; now returns 404
Second-stage pathhxxp://<domain>/codepyPython code fetched and run by the loader once a domain is set
Request headerX-Token: XWiA8gZNa7NcX2XRoAEbStatic token on upload requests
User-AgentMozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0)Sent with upload requests

File system and process

IndicatorValueDescription
Startup filessite-packages/cook.pth, site-packages/cook.py, site-packages/sitecustomize.py12, 22,252 and 25,509 bytes
Once-marker/tmp/.pc_once_72129118c59f5636c0bbWritten only after a successful upload
Loader lock/tmp/remote_code_loader.lockHeld by the detached loader process
Environment variablePRIVCOLLECT_ACTIVE=1Set by the stealer

Packages and images

IndicatorValueDescription
PyPI releaselitellm==1.107.0All seven wheels; the sdist carries the modified pyproject.toml
Container imagesdocker.io/litellm/litellm, ghcr.io/berriai/litellm, ghcr.io/berriai/litellm-database, ghcr.io/berriai/litellm-non_rootTag v1.107.0, and latest, main-latest and main-stable as served on 2026-10-21 before the removal

Wheel hashes (SHA256)

FileSHA256
litellm-1.107.0-cp310-abi3-macosx_10_12_x86_64.whl7598727f209d717041db37f7dca271d3a7d328a279890e0e1d55375729aeb86d
litellm-1.107.0-cp310-abi3-macosx_11_0_arm64.whl0aaf7ba346d41ef2f40390436738b001180d5a1c03aa093f121078a9969d9a1a
litellm-1.107.0-cp310-abi3-manylinux_2_28_aarch64.whl668169d35b87bf51324080390b1b1c5ab12bd335cfe5f1a99e3689abd5e02ae6
litellm-1.107.0-cp310-abi3-manylinux_2_28_x86_64.whl462d0c9f67ca522d9e0e4acd245e90b36ce0d5353604a66bb7c43a1ec4a6fcf7
litellm-1.107.0-cp310-abi3-musllinux_1_2_aarch64.whlc2e32d0ce2548aa662c18bf5001911a79fe5b30cf8357132275add02fdcde054
litellm-1.107.0-cp310-abi3-musllinux_1_2_x86_64.whl1bbce8caa94f01c0aaa0b7ddae51553a8a32e1ce53d96d44fe583272aff7ad00
litellm-1.107.0-cp310-abi3-win_amd64.whl12d4ccc5c6f8d35cf4f2cf233b7f3e418e863e657d81ff5f5fc9e210005b9484

Credential targets

CategoryTargets
BrowsersSaved 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 containersAWS, Azure and Google Cloud credentials, kubeconfig, Docker configuration, gh and rclone credentials, Kubernetes service account tokens
Cloud metadataInstance metadata endpoints on AWS, Google Cloud and Azure
CI and infrastructureGitLab runner configuration, Jenkins secrets, Vault tokens, Terraform and Packer credentials
AI developer toolsClaude Code, Claude Desktop, Codex, Gemini CLI, Cursor, Windsurf, Copilot, VS Code mcp.json, Qwen and Kiro configuration, Hugging Face tokens
RuntimeEnvironment variables, including other processes’ /proc/<pid>/environ
LiteLLM proxyLiteLLM_CredentialsTable and LiteLLM_ProxyModelTable via DATABASE_URL
KeysGPG 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

  1. Remove the startup files before running Python again. Any interpreter start, including pip, re-runs both hooks. Using shell tools only, remove cook.pth, cook.py, sitecustomize.py and their compiled __pycache__ copies from each affected site-packages, then stop the detached python process holding /tmp/remote_code_loader.lock.
  2. Block the infrastructure. Block the upload domain at DNS and the dead-drop URL at the egress proxy (both in the IOC table).
  3. Find every place 1.107.0 ran. Developer machines, CI runners, build caches, and any container started from v1.107.0 or from latest, main-latest or main-stable pulled on October 21 before the images were removed.
  4. 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.
  5. Review cloud activity. Check for use of the rotated role credentials from outside your infrastructure after the install time.
  6. 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

Manage open source risk

Read the report

Recent resources

10 malicious npm versions

Mini Shai-Hulud hits openapi-react-query-codegen: 10 malicious npm versions

An npm supply chain attack published ten malicious versions

Read more
Mini Shai Hulud Hits keyv

Mini Shai-Hulud Hits keyv: Trojanized Release Exfiltrates CI Secrets via GitHub

Trojanized keyv@6.0.0 exfiltrates CI secrets through GitHub’s own API.

Read more
Featured image Rubygems attack 1000x650

RubyGems supply chain attack: malware used as a credential exfiltration dead drop

RubyGems supply chain attack: stolen credentials hidden in the registry.

Read more