On Tuesday 21 October, litellm==1.107.0 reached PyPI at 11:39 UTC with a credential stealer in all seven wheels, and the Docker images tagged v1.107.0, latest, main-latest and main-stable followed at 11:46. PyPI removed the release at 13:23, the GHCR tags were gone by 13:51 and Docker Hub by 14:20. Wiz counts about 49,000 downloads. The three files it dropped into site-packages collect SSH and GPG keys, cloud credentials, browser passwords, the credential files of Claude Code, Codex, Cursor and Gemini CLI, every environment variable the process can read, and, when DATABASE_URL is set, the provider keys stored in the LiteLLM proxy's own database. By 16:20 UTC the attacker was testing stolen AWS instance-role credentials against Bedrock.

The release was not forged. LiteLLM's own release workflow built it, signed it and published it, from a lookalike repository called Berri-AI/litellm, one hyphen away from the real one. The signature is still in Sigstore's public transparency log: an entry made at 11:47:52 UTC over the malicious image digest, with the same key LiteLLM's release notes told you to verify against, and it still checks out with openssl today. A cosign verify pinned to that key passed the poisoned image. The one field that named the wrong repository was the build provenance, which cosign verify does not read. LiteLLM rotated the key on Thursday, and the four patch releases it pushed this morning warn that the previous key no longer matches.

The payload also did not wait for import litellm. A twelve-byte .pth file in site-packages runs when the interpreter starts, so the stealer ran whenever any Python in that environment started: the app, a test, a cron job, pip. I rebuilt the mechanism with a harmless hook: python -I, the isolation flag, runs it, and only -S does not. pip uninstall does remove the three files, because the wheel lists them in its RECORD, but the uninstall is itself a Python start, so it runs the stealer once more on the way out, and the detached loader that sitecustomize.py forked keeps running. Python 3.15, released twelve days before the attack, deprecated executable .pth lines. It still executes them.

Before your first line of code

The whole packaging change is one line. Commit 783c43bc, pushed to the lookalike repository at 09:48 UTC by an author named Berri-AI with a Proton Mail address, adds data = "cook" under [tool.maturin] in pyproject.toml. Maturin copies a directory of that name into the wheel's .data section, and anything under cook/purelib/ installs into the root of site-packages. The v1.107.0 tag in the real repository never had the line or the directory. The commit is still readable through the real repository by SHA, because a fork shares objects with its parent, even though github.com/Berri-AI now returns 404.

cook.pth is the twelve bytes import cook. The site module's documentation says lines starting with import "are executed with exec()," and that after the path files Python tries to import a module named sitecustomize. The attacker shipped both. cook.py decodes and runs a 1,237-line collector with sixteen modules. sitecustomize.py runs a second copy of it and a loader.

To see the startup behaviour without the malware, I built a wheel with a .data/purelib/demo_hook.pth that imports a module which appends a line to a log, installed it in a fresh Python 3.12.8 venv with pip 24.3.1, and started the interpreter five ways. Plain python -c pass wrote a line. So did python -I, python -s, python -E and pip --version, because pip is a Python program running in that venv. Only python -S, which skips the site module entirely, wrote nothing. -I protects you from the current directory, environment variables and the user site directory. It does not touch the venv's own site-packages, which is where this lived.

A docker pull does not trigger it. Nothing in the image runs until a Python process starts from it, and then every one does.

Python 3.15, released on 9 October, says import lines in .pth files "are silently deprecated" and introduces .start files as the auditable replacement. During the deprecation they still execute, and a diagnostic appears only under -v. The images ship Python 3.13 anyway.

Uninstalling runs it once more

LiteLLM's incident post opens its cleanup with "don't run pip install first," because any Python start re-runs both files, and pip is one. Endor Labs goes further and says removing the package leaves the files in place. In my reproduction it did not: pip uninstall -y demo-pth deleted the .pth, the module and the compiled .pyc, because pip removes whatever the installed RECORD lists, and the real wheel's RECORD lists cook.pth, cook.py and sitecustomize.py with valid hashes. My log also shows the hook firing exactly once during the uninstall. Removal works. It costs one more collection run.

Uninstall removes files, not processes. sitecustomize.py forks, calls setsid(), takes a lock on /tmp/remote_code_loader.lock and loops every 3,600 seconds: fetch domain.txt from a GitHub repository under the account letters9813, download http://<domain>/codepy, run it with exec(), no signature or hash check. The account returned 404 by 13:40 UTC on the 21st, so the loader currently finds nothing. A host that polled while it was live may have run a second stage nobody has recovered. The collector writes /tmp/.pc_once_72129118c59f5636c0bb only after a successful upload, so a missing marker proves nothing either.

LiteLLM's order is: delete the three files and their __pycache__ entries from a shell, kill the process holding the lock, rebuild the venv or image on 1.106.2, then rotate the instance role, every provider key in the proxy database, the master and virtual keys and the DATABASE_URL password.

The signature was real

After the March incident, LiteLLM moved releases to a private repository, published through Trusted Publishers, made Docker tags immutable and started signing images. Publishing was approved by an account "locked to a specific laptop physically located within our office." On Monday 19 October it relaxed that so any two engineers could trigger and approve a release, to ship daily bugfix backports.

The attacker held the GitHub CLI OAuth tokens of three engineers, one of them an organization owner. At 10:08:29 UTC branch protection rejected a push to the release workflow. Five seconds later the owner's token disabled protection and force-pushed it. At 10:46:39 the workflow was dispatched with its source set to Berri-AI/litellm at commit 20535d8f, at 11:11:18 the production gate was approved with the second token, wheels went to PyPI at 11:39:51 and images at 11:46:56, and at 11:53:49 the third token deleted the run records. The pipeline did what it does: built, signed, published.

The signature artifact is gone from GHCR along with the image, but Rekor, Sigstore's transparency log, is append-only. I rebuilt the payload cosign signs, the reference ghcr.io/berriai/litellm plus the digest sha256:1bbcb144..., hashed it, and asked Rekor for that hash. Entry 3355443881, integrated at 11:47:52 UTC on 21 October, is signed with a key whose SHA-256 is ff8869bf...: the cosign.pub at commit 0112e53, the one the v1.106.2 release notes pin. openssl dgst -sha256 -verify with that key over the rebuilt payload returns Verified OK. Anyone running cosign verify --key against that commit's key on 21 October got a pass on the stealer.

What the signature did not say is who built it. The SLSA provenance attached to every v1.107.0 image records https://github.com/Berri-AI/litellm as the source. A policy that checks the provenance's source repository would have failed it. A digest pin would not: 1.106.2 users were untouched, latest and main-stable users got the stealer for up to two and a half hours, and anyone who pinned 1bbcb144... pinned the attacker's build.

On Thursday evening LiteLLM merged a new cosign.pub at commit e82a3c6. This morning's 1.106.3, 1.105.4, 1.104.6 and 1.103.8 are signed with it, and their release notes say verifying with the previous key "will fail" and tell you to update admission-policy pins. I checked: the 1.106.3 signature verifies with the new key and fails with the old, and latest and main-stable now resolve to 1.106.3, while main-latest still points at 1.106.2.

Where the token came from

The 21 October status updates said Claude Code "will embed a user's GitHub CLI OAuth token into a response body," that an internet-exposed internal deployment logged response bodies, and that the tokens came from those logs. Two days later LiteLLM wrote that some of those assumptions "were later discovered to be inaccurate or incomplete" and published the attack chain.

On 27 May a configuration file committed to the public LiteLLM-Labs/lite-harness repository "inadvertently contained the master key to a deployment of the LiteLLM Agent Platform." The commit was force-pushed over and the exposed keys were flagged for revocation. "However, key revocation did not occur for the master key." On 10 October, at 21:27:10 UTC, that key called GET /api/v1/managed_agents/agents?limit=100, which "returned the configuration of every managed agent." One configuration held the GitHub CLI OAuth token of one of the two organization owners, "not associated with a service account and was not scoped down." Forty-four minutes later a workflow on a throwaway branch of BerriAI/litellm posted six repository secrets to webhook.site, one of them a second maintainer's token, and a workflow on BerriAI/litellm-auto posted a third. Both runs were deleted within two minutes.

A gateway whose pitch is keeping every provider key in one place lost its own keys through one place: a list endpoint that returned a stored OAuth token in plaintext, opened with a master key that had been flagged for revocation in May and never revoked.