On October 21 at 11:39 UTC, seven wheels of litellm 1.107.0 landed on PyPI with a credential stealer inside. They came out of LiteLLM's own release pipeline, signed, with valid build provenance. PyPI pulled them at 13:23. Docker Hub kept serving the infected image under the latest tag until 14:20. About 49,000 installs happened in between, by Wiz's count.

LiteLLM is the proxy a lot of teams put in front of OpenAI, Anthropic, Bedrock and everything else so their apps talk to one API. Socket puts it at about 23 million downloads a week. It holds provider keys by design. That is why someone went to this much trouble to poison it.

One line in pyproject.toml

The git tag for v1.107.0 is clean. If you had diffed the tag against 1.106.2 you would have found nothing wrong. The release was not built from that tag.

It was built from a repository named Berri-AI/litellm. The real one is BerriAI/litellm. One hyphen. The attacker pushed ten commits there between 08:56 and 10:46 UTC, nine of them carrying GitHub's "Verified" badge because they went through the web editor. The commits added a folder named cook with three files and one new line in the maturin section of pyproject.toml: data = "cook". That setting tells the build to copy the folder into the wheel's site-packages root. The source tarball got the same line but not the folder, so anyone inspecting the sdist saw a config change of one line and no payload.

The three files are the whole attack. cook.pth is twelve bytes and says import cook. Python runs every .pth file in site-packages at interpreter startup, before your code, before pip, before anything. So the stealer ran whether or not you ever imported litellm. Running pip to uninstall it ran it again. sitecustomize.py is the second hook, another file Python loads on startup, and it carries the backdoor. Wiz says this is the first case it has documented of both startup hooks used together in the wild.

The payload calls itself privcollect, and the name is accurate. Socket's teardown lists sixteen modules. SSH, GPG and TLS private keys. AWS, GCP and Azure credentials, plus Kubernetes and Docker configs. GitHub CLI tokens, npm and PyPI tokens, .netrc and .env files. Chromium and Firefox password databases. Shell history. The environment of every other readable process on the box, via /proc. The AWS instance metadata service, v2 then v1, for the role credentials. And the config files of more than twenty AI coding tools, by path: ~/.codex/auth.json, ~/.claude/.credentials.json, ~/.gemini/oauth_creds.json, ~/.cursor/mcp.json.

On a LiteLLM proxy host it did one more thing. If DATABASE_URL was set, it queried the LiteLLM_CredentialsTable and pulled up to 500 rows. That table is where the proxy stores your upstream provider keys. One compromise of the gateway hands over every key behind it.

Everything got tarred, encrypted with AES-256, the key wrapped in an embedded RSA-4096 public key, and posted over plain HTTP to www.litellm.dev/healthcheck. The real project lives at litellm.ai. The attacker registered litellm.dev on October 13, eight days before the release, and created the GitHub account for the backdoor's dead drop six hours after that.

The backdoor forked, called setsid, detached to PID 1, and once an hour fetched a domain.txt from that GitHub account, then downloaded and exec'd whatever sat at /codepy on the domain it named. GitHub killed the account around 13:30 UTC and the file was never populated with a live domain, so as far as anyone can tell the second stage never fired. Wiz did watch the first stage pay off. Between 16:20 and 17:01 UTC the attacker validated stolen AWS instance role credentials from two IPs and called bedrock:ListFoundationModels. That is the LLMjacking playbook: steal cloud credentials, find the accounts with Bedrock access, resell the inference.

The tokens came out of LiteLLM's own logs

The attacker did not phish anyone or steal a PyPI token. They held GitHub CLI OAuth tokens for three LiteLLM engineers, and LiteLLM's incident post says those came from its own internal LiteLLM deployment, which was exposed to the public internet and was logging response bodies.

LiteLLM's explanation for how GitHub tokens end up in LLM response bodies: it says it observed cases where Claude Code embeds a user's GitHub CLI OAuth token in a response. If you log full response bodies on the proxy, the token is now in your logs. An unexpected user account with an innocuous name showed up in that deployment. LiteLLM suspects entry through a recently patched vulnerability but has not confirmed it.

From there the chain shows what layered controls are worth when one credential can override them all. At 10:08:29 UTC, branch protection rejected the attacker's change to the private release workflow. Five seconds later an organization owner's token overrode branch protection and pushed it anyway. At 10:46 the modified workflow built from the lookalike repo. At 11:11 a second engineer's credential satisfied the production approval gate. At 11:53 a third engineer's credential deleted the staging and production workflow run records. Three engineers' identities, zero engineers involved.

LiteLLM also notes that release permissions had been temporarily widened that Monday so two engineers could publish backports. The attacker either knew or got lucky.

Provenance proved the wrong thing

LiteLLM got hit in March too. TeamPCP used a stolen PyPI token to publish malicious 1.82.7 and 1.82.8, and Hudson Rock later tied the 153 GB haul from that attack to 2,488 corporate domains. After March the maintainers moved to a private repo, PyPI Trusted Publishing, immutable tags and image signing. All of that worked as designed on October 21. The SLSA provenance on the poisoned wheels named BerriAI's real release workflow, because that is what built them. The signatures were valid because the pipeline signed them.

Trusted Publishing proves the artifact came from the pipeline. It says nothing about what the pipeline was pointed at. When the attacker can edit the workflow file, the workflow is the attacker, and provenance becomes a notarized record that it ran.

Detection did its job. Socket's scanner flagged the wheels at 11:46, seven minutes after upload. Chainguard's flagged them at 11:55. Lidor Machluf at Upwind opened the public issue on BerriAI's tracker at 12:06, having traced the Docker image's build provenance back to the hyphenated repo. The maintainers had the workflow restored by 12:24. The slow parts were the registries: 97 minutes for PyPI to remove the release, and Docker Hub still pointed latest at the infected digest for half an hour after GHCR was clean.

Railway published its own security disclosure the same day, saying an unauthorized party may have decrypted customer environment variables and that it believes this is "part of a series of recent attacks on platform companies aimed at compromising AI inference providers." Nobody has publicly connected the two incidents. The target set matches.

Cleanup order

If any host of yours installed litellm 1.107.0, or pulled the latest, main-latest or main-stable image between 11:46 and 14:20 UTC on Tuesday, the order of operations matters.

  1. Do not run pip first. Every Python start reruns the stealer. Delete cook.pth, cook.py, sitecustomize.py and their .pyc files from every site-packages directory on the box.
  2. Kill the detached Python process holding /tmp/remote_code_loader.lock. If someone re-registers the deleted GitHub account and repopulates domain.txt, every daemon still running will execute what they serve.
  3. Throw the environment away. Rebuild the venv or image from scratch and pin litellm==1.106.2, by digest for containers.
  4. Rotate everything the process could read: the instance IAM role, every provider key and virtual key in the proxy database, the master key, the database password, SSH keys, and the tokens for every AI coding tool on any developer laptop that ran it.
  5. Check CloudTrail for GetCallerIdentity from the instance role around install time, and egress logs for litellm.dev and raw.githubusercontent.com/letters9813.

The marker file the stealer writes after a successful upload, /tmp/.pc_once_72129118c59f5636c0bb, proves exfiltration if present. Its absence proves nothing.

Two changes for everyone else. Stop logging full response bodies on anything that proxies LLM traffic. The bodies contain whatever the model and the tools around it put there, and in this case that included OAuth tokens. And stop deploying from floating tags. A team that pinned litellm/litellm by digest never pulled this image. A team on latest pulled it on the next restart without touching a version number.

LiteLLM will patch its workflow and tighten its GitHub org, and it will get hit a third time, because a package that holds everyone's provider keys and ships to 23 million installs a week is the best target on PyPI right now. The fix on your side is to assume that and build so a bad release costs you a rebuild and nothing else.