On 21 October 2026 someone published litellm 1.107.0 to PyPI and to the project's Docker images, and it was not LiteLLM's code. Every wheel dropped three files into site-packages that run at every Python start on the machine: a credential stealer that takes cloud keys, SSH keys, CI secrets, AI tool tokens and the provider API keys stored in a LiteLLM proxy's database, and a loader that checks in hourly for more code to run. You did not need to import litellm for it to fire. Running pip was enough (LiteLLM incident report, StepSecurity analysis).
This article explains how the release pipeline was hijacked, why a .pth file is such an effective place to hide, and how to check a host or image without setting the payload off again. It ends with the clean-up order, what to rotate, and three controls that would have kept 1.107.0 out of your builds. It is for anyone who runs LiteLLM as a library, as the proxy, or in CI, and for anyone who installs Python packages unpinned.
| Bad version | litellm==1.107.0: all seven wheels; the sdist has a tampered pyproject.toml but no payload |
| Bad images | v1.107.0, latest, main-latest, main-stable on ghcr.io/berriai/litellm, litellm-database, litellm-non_root and docker.io/litellm/litellm |
| Last clean release | 1.106.2 (1.107.0rc1 and rc2 are also clean) |
| Files planted | cook.pth, cook.py, sitecustomize.py in the root of site-packages |
| Exfiltration | POST http://www.litellm.dev/healthcheck (a look-alike domain registered 13 October) |
| Downloads while live | About 49,000, per Wiz citing ClickPy |
What happened to LiteLLM 1.107.0?
An attacker used stolen developer credentials to rewrite LiteLLM's private release workflow, point it at a look-alike repository, and let the official pipeline build, sign and publish a poisoned release. The v1.107.0 tag in the real BerriAI/litellm repository is clean; the artifacts were built from Berri-AI/litellm, with a hyphen (LiteLLM).
It is LiteLLM's second supply-chain incident this year. In March a stolen PyPI token was used to publish 1.82.7 and 1.82.8; nobody has linked the two attacks (Chainguard). After March the project moved releases to a private repository, adopted Trusted Publishing, made Docker tags immutable and signed its images. None of that helped here, because the attacker changed the trusted workflow itself.
Am I affected? Check the version and the time window
You are affected if anything installed litellm 1.107.0 between 11:39:51 and 13:23 UTC on 21 October, or pulled one of the affected image tags between 11:46:56 and 14:20 UTC. LiteLLM's own advice is to assume such a host is compromised (incident report). Floating tags matter most: latest and main-stable pointed at the bad image for the whole Docker Hub window.
Source: [LiteLLM incident report, 21 October 2026](https://docs.litellm.ai/blog/security-incident-oct-21-2026): PyPI 11:39:51–13:23, GHCR 11:46:56–13:51, Docker Hub 11:46:56–14:20 UTC
| Minutes available | |
|---|---|
| PyPI wheels | 103 min |
| GHCR images | 124 min |
| Docker Hub images | 153 min |
Lockfiles that pinned an earlier version were safe. So were enterprise customers on LiteLLM's private release, which the maintainers say was not affected. Anything that resolved litellm without an upper bound during that window, a nightly CI job, a fresh pip install litellm, a Kubernetes pod that restarted on latest, needs checking.
How the release pipeline was hijacked
The attacker held GitHub CLI tokens for three LiteLLM developers. LiteLLM believes they came from the logs of an internet-exposed internal LiteLLM deployment, where it found an unexpected user account; how the attacker got into that deployment is unconfirmed. The report also says that Claude Code can embed a user's GitHub CLI OAuth token in response bodies, which then end up in proxy logs (LiteLLM).
- Exposed internal proxy → Release workflowowner token
- Release workflow → Berri-AI/litellmsource repointed
- PyPI → Your hostpip install
- GHCR, Docker Hub → Your hostdocker pull
- Your host → www.litellm.devencrypted tar.gz
The timeline shows how many controls fell one after another. At 10:08:29 branch protection rejected the change to the release workflow; five seconds later an organisation owner's token overrode the protection and applied it. A staging run from the look-alike repository completed at 11:08. A second developer's credential passed the production approval gate at 11:11. At 11:53 a third credential deleted the workflow run records, which is why parts of the investigation rely on registry logs.
The payload itself was hidden in build configuration, not source. One added line under [tool.maturin], data = "cook", made the build copy a cook/ folder into each wheel's .data/purelib/ tree, which pip unpacks into the root of site-packages. The folder was left out of the sdist, so reading the source release showed nothing (Chainguard).
Provenance proved the artifacts came from LiteLLM's pipeline. It could not prove the pipeline was building LiteLLM.
Why a .pth file runs without import litellm
Python's site module reads every .pth file in site-packages at interpreter start, and the documentation is explicit: "Lines starting with import (followed by space or tab) are executed." Then it imports sitecustomize if one exists. Both happen before your program's first line, for every interpreter in that environment: your app, pip, pytest, the language server in your editor and every CI step.
You can see the mechanism with a harmless file in a throwaway virtual environment:
$ python3 -m venv /tmp/demo-venv$ echo 'import os; print("ran at startup")' > /tmp/demo-venv/lib/python3.13/site-packages/zz-demo.pth$ /tmp/demo-venv/bin/python -c passran at startupran at startup$ /tmp/demo-venv/bin/python -S -c passIt printed twice because this Linux venv has a lib64 symlink to lib, so the same directory is processed under two names. The -S flag skips the site module entirely, which is what makes it useful for inspecting a suspect environment: nothing in site-packages runs.
In 1.107.0, cook.pth held one line, import cook. cook.py decoded a single zlib and base64 layer into the stealer and ran it on a daemon thread, guarded by a PRIVCOLLECT_ACTIVE environment variable so child processes did not repeat the work. sitecustomize.py forked a detached loader on Linux and macOS. On Debian and Ubuntu system Python, a distribution sitecustomize can shadow the malicious one, but the .pth route still runs (StepSecurity).
What the stealer takes, and where it sends it
It collects anything a developer machine or CI runner tends to hold. From the analyses by StepSecurity, SafeDep and Wiz:
- Cloud: AWS, Azure and gcloud credential files and environment variables, plus tokens from the AWS (IMDSv2 and v1), ECS, GCP and Azure metadata endpoints. Wiz saw stolen AWS role credentials tested with
GetCallerIdentityabout four and a half hours after exfiltration. - Keys and registries: SSH and GPG keys,
.pem,.key,.p12and similar files, Docker, GitHub CLI, npm, PyPI, Maven, Gradle and Cargo credentials, and.envfiles two levels deep. - CI and infrastructure: GitLab Runner, Jenkins, Vault, Terraform and Kubernetes tokens,
/run/secrets, and on Linux the environment of every readable process through/proc/<pid>/environ. - AI tools: credentials for Claude Code and Claude Desktop, Cursor, Copilot, Gemini CLI, Codex and others.
- The LiteLLM proxy database: if
DATABASE_URLis set, it readsLiteLLM_CredentialsTableandLiteLLM_ProxyModelTable, which hold your upstream provider keys. - Browsers and history: saved logins, cookies and history from Chromium-based browsers and Firefox, plus shell and database history.
The haul is packed as a tar.gz, encrypted with AES-256-CBC under a key wrapped with RSA-4096, and posted over plain HTTP to www.litellm.dev/healthcheck with the header X-Token: XWiA8gZNa7NcX2XRoAEb. The marker file /tmp/.pc_once_72129118c59f5636c0bb is written only after the server answers 200, so a missing marker does not mean nothing was sent.
The loader reads a host name from raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txt every hour, downloads http://<host>/codepy and runs it with exec(). The file held only test values, and GitHub removed the account, so researchers saw no second stage. StepSecurity points out the risk that remains: infected hosts keep polling, and anyone who re-registers letters9813 gets code execution on them.
How to check a machine without triggering the payload
Use tools that do not start the affected interpreter. Every python or pip in a poisoned environment runs the stealer again, so the first passes are shell only.
Search for the planted files and markers
check-host.shShell find / -name cook.pth -path '*site-packages*' 2>/dev/nullls -la /tmp/.pc_once_72129118c59f5636c0bb /tmp/remote_code_loader.lock 2>/dev/nulllsof /tmp/remote_code_loader.lock 2>/dev/null # PID of the detached loaderThese commands come from StepSecurity's write-up. In containers, run the same
findwithdocker exec, or against an exported filesystem.Find the bad version in lockfiles and images
check-repos.shShell grep -rniE 'litellm[=<>~! ]*1\.107\.0([^0-9rc]|$)' . \ --include='requirements*.txt' --include='*.lock' --include=pyproject.toml --include='Pipfile*'docker images --digests | grep -E 'berriai/litellm|litellm/litellm'Compare image digests with the lists from LiteLLM and Chainguard; the Docker Hub index digest was
sha256:1bbcb144…c339e.Audit every startup hook with site switched off
audit_startup_hooks.pyPython """Find code that runs at every Python start: executable .pth lines,sitecustomize/usercustomize, and the litellm 1.107.0 payload files. Run it without the site module, so nothing it inspects is executed: python3 -S -I audit_startup_hooks.py /path/to/venv /usr/lib /usr/local/lib"""import hashlibimport osimport pathlibimport sys KNOWN_BAD = { "7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb": "litellm 1.107.0 cook.pth", "9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25": "litellm 1.107.0 cook.py", "34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa": "litellm 1.107.0 sitecustomize.py",}HOOKS = ("sitecustomize.py", "usercustomize.py", "cook.py") def sha256(path): return hashlib.sha256(path.read_bytes()).hexdigest() def site_dirs(root): for dirpath, dirnames, _ in os.walk(root): dirnames[:] = [d for d in dirnames if d not in ("proc", "sys", "dev", ".git", "node_modules")] if os.path.basename(dirpath) in ("site-packages", "dist-packages"): dirnames[:] = [] # do not descend into packages yield pathlib.Path(dirpath) findings = 0for root in sys.argv[1:] or ["."]: for d in site_dirs(root): for f in sorted(d.glob("*.pth")): lines = f.read_text(errors="replace").splitlines() code = [l for l in lines if l.startswith(("import ", "import\t"))] bad = KNOWN_BAD.get(sha256(f), "") if code or bad: findings += 1 print(f"EXEC {f} {code[0][:60]!r} {bad}") for name in HOOKS: f = d / name if f.exists(): findings += 1 print(f"HOOK {f} sha256={sha256(f)[:12]} {KNOWN_BAD.get(sha256(f), '')}") for marker in ("/tmp/.pc_once_72129118c59f5636c0bb", "/tmp/remote_code_loader.lock"): if os.path.exists(marker): findings += 1 print(f"IOC {marker}")print(f"{findings} finding(s)")The script walks the directories you give it, lists every
.pthwith an executable line and everysitecustomizeorusercustomize, and flags the three published payload hashes. Run it with-S -Iso the interpreter skipssite-packagesand ignores your environment variables. Here it is against the demo venv from earlier, with setuptools installed:Audit outputShell $ python3 -S -I audit_startup_hooks.py /tmp/demo-venvEXEC /tmp/demo-venv/lib/python3.13/site-packages/distutils-precedence.pth "import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.en" EXEC /tmp/demo-venv/lib/python3.13/site-packages/zz-demo.pth 'import os; print("ran at startup")' 2 finding(s)
Note the first finding: setuptools ships a legitimate executable .pth, and so do editable installs and some coverage tools. That is why Endor Labs recommends matching on hashes and treating a new executable .pth or sitecustomize.py in a third-party wheel as the signal, not the file type alone.
Clean up and rotate: the order matters
Remove the payload before you run Python again, then rebuild, then assume the secrets are gone. This is the order from LiteLLM's report and StepSecurity:
- Isolate the host or container and preserve logs.
- Kill the process holding
/tmp/remote_code_loader.lock(a detached Python process with its own session and/as working directory). - Delete
cook.pth,cook.py,sitecustomize.pyand their__pycache__entries from every affectedsite-packages, from the shell. - Rebuild the virtual environment or image from scratch with
litellm==1.106.2, pin images by digest, and purge cachedv1.107.0images from mirrors and runners. - Rotate everything the host could reach: cloud keys and role sessions, SSH and GPG keys, registry and publishing tokens, Kubernetes and Vault tokens, CI secrets, AI tool tokens and saved browser passwords.
- On proxy hosts, rotate every provider key stored in the database and in model
litellm_params, the master and salt keys, virtual keys and theDATABASE_URLpassword. - Search CloudTrail and equivalents for
GetCallerIdentityfrom instance or task roles near the install time, and for those credentials used from outside your network; blocklitellm.devat DNS and egress.
Stop the next one: hashes, cooldowns and provenance checks
Three controls would each have kept 1.107.0 out of a build, and none depends on the publisher getting everything right.
Pin with hashes. In pip's hash-checking mode, "hashes are required for all requirements" and every requirement must be pinned with == (pip docs). A lockfile made on 20 October could not resolve to a release published on the 21st. uv lock records hashes by default, and pip-compile --generate-hashes writes them into requirements.txt.
Add a dependency cooldown. uv can ignore anything published more recently than a rolling window (uv docs). A one-week cooldown would have skipped 1.107.0 entirely; it was pulled from PyPI within two hours.
[tool.uv]exclude-newer = "1 week"# tighten it for packages you treat as high riskexclude-newer-package = { litellm = "14 days" }Check who built it, not only that someone did. Every v1.107.0 image had valid provenance naming Berri-AI/litellm at commit 20535d8f (StepSecurity). A policy that requires the expected repository owner, not just a valid signature, would have rejected it. Pin images by digest so a tag cannot move under you.
Questions people ask about the LiteLLM compromise
Which LiteLLM version is compromised?
Only litellm==1.107.0 on PyPI, all seven wheels, and the v1.107.0 images, plus latest, main-latest and main-stable while they pointed at it on 21 October 2026. 1.106.2 is the last clean release.
Is LiteLLM safe to use now?
The maintainers say every earlier version is safe and the bad artifacts are removed from PyPI, GHCR and Docker Hub. Pin an explicit version with hashes and pin images by digest rather than trusting latest.
I installed 1.107.0 but never imported litellm. Am I affected?
Yes. The payload runs from cook.pth and sitecustomize.py at every Python start in that environment, including pip itself, so installing it was enough.
How do I check for the backdoor without running it?
Use shell tools such as find, lsof and sha256sum, or run an inspection script with python3 -S -I, which skips site-packages. Look for cook.pth, cook.py, sitecustomize.py, /tmp/.pc_once_72129118c59f5636c0bb and /tmp/remote_code_loader.lock.
Does the missing marker file mean my secrets were not stolen?
No. The marker is written only after a successful upload, and LiteLLM says a missing marker does not prove the host is clean. Rotate if the bad version ran with outbound network access.
Were LiteLLM Docker images affected?
Yes: ghcr.io/berriai/litellm, litellm-database, litellm-non_root and docker.io/litellm/litellm at v1.107.0, and the floating tags while they pointed there, from 11:46:56 UTC until 13:51 (GHCR) and 14:20 (Docker Hub).
How did the attacker publish through LiteLLM's signed pipeline?
With three developers' stolen GitHub CLI tokens they overrode branch protection, repointed the release workflow at a look-alike repository and passed the approval gate, so the real pipeline signed the malicious build.
Sources and further reading
- LiteLLM: Contained: Oct 21, 2026 LiteLLM release v1.107.0 Compromised
- StepSecurity: litellm 1.107.0 compromised on PyPI
- SafeDep: Compromised LiteLLM 1.107.0 installs a credential stealer and backdoor
- Wiz: LiteLLM compromised, steals credentials
- Chainguard: LiteLLM attacked again
- Endor Labs: Python startup hooks steal cloud, AI and proxy credentials
- Python documentation: site, site-specific configuration hook
- pip documentation: Secure installs and uv documentation: dependency cooldowns
Discussion
Join the discussion
Sign in with Google, Apple or an email code to comment and reply.