A Lookalike Repo and Three Stolen OAuth Tokens Shipped a Credential Stealer to Every LiteLLM Gateway

LiteLLM v1.107.0 was live on PyPI for 1h43m and on Docker for 2h33m. The payload was a .pth hook that ran before your application did — and one of its queries reads your entire LLM credential table.

On October 21, 2026, LiteLLM v1.107.0 was published to PyPI at 11:39:51 UTC and pulled at 13:23 UTC. All seven wheels were malicious. The compromised Docker tags — v1.107.0, latest, main-latest, and main-stable — stayed live longer, from 11:46:56 to 14:20 UTC on Docker Hub, and until 13:51 UTC on GHCR.

The window on PyPI was 1 hour 43 minutes. On Docker it was 2 hours 33 minutes.

LiteLLM is the open-source proxy most AI platform teams put in front of their model providers: one endpoint, one key, many upstream vendors, centralized spend tracking. Which makes the interesting part of this incident not the malware. It’s what the malware was built to read.

The root cause is a boring one, and that’s the point

LiteLLM’s own incident report attributes the attack to three engineers’ GitHub accounts, accessed via stolen GitHub CLI OAuth tokens. One account triggered the project-releaser workflow run. A second account approved it. The tokens have been revoked.

The malicious code did not come from the legitimate repository. Wiz traced the payload to github.com/Berri-AI/litellm — a hyphenated lookalike of the real BerriAI/litellm. Between 08:56 and 10:46 UTC the attacker pushed ten commits under the author email ckdi82fkoos@proton.me. The first three added the persistence files. Four more bumped the version string to 1.107.0. After a 39-minute gap, two final commits refined the payload and added a data = "cook" directive to pyproject.toml — the instruction that told the build system to include it.

LiteLLM states it plainly in their incident report:

The official v1.107.0 source tag in GitHub does not contain the malicious files. The compromised artifacts were built from an attacker-controlled repository.

So: the code you reviewed was not the code you shipped. The provenance of the artifact changed underneath the source tree.

A .pth file runs before your application does

The payload installs three files directly into the site-packages root on every platform: cook.pth, cook.py, and sitecustomize.py.

cook.pth contains an instruction to import cook. Python’s interpreter processes .pth files at startup, in site initialization — before your entrypoint, before your framework, before your app has a chance to log a line. cook.py deobfuscates itself, runs a stealer routine internally named privcollect, and writes a marker file to /tmp/.pc_once_72129118c59f5636c0bb.

This is the part that breaks normal reasoning. You do not have to use LiteLLM for this to execute. Any Python process that starts on a host where the package was installed runs the hook — a cron job, a Jupyter kernel, a CI step, a python -c one-liner. Wiz identifies the combination of .pth plus sitecustomize.py as, to their knowledge, the first documented in-the-wild use of both techniques simultaneously.

The second stage is a remote-code loader. sitecustomize.py fetches a file called domain.txt from a GitHub dead-drop repository, then downloads and executes a Python script from a /codepy endpoint at whatever address that file returns. As of analysis the dead drop was never populated with a live address and the repository has since been taken down. Wiz was explicit about the distinction: capability, not confirmed exploitation. Treat the RCE path as unconfirmed and the credential theft as confirmed.

The stealer reads your gateway’s credential database

privcollect harvests across 16 modules. Browser credentials and cookies from Chrome, Firefox, Edge, and Safari. AWS credentials from ~/.aws/, environment variables, and EC2/ECS instance metadata. GCP service account keys and application default credentials. Azure tokens from the CLI cache. SSH private keys, GPG keys, shell history. And AI provider keys for OpenAI, Anthropic, and Cohere from environment variables and config files.

Then the part specific to this package: LiteLLM Proxy stores every upstream provider key in Postgres. The stealer queries that table directly — SELECT * FROM "LiteLLM_CredentialsTable" LIMIT 500.

If you run a LiteLLM proxy, that query is the whole attack. You do not need a single one of your own application’s secrets compromised, because the aggregator you built specifically to hold all your model-provider credentials in one place handed them over as a single read.

Harvested data is encrypted with AES-256-CBC, the key wrapped with RSA-4096, and exfiltrated over HTTP POST to www.litellm[.]dev/healthcheck — a domain chosen to look like legitimate LiteLLM infrastructure, which is what makes the outbound traffic easy to wave through a proxy log.

“We’re on latest” was the failure mode

The compromised image index was sha256:1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e, and it was reachable under four tags across two registries:

  • ghcr.io/berriai/litellm — sha256:1bbcb1443bc9…
  • ghcr.io/berriai/litellm-database — sha256:c00f9d92c427…
  • ghcr.io/berriai/litellm-non_root — sha256:7b00e91cd2fe…
  • Docker Hub equivalents of v1.107.0, latest, main-latest, main-stable

Teams running image: latest were pulling the payload. Teams pinning a digest were not. There is no version-bump discipline that catches this, because a moving tag is not a version. The remediation LiteLLM recommends — pin to 1.106.2 or any explicit prior release — is the same instruction applied to images: pin the digest.

The release candidates were clean, and it didn’t matter

safedep reports that 1.107.0rc1 and 1.107.0rc2 were unaffected. The promoted final was poisoned.

A staging-pipeline process that tests release candidates before promoting them to final would have passed cleanly, because that is exactly what happened here: the candidates were real and the final was built from a different repository. The artifact’s provenance changed at the last step, not the code.

This is why “we gated it on RC” is not a supply-chain control. It is a functional-testing control that happens to sit near the publish step. Provenance checks — signed attestations, build isolation, verifying that the published artifact corresponds to a reviewed commit in the reviewed repository — are the controls that would have caught this. None of them exist in most pip install flows today.

Recovery is not an upgrade, and this is the detail everyone gets wrong

LiteLLM’s remediation guidance leads with a warning that is worth repeating verbatim, because the intuitive first action re-triggers the payload:

Don’t start with pip install. Any Python start in the affected environment runs cook.pth and sitecustomize.py again, and that includes running pip.

The actual sequence: from a shell, delete cook.pth, cook.py, sitecustomize.py and their __pycache__ artifacts from every affected site-packages. Kill the detached Python daemon holding /tmp/remote_code_loader.lock. Reinstall pinned to 1.106.2 — or better, rebuild the venv and images from scratch. If 1.107.0 was ever installed on a host with outbound internet, assume the exfiltration succeeded; the presence of the marker file confirms it, its absence does not prove the host is clean.

Then rotate, from a machine you trust: cloud keys, SSH keys, repository tokens, browser sessions, and every AI provider key. Replacing the package is not remediation when the stolen credentials still work somewhere else.

What this actually costs you

Three things, in order of how long they take to notice.

The exposure window was short and detection was fast — Socket’s scanner flagged all seven wheels within seven minutes of publication, and JFrog Security Research surfaced it publicly. The response side was not fast in a way that reduces exposure: a compromised window that any automated dependency updater can pull from, across a package most teams install without review.

The blast radius is the credential store, not the package. LiteLLM’s response was to remove all but one user from their GitHub organization as a temporary mitigation, because they could not yet establish how the OAuth tokens were acquired. That is the honest posture: a publishing identity with org-level reach is an attack surface with no clean revocation path.

And this is the second LiteLLM supply-chain compromise reported this year, following earlier versions 1.82.7 and 1.82.8. Same package, same class, different entry point.

The operational takeaway for anyone running an LLM proxy: the proxy is not a convenience layer. It is a credential aggregator with an internet-facing blast radius, and the package you install to get it is executed by the interpreter before your code ever gets a turn. Treat its publishing pipeline, its OAuth scopes, and its container tags as production infrastructure, because today it is — and it was compromised by three stolen tokens and a hyphen.

The incident page is at docs.litellm.ai. Technical analysis is at Wiz and Socket, with the upstream discussion in BerriAI/litellm issue #47826.