Developer tooling

LiteLLM 1.107.0 compromised: how the .pth backdoor works, and how to check and clean up

LiteLLM 1.107.0 ran a credential stealer at every Python start. How the attack worked, how to check hosts safely, and what to rotate.

Kemo
11 min read
Rows of pale grey industrial pipes with bolted flanges bending down a concrete wall.
On this page
  1. What happened to LiteLLM 1.107.0?
  2. Am I affected? Check the version and the time window
  3. How the release pipeline was hijacked
  4. Why a .pth file runs without import litellm
  5. What the stealer takes, and where it sends it
  6. How to check a machine without triggering the payload
  7. Clean up and rotate: the order matters
  8. Stop the next one: hashes, cooldowns and provenance checks
  9. Questions people ask about the LiteLLM compromise
  10. Sources and further reading

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 versionlitellm==1.107.0: all seven wheels; the sdist has a tampered pyproject.toml but no payload
Bad imagesv1.107.0, latest, main-latest, main-stable on ghcr.io/berriai/litellm, litellm-database, litellm-non_root and docker.io/litellm/litellm
Last clean release1.106.2 (1.107.0rc1 and rc2 are also clean)
Files plantedcook.pth, cook.py, sitecustomize.py in the root of site-packages
ExfiltrationPOST http://www.litellm.dev/healthcheck (a look-alike domain registered 13 October)
Downloads while liveAbout 49,000, per Wiz citing ClickPy
LiteLLM 1.107.0 at a glance, from the maintainers' incident report and StepSecurity's analysis

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.

How long each poisoned artifact was availableMinutes between publication and removal on 21 October 2026
PyPI wheels103 minGHCR images124 minDocker Hub images153 min

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

How long each poisoned artifact was available
Minutes available
PyPI wheels103 min
GHCR images124 min
Docker Hub images153 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).

LiteLLM's release infrastructureExposed internal proxygh tokens in logsRelease workflowbranch protection overriddenBerri-AI/litellmlook-alike repoOfficial pipelinebuilds, signs, publishesPyPI1.107.0 wheelsGHCR, Docker Hubv1.107.0, latestYour hostcook.pth runs on startwww.litellm.devexfiltration
  1. Exposed internal proxy → Release workflowowner token
  2. Release workflow → Berri-AI/litellmsource repointed
  3. PyPI → Your hostpip install
  4. GHCR, Docker Hub → Your hostdocker pull
  5. Your host → www.litellm.devencrypted tar.gz
The attack chain: real pipeline, fake source. Every artifact carried valid signatures and provenance.

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:

A .pth line runs on every start; -S turns it offShell
$ 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 pass

It 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 GetCallerIdentity about four and a half hours after exfiltration.
  • Keys and registries: SSH and GPG keys, .pem, .key, .p12 and similar files, Docker, GitHub CLI, npm, PyPI, Maven, Gradle and Cargo credentials, and .env files 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_URL is set, it reads LiteLLM_CredentialsTable and LiteLLM_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.

  1. 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 loader

    These commands come from StepSecurity's write-up. In containers, run the same find with docker exec, or against an exported filesystem.

  2. 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.

  3. 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 .pth with an executable line and every sitecustomize or usercustomize, and flags the three published payload hashes. Run it with -S -I so the interpreter skips site-packages and 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:

  1. Isolate the host or container and preserve logs.
  2. Kill the process holding /tmp/remote_code_loader.lock (a detached Python process with its own session and / as working directory).
  3. Delete cook.pth, cook.py, sitecustomize.py and their __pycache__ entries from every affected site-packages, from the shell.
  4. Rebuild the virtual environment or image from scratch with litellm==1.106.2, pin images by digest, and purge cached v1.107.0 images from mirrors and runners.
  5. 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.
  6. On proxy hosts, rotate every provider key stored in the database and in model litellm_params, the master and salt keys, virtual keys and the DATABASE_URL password.
  7. Search CloudTrail and equivalents for GetCallerIdentity from instance or task roles near the install time, and for those credentials used from outside your network; block litellm.dev at 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.

pyproject.tomlText
[tool.uv]exclude-newer = "1 week"# tighten it for packages you treat as high riskexclude-newer-package = { litellm = "14 days" }
Durations are fixed seconds; uv does not update the lockfile just because time has passed.

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

Discussion

Join the discussion

Sign in with Google, Apple or an email code to comment and reply.

One practical article a week

Follow k13 in any RSS reader. No account needed.