All articles
AI

LiteLLM Supply Chain Attack: How a Clean Repo Shipped a Credential Stealer

LiteLLM supply chain attack, reconstructed: how 1.107.0 wheels and Docker tags shipped a stealer, a Beijing-time timeline, what it took, and how to clean up.

A sealed parcel on a pale blue conveyor with a small unlabeled envelope tucked under its tape, illustrating the LiteLLM supply chain attack: clean source, poisoned release
Also available in中文Español

At some point between 19:40 and about 21:12 Beijing time on October 21, 2026 (11:40–13:12 UTC), a lot of people typed pip install -U litellm and got version 1.107.0. Nothing looked wrong. The GitHub tag was clean, the release notes were normal, and the new version imported fine. But every one of its seven wheels carried a credential stealer that runs whenever Python starts, plus a loader built to fetch and run more code later. This LiteLLM supply chain attack is worth understanding properly, because it beat the one check most of us rely on: “I looked at the source, and it’s fine.”

I spent the evening going through the official GitHub issue #47826 and every comment on it, AISLE’s analysis, JFrog’s write-up, Aikido’s, the PyPI and Docker registries, and the attacker’s commits themselves. Below is what happened, in order, with sources. Where something isn’t known yet, I say so. If you only want the fix, jump to the self-check. The shorter version also lives in our Claude Code errors guide.

The short version

  • What shipped: litellm==1.107.0 on PyPI (all 7 wheels) and the official Docker images v1.107.0, latest, main-latest and main-stable on Docker Hub and GHCR. Source: issue #47826.
  • What it does: drops cook.pth, cook.py and sitecustomize.py into site-packages. The stealer runs on every Python start, even if nothing imports litellm, and sends an encrypted bundle to www.litellm.dev. The real project domain is litellm.ai.
  • Status at 23:18 Beijing time: PyPI shows 1.107.0 as quarantined and the latest version is 1.106.2. The maintainers said at 22:32 that the malicious Docker images were removed. The tags are gone, but in our test Docker Hub still served the poisoned image by digest (details below).
  • Still unknown: how the attacker got publishing access. The maintainer’s words: “We have not yet identified or locked down the method.”

Why the clean GitHub source didn’t protect anyone

Most people treat “the repo is clean” and “the package is clean” as the same thing. They aren’t. What you install is a built artifact (a wheel or an image) that somebody’s pipeline produced from some source tree. In this LiteLLM supply chain attack, the v1.107.0 tag on BerriAI/litellm really is clean. AISLE says its AI review “found no issues in LiteLLM source code”, and Upwind, the team that filed the issue, confirms the payload is not in the tag.

The difference sits in one line of the sdist’s pyproject.toml that isn’t in the tag: data = "cook" under [tool.maturin]. That tells maturin, the Rust/Python build tool LiteLLM uses, to copy a cook/ directory into the wheel’s data section. Files there land in the root of site-packages. The cook/ directory isn’t in the sdist or on any branch. JFrog notes that the payload files have the same ZIP timestamps and metadata as the legitimate files, which means they went in during a normal build, not through hand-repacking afterwards. So reading the tag, or even unpacking the sdist, would not have shown you the payload. You had to open the wheel.

Minute by minute: the LiteLLM supply chain attack timeline

All times are Beijing time (UTC+8), with UTC in brackets. The attacker-side times come from Upwind’s analysis of the commits and image provenance in its first update. The rest come from each source’s own timestamps.

LiteLLM supply chain attack timeline in Beijing time, from the attacker's commits at 16:56 to the 23:08 registry check
Timeline built from issue #47826 (Upwind), AISLE, PyPI, Docker Hub and the GitHub API. Beijing time (UTC+8), UTC in brackets.
  • Oct 13, 08:45 (00:45 UTC): litellm.dev is registered. Upwind lists the GitHub dead-drop account letters9813 as created the same day; AISLE says the dead-drop repo appeared on Oct 18. The setup started more than a week early.
  • 16:56–17:48 (08:56–09:48): nine commits authored as Berri-AI <[email protected]> add the three payload files, set the version back to 1.107.0 and add data = "cook". They were made in GitHub’s web editor, so GitHub marks them “Verified”. That badge only means they were made on github.com. You can browse the full diff; the commits sit in the fork network, on no branch or tag.
  • 18:46:29 (10:46:29): the tenth commit, 20535d8f, adds COPY cook ./cook to three Dockerfiles. I checked it through the GitHub API: it’s the only one of the ten that is unsigned (verified: false), and its message quotes a build failure word for word: “The gateway/backend/migrations builds failed with: maturin failed: No such data directory /app/cook”.
  • 18:51:23 (10:51:23): five minutes later, the official release tool BerriAI/project-releaser starts run 39587059944. Its provenance names the source as github.com/Berri-AI/litellm at 20535d8f (Upwind).
  • 19:39:51–19:40:13 (11:39:51–11:40:13): eight files land on PyPI (AISLE).
  • 19:43:05: AISLE’s dependency firewall flags the first wheel, 3 minutes 14 seconds after upload. That was internal; nothing went public yet.
  • ~19:48: on Docker Hub, v1.107.0, latest, main-latest and main-stable now point at the poisoned image.
  • 19:54:18: @UpwindMDR posts on X about a “potential supply chain compromise”, naming cook.pth, sitecustomize.py and hourly C2 polling. As far as I can find, it’s the first public warning: 14 minutes after the upload and 12 minutes before the GitHub issue.
  • 20:06:41 (12:06:41): Upwind researcher LidorMachluf opens issue #47826 with every file hash, the payload breakdown and cleanup steps. JFrog’s alert on X follows at 20:22; when I checked at about 23:09 it showed 68,417 views.
  • 20:42:54: in a reminder of how quickly bad versions travel, the RAG project VectifyAI/PageIndex merges PR #619, raising its floor to litellm>=1.107.0 to pick up a fix. It’s reverted in PR #620 at 21:11:12, titled “1.107.0 was compromised”. For 29 minutes, a fresh install from that main branch could only resolve to the poisoned release.
  • 20:57: Upwind’s update: the Docker images are infected too, and every v1.107.0 image’s build provenance points to the lookalike repo.
  • No later than ~21:12: PyPI quarantines the release. Nobody has published the exact time. The earliest public mention I found is an X post at 21:12:09, and a user’s PyPI screenshot posted at 21:17 already shows “RELEASE QUARANTINED”. AISLE says PyPI reached out “shortly after our report”.
  • 21:50:33: maintainer ojensen-berri, first official reply: “treating this as SEV-0. PyPI quarantined 1.107.0.”
  • 22:32:11 (14:32:11): second reply: “The malicious docker images have been removed.” OSS users should pin 1.106.2. The enterprise private registry is not affected. Next update at 16:30 UTC, which is 00:30 Beijing time on Oct 22.
  • 23:08 and 23:18: our own registry tests (next section); the second one, just before publishing, gave the same result.
GitHub issue #47826 header and the maintainer's 22:32 Beijing time comment that the malicious Docker images were removed, LiteLLM supply chain attack
Source: GitHub issue #47826, BerriAI/litellm. Captured Oct 21, 2026, 23:00 Beijing time (15:00 UTC). “28m ago” on the comment means 22:32.

“Removed” means the tags. Docker Hub still had the image

This part of the LiteLLM supply chain attack is easy to miss. At 23:08 Beijing time (15:08 UTC) I sent HEAD requests for manifests to both registries, and repeated them at 23:18 right before publishing with identical results. I didn’t pull or run anything. On Docker Hub and GHCR, latest, main-stable and v1.107.0 all returned 404, and v1.106.2 returned 200. So latest was deleted, not repointed to a clean build, and a bare docker pull litellm/litellm with no tag now fails. That’s fine; it’s what pinning should look like anyway.

But on Docker Hub, the poisoned index (digest starting sha256:1bbcb144…) and its amd64 (bd1aeee4…) and arm64 (56b74573…) manifests still returned 200. On GHCR the same digests returned 404, so GHCR really deleted them. Here’s why it matters: if a Kubernetes manifest, Helm values file or compose file pinned that digest tonight, maybe because someone “did the right thing” and pinned the digest of whatever latest was, it will keep pulling the stealer. Compare your digests against the IOC list in Upwind’s update. I’m deliberately not printing a full pull command for it.

Docker Hub shows no latest tag for litellm/litellm, while a registry test shows the poisoned 1.107.0 digest still returning 200 after the LiteLLM supply chain attack
Top: Docker Hub tags page, captured 23:01 Beijing time. Bottom: binmage’s manifest HEAD requests at 23:08 Beijing time (15:08 UTC); digests truncated on purpose.

How the payload works

Why a .pth file runs without an import

Python’s site module processes every .pth file in site-packages at startup. Most lines just add paths, but the docs are explicit: “lines starting with import (followed by space or tab) are executed with exec()“, and these lines “are always run at Python startup … regardless of whether a particular module is actually going to be used.” cook.pth is 12 bytes: import cook. So cook.py runs whenever that interpreter or venv starts: your app, your tests, a Jupyter kernel, or pip itself. Python 3.15 has deprecated import lines in .pth files, but they still execute during the deprecation period, so this route is wide open on every Python version people actually run.

sitecustomize.py is the second hook. After setting up paths, Python tries to import a module with that name. It’s meant for admins to customize an install, and here it’s used to start the loader.

AISLE's technical section showing cook.pth, cook.py and sitecustomize.py in the 1.107.0 wheel, the core trick of the LiteLLM supply chain attack
Source: AISLE Research Team. Captured Oct 21, 2026, 23:01 Beijing time (15:01 UTC).

What the stealer takes

cook.py is a one-line zlib+base64 blob that decodes to about 61 KB of Python calling itself privcollect. Upwind published the decoded source in a follow-up comment. It runs once in a background thread and needs no C2 server. It collects:

  • host and network info, shell, database and REPL histories, ~/.ssh, GPG and TLS private keys
  • saved Chromium passwords (decrypted), cookies and Firefox logins
  • AWS, Azure, GCP, Kubernetes, Docker, GitHub CLI and package-registry credentials, .env*, *.pem and *.key files
  • cloud metadata: AWS instance-role and ECS task credentials, which it checks with STS GetCallerIdentity, plus GCP and Azure tokens
  • config and login files for AI tools: Claude, Codex, Cursor, Gemini, Copilot, Windsurf, Kiro, Hugging Face tokens and more
  • the environment variables of every readable process on Linux (/proc/*/environ), not just its own
  • if DATABASE_URL is set, a dump of LiteLLM_CredentialsTable and LiteLLM_ProxyModelTable, meaning every upstream provider key the proxy stores

The bundle is encrypted with AES-256-CBC, the key is wrapped with an embedded RSA-4096 public key, and the result is POSTed over plain HTTP to http://www.litellm.dev/healthcheck. A network capture won’t tell you what was taken. After a successful upload it writes /tmp/.pc_once_72129118c59f5636c0bb. JFrog points out that this means a missing marker proves nothing.

The hourly loader: dormant, not dead

sitecustomize.py forks a detached daemon (setsid, output to /dev/null, working directory /) and takes a lock on /tmp/remote_code_loader.lock. Every 3,600 seconds it reads a domain.txt file from the GitHub repo letters9813/pythonclient-test. If that file holds a domain, it runs the stealer again, this time with no marker, then downloads http://<domain>/codepy and passes it straight to exec() with no check. On Windows the fork fails silently, but cook.py still runs.

Per Upwind, domain.txt has been empty since Oct 20, so no remote command ever reached a victim. When I checked tonight, the repo and the letters9813 account both returned 404. Good, but don’t read too much into it. The stealer already did its work on the first start, and nobody knows yet whether that account name could be re-registered. Treat the C2 being gone as “less bad”, not “safe”.

How it got published, and what’s actually confirmed

Attack path diagram of the LiteLLM supply chain attack: clean tag, fork-network commits, lookalike repo, PyPI wheels and Docker tags, cook.pth on Python start, exfiltration and hourly loader
The build-source step is Upwind’s conclusion from image provenance; maintainers haven’t confirmed it.

Here’s my reconstruction of how the LiteLLM supply chain attack got published, keeping evidence and inference apart.

  • Confirmed by artifacts: the payload isn’t in the tag. The sdist’s pyproject.toml has the extra data = "cook" line. The wheels and images contain byte-identical payload files.
  • Upwind’s conclusion, with strong evidence but not confirmed by maintainers: the release was built from the lookalike repo Berri-AI/litellm (note the hyphen). Every v1.107.0 image’s SLSA provenance records that source at 20535d8f, and the same releaser recorded the real BerriAI/litellm for v1.106.2, v1.106.1 and v1.107.0-rc.2. In its 21:24 update, Upwind also compared files byte for byte: the 1.107.0 wheel’s source matches the main commit a8113b0d (3,131 of 3,131 files), not the tag. Upwind also corrected one of its own early claims, that PyPI got the upload before the GitHub release; that turns out to be LiteLLM’s normal order. I like seeing that kind of correction in a report.
  • My inference, not a finding: commit 20535d8f quotes a Docker build failure that only shows up once data = "cook" exists without the directory being copied in, and the official release run started five minutes later. That suggests the attacker could see the output of a real build, whether official logs or a faithful copy of the pipeline somewhere. It’s a lead, not proof.
  • Unknown: who triggered run 39587059944, how its source-repo input came to be Berri-AI/litellm, whether a PyPI token was involved, and how many people downloaded 1.107.0. No source has a per-version download count for tonight, and I’m not going to guess one.
Attacker commit 20535d8f, not on any branch, quoting a Docker build error: evidence in the LiteLLM supply chain attack
Source: GitHub commit 20535d8f. GitHub warns it belongs to no branch in the repo. Captured Oct 21, 2026, 23:09 Beijing time.

Trusted publishing and provenance: what LiteLLM had

PyPI supports Trusted Publishing, where a CI workflow publishes with a short-lived OIDC identity instead of a stored token, and attestations, Sigstore-signed statements of which workflow built a file. On the release history page, every litellm release shows “No provenance”, including the safe 1.106.2. I also checked its provenance endpoint, which returned 404. Upwind says litellm has never published attestations.

Would attestations have stopped this? Honestly, probably not on their own, if the official releaser itself was pointed at the wrong repo. What’s interesting is that the Docker images did carry SLSA provenance, and that’s exactly how Upwind spotted Berri-AI with a hyphen. The record existed. Nobody’s tooling checked it before deploying. LiteLLM signs its Docker images with cosign, per its release notes. Whether the poisoned images had valid signatures isn’t stated anywhere I could find; if they did, it would only prove “our pipeline built this”, and the pipeline is the part that was subverted.

PyPI page showing litellm 1.106.2 as latest and 1.107.0 marked Release Quarantined with no provenance after the LiteLLM supply chain attack
Source: PyPI, litellm. Captured Oct 21, 2026, 23:01 Beijing time (15:01 UTC).

This is also LiteLLM’s second PyPI compromise this year. In March, versions 1.82.7 and 1.82.8 were published with a stealer after, by LiteLLM’s own account, credentials leaked through a compromised Trivy dependency in CI. JFrog says the new payload, persistence and infrastructure are different and it found no link to that group, so treat them as separate incidents. Still, a second release-path compromise in seven months says something about how hard this part of the job is.

Why a gateway is the worst place to get hit

The point of LiteLLM is to put one OpenAI-style API in front of dozens of providers. That’s why it’s popular with exactly the people who read this site: you route Claude Code or the Codex CLI through a LiteLLM proxy (for example by pointing ANTHROPIC_BASE_URL at it) to use cheaper models, spread load, or stretch your Claude usage limits. Setups like that save real money. They also mean one box holds your Anthropic, OpenAI, Gemini and Bedrock keys at the same time.

The LiteLLM supply chain attack shows why: this stealer was built for exactly that box. It has a module that reads the proxy’s database for stored provider keys, it reads every process’s environment, and it grabs Claude Code, Codex and Cursor login files off the same machine. If you ran the proxy on your laptop, which is common for personal setups, it also took your browser passwords and SSH keys. That’s the trade-off I’d like people to see clearly: a cheaper AI setup puts all your keys in one place, so it has to be run like something that matters.

Downstream projects widen the LiteLLM supply chain attack further. I read the dependency files on each project’s main branch. DSPy depends on litellm>=1.65.8 at its core (on Python below 3.15), so a fresh, unpinned install tonight would have resolved to 1.107.0. CrewAI (crewai[litellm]), Google ADK (its extensions extra), smolagents ([litellm]) and the OpenAI Agents SDK ([litellm]) pull it in through optional extras with no upper bound. GraphRAG’s LLM package pins litellm==1.100.1, mem0 caps it below 1.98.0, aider’s requirements pin 1.82.3, and crawl4ai uses a forked package, so those weren’t exposed. “Would resolve” doesn’t mean “was infected”; that depends on your lockfile and when you installed.

LiteLLM supply chain attack self-check and cleanup

Order matters. In an infected environment, any Python start runs the payload again, and pip is Python. So check with the shell first.

# 1) Look for the dropped files WITHOUT starting Python
#    (covers venvs, conda, ~/.local and Debian/Ubuntu dist-packages)
find / \( -path '*site-packages*' -o -path '*dist-packages*' \) \
  \( -name cook.pth -o -name cook.py \) 2>/dev/null

# sitecustomize.py can be legitimate, so check what is inside it
find / -name sitecustomize.py 2>/dev/null | \
  xargs grep -l -E 'remote_code_loader|letters9813|litellm\.dev' 2>/dev/null

# 2) Host markers and the background loader
ls -la /tmp/.pc_once_72129118c59f5636c0bb /tmp/remote_code_loader.lock 2>/dev/null
fuser -v /tmp/remote_code_loader.lock 2>/dev/null   # or: lsof /tmp/remote_code_loader.lock
  1. Search for files first, as above. The pattern covers both site-packages and Debian/Ubuntu’s dist-packages, where a system-level pip installs. On a Mac, narrow / to ~, /usr/local, /opt and /Library/Frameworks to save time. If you find cook.pth, treat the machine as compromised.
  2. Docker: if you pulled latest, main-latest, main-stable or v1.107.0 after about 19:48 tonight, assume you got it. Images stay on your host even after the registry deletes them, so check them with the commands below the list.
  3. Then check versions with pip show litellm, but only in environments where step 1 found nothing.
  4. If infected: kill the process holding the lock file, delete cook.pth, cook.py, sitecustomize.py and their __pycache__ files with rm, and preferably rebuild the venv, container or image from scratch. Uninstalling with pip isn’t a clean fix: it starts Python first, and a daemon that’s already running stays in memory after the files go.
  5. Rotate what the host could reach. Assume the upload worked if the machine had outbound internet. Start with every provider key stored in the LiteLLM proxy, then the master and virtual keys, the DATABASE_URL password, cloud keys and instance roles, SSH and GPG keys, GitHub, npm and PyPI tokens, AI tool logins (Claude Code, Codex, Cursor), browser-saved passwords, and any secret in any process’s environment on that host.
  6. Watch the bills. Check usage in your Anthropic, OpenAI and other provider dashboards for unfamiliar spikes, and set hard spend limits now if you haven’t. AWS users: per the issue, look in CloudTrail for GetCallerIdentity from the instance role around install time, then for any use of those credentials from outside your network.
# Which LiteLLM images do you have locally, and from when?
docker images --digests | grep -i litellm

# Inside a running proxy container (ls, not python):
docker exec <container> sh -c 'ls /app/.venv/lib/python3*/site-packages | grep -E "^cook|^sitecustomize"'

Lessons that will outlast this incident

None of this needs new tools. Most of what would have blunted the LiteLLM supply chain attack is boring hygiene you can set up this week.

  • Pin versions and hashes. pip install --require-hashes, or a lockfile from uv (uv lock) or poetry. One caveat: a hash proves you got the same file you locked, not that the file is clean. 1.107.0’s hashes were “official” too.
  • Pin images by digest, never latest. And keep a record of which digest you approved, because, as tonight showed, a deleted tag doesn’t mean a deleted image.
  • Wait a day before taking new releases. Tonight, detection took 3 minutes and a public warning took 14. A 24-hour delay on non-security updates (Renovate’s minimumReleaseAge, or simply not running -U in CI) would have kept almost everyone out of this window.
  • Pinning 1.106.2 is trust, not verification. It has no attestations either. Until LiteLLM publishes provenance, “known good” means “nobody has found anything yet”. That’s a reason to push the project for Trusted Publishing, not to panic.
  • Treat your gateway like a vault. Use separate provider keys just for the gateway, with low spend caps. Run it on its own box or container, not the laptop that holds your browser profile and SSH keys. Restrict its outbound traffic to the provider APIs it needs. A stealer that can’t reach litellm.dev can’t send anything.
# requirements.txt, then: pip install --require-hashes -r requirements.txt
litellm==1.106.2 \
  --hash=sha256:cb1a2a1e55a4a8350ae2167525dc0d06f59b078cd685fb3e0f5419f02da5420e \
  --hash=sha256:34bfdc26c2c075726eda59ec0675e2a8a67b6e59a03980def7ef7651b849141a
#  (manylinux x86_64 and aarch64 wheels; the other 6 file hashes are on PyPI)

# Docker: tag plus digest
ghcr.io/berriai/litellm:v1.106.2@sha256:7294b90f868c5c38c99b83021c7ba46f9b439e95f8e32964c925c7d2563a03f7

FAQ

I only use the LiteLLM Python SDK, not the proxy. Am I affected?

Yes, if the 1.107.0 wheel was installed. The payload doesn’t depend on importing litellm or running the proxy. The proxy-only part is the database dump, which needs DATABASE_URL.

I uninstalled 1.107.0 already. Am I clean?

Not necessarily. Run the find commands above, check for a running loader, and still rotate keys. The stealer runs on first start, and uninstalling doesn’t bring stolen keys back.

Is 1.106.2 safe?

Upwind checked every wheel of 1.106.2 and several earlier versions and found them clean, and the maintainers recommend pinning it. It has no PyPI attestation, so that’s the best evidence available right now, not a cryptographic guarantee.

Does this affect Claude Code or Codex themselves?

No. Those tools weren’t compromised. The risk is that the stealer reads their login and config files if it runs on the same machine. If it did, sign out, sign back in, and revoke any API keys stored there.

How many people were hit?

Unknown. No source has published a 1.107.0 download count, and the stolen data is encrypted to the attacker’s key, so nobody outside can count it. Be suspicious of any victim count for the LiteLLM supply chain attack you see today.

What should I watch next?

The maintainers promised the next update in issue #47826 at 00:30 Beijing time on Oct 22 (16:30 UTC). The open question is how the release path was hijacked. Until that’s answered, I’d treat any new LiteLLM release with extra suspicion and wait before upgrading.

Sources: GitHub issue #47826 (Upwind’s report and maintainer replies), AISLE, JFrog, Aikido, PyPI, Docker Hub, Python site docs, OSSF malicious-packages #1655. Registry checks by binmage at 23:08 and 23:18 Beijing time, Oct 21, 2026.

Leave a thought

Your email address will not be published.