← Back to all posts
Published on · by Renaud Deraison

The release was two hours old

On October 21, attackers holding three LiteLLM developers' stolen GitHub CLI tokens published litellm 1.107.0 to PyPI. Every wheel carried a credential stealer that runs on any Python start and an hourly remote-code loader. PyPI pulled it 1 hour 43 minutes later, after an estimated 49,000 downloads. In Bromure Agentic Coding, the age gate that ships on by default hides any release younger than two days, so pip on a Bromure machine never saw 1.107.0.

The malicious release was on PyPI for 1 hour and 43 minutes. With Bromure's default settings, pip on an agent's machine would not have seen it until two days after it went up.

At 11:39 UTC on Tuesday, October 21, seven wheels for litellm==1.107.0 landed on PyPI. LiteLLM is the Python library and proxy that many teams use to route model traffic, and it ships in a lot of AI tooling. The new release looked like the project's own: the project's release workflow had signed it, and it came with SLSA provenance. It also contained a credential stealer and a backdoor.

PyPI removed it at 13:23 UTC. Infected Docker images tagged v1.107.0, latest, main-latest and main-stable stayed on Docker Hub until 14:20. Wiz estimates about 49,000 downloads of the malicious version in that window. LiteLLM's incident report tells anyone who installed it on a host with outbound internet access to "assume the exfil succeeded."

This is the project's second compromise this year. The first, in March, came through a poisoned security scanner in its CI. We wrote about how responders checked the wrong window for that one.

How the attackers got the keys

The LiteLLM team has published a minute-by-minute timeline. The attackers held GitHub CLI OAuth tokens for three developers. That Monday, release permissions had been widened so two engineers could ship bugfix backports. At 10:08 on Tuesday, branch protection rejected a change to the private release workflow. Five seconds later, a token belonging to one of the organization's two owners overrode the protection and pushed the change. The modified workflow built from Berri-AI/litellm, a lookalike of the real BerriAI/litellm, and a second developer's token passed the production approval gate. At 11:53, a third developer's token deleted the workflow run records.

LiteLLM traces the tokens to two things it found. One was an "unexpected, innocuously named user account" on its internal LiteLLM deployment, which was exposed to the internet. The other concerns coding agents. The report says LiteLLM has "observed specific situations in which Claude Code will embed a user's GitHub CLI OAuth token into a response body, and in situations where response bodies are logged, these tokens may appear in LiteLLM logs."

A gateway that logs model traffic keeps a copy of whatever the agent wrote. In this case the copy included the token that could publish the package.

What the wheels did

StepSecurity's teardown explains why the source on GitHub looked clean. The attackers added one line to the build configuration, data = "cook" under [tool.maturin]. It copied a cook/ directory into each wheel. The directory never appeared in the source distribution, and the v1.107.0 tag on GitHub has no trace of it.

Each wheel installs three files into site-packages:

  • cook.pth, 12 bytes: import cook. Python's site module runs import lines in .pth files at every interpreter start. Nothing ever has to import litellm. Starting pip is enough.
  • cook.py, one obfuscated line that decodes to about 61,000 bytes. It is a stealer that tags its output "tool": "privcollect" and runs 16 collection modules.
  • sitecustomize.py, a loader that forks into a detached daemon. Every hour it reads domain.txt from a GitHub repository the attacker controlled, fetches http://<host>/codepy and runs the result with exec().

The stealer takes SSH and GPG keys, cloud credentials and instance metadata, ~/.kube/config, Docker config, GitHub CLI tokens, .git-credentials, .netrc, registry tokens for npm, PyPI, Cargo and others, .env files two levels deep, browser logins and cookies, shell and database history, and the environment of every readable process from /proc/<pid>/environ. It also lists auth and config files for Claude Code, Claude Desktop, Cursor, Windsurf, Kiro, GitHub Copilot, Gemini CLI, Codex, Continue, Aider, Goose and about a dozen other AI tools. On a LiteLLM proxy host, it reads the provider API keys out of the proxy's database.

The stealer encrypts the bundle with AES-256, wraps the key with the attacker's RSA-4096 public key, and posts the result over plain HTTP to http://www.litellm.dev/healthcheck. The attacker registered the domain on October 13. Until the server answers 200, the stealer runs again at the next Python start.

The loader's domain.txt was empty at release, so the second stage never armed. The GitHub account behind it has been deleted, but infected hosts are still polling that URL every hour. If anyone re-registers that username, every one of those hosts runs their code.

Why coding agents were exposed

An agent working on a Python project runs pip install -U litellm because a README or an error message suggests it. It runs pip install -r requirements.txt with an unpinned litellm line. It starts a fresh virtualenv and installs whatever is newest, at any hour, and nobody reads the version that came back.

On Tuesday, between 11:39 and 13:23 UTC, newest meant 1.107.0. On an agent host, cook.pth then ran on every python, pytest and pip call the agent made for the rest of the session. The developer's own credentials were in the stealer's reach, and the agent's sign-ins were on its target list.

Bromure never served 1.107.0

Bromure Agentic Coding runs Claude Code, Codex, Grok, Kimi and omp in Linux VMs on your Mac. Every package fetch from the VM passes through a proxy on the Mac, which applies the machine's supply-chain policy before the response reaches pip. The VM can't change that policy. The pip.conf inside the VM can only narrow it.

One layer of that policy is on for every new machine: the age gate, with a minimum age of 2 days. When pip asks PyPI's simple index which versions of litellm exist, the proxy drops every file younger than the cutoff from the listing before pip reads it. At 11:45 UTC on Tuesday, a Bromure machine running pip install -U litellm got a list that ended at 1.106.2, the last clean release, and installed it. To the agent, 1.107.0 did not exist yet, and by the time the gate would have opened, PyPI had deleted it.

If the agent pins the version, the artifact fetch is the second check. The proxy looks up the file's publish time on PyPI and refuses the download with HTTP 451:

$ pip install litellm==1.107.0
ERROR: HTTP error 451 while getting https://files.pythonhosted.org/.../litellm-1.107.0-...whl
Bromure Supply-Chain Security blocked this request: pypi package [email protected]
published 1 hour ago — policy requires 2 days minimum

pip prints the reason verbatim, so the agent reads it too, and it often pins an older version on its own.

The release was gone long before the gate would have openedOct 21 08:00Oct 22 00:00Oct 23 00:00Oct 23 14:00litellm 1.107.0 on PyPI11:39 to 13:23 · about 49,000 downloadsDocker Hub tags until 14:20Bromure age gate, default 2 dayspip on a Bromure machine sees 1.106.2 as the newest litellmOct 23 11:39: gate would open. Nothing left to serve.removedTimes from LiteLLM's incident report. Download estimate from Wiz.
litellm 1.107.0 on PyPI, October 21 to 23, UTC. The malicious release was live for 1 hour 43 minutes and was gone more than 46 hours before Bromure's default age gate would have let pip see it.

The gate needed no advisory, no malware signature and no one to name the package. Registries and researchers tend to find and pull a hijacked release within hours, and a two-day wait costs you almost nothing in normal development. If your own team publishes a package that has to install the minute it ships, add it under Exempt packages in the machine's Supply Chain pane, as pypi:yourpkg. The rest of PyPI still waits two days.

The same pane adds more layers. The OSV check refuses any version with a published advisory at or above the severity you pick. The socket.dev filter blocks packages flagged as malware. If a reputation source you turned on can't give a verdict, the download pauses and Bromure asks you about that one package and version.

The token in the log was a stand-in

The release was only possible because the attackers held real GitHub tokens. LiteLLM believes they got them from a deployment that logged model traffic, where Claude Code had written a developer's GitHub CLI token into a response.

In a Bromure machine, that token is a stand-in. When the VM boots, Bromure writes a fake ghp_ token into gh's configuration and ~/.git-credentials and exports it as GH_TOKEN. The fake has the right prefix and length, so gh accepts it. The real token is kept on the Mac. When a request leaves the VM for GitHub, the proxy swaps the fake for the real value on the wire, for GitHub's hosts only.

An agent can still paste the token into a reply. It has only the stand-in to paste. The model provider and any gateway log in between record the stand-in too. Replayed from someone else's laptop, GitHub rejects it, because no Bromure proxy is there to swap it.

Release permissions get the same protection. In a machine's Guardrails pane, every forge token has a write policy, and new machines start on Prompt before write. A force-push to a release workflow, or an API call that turns off branch protection, waits for a dialog on your Mac showing METHOD /path, and you decide.

What privcollect would have found

Suppose 1.107.0 did reach a machine, because someone turned the gate off or added an exemption. The stealer's list is long. In a Bromure VM, most of what it can open holds stand-ins:

  • GH_TOKEN, ANTHROPIC_API_KEY and every other credential the machine was given, in the environment of every process the stealer reads through /proc, are fakes.
  • ~/.git-credentials, ~/.docker/config.json, ~/.kube/config, ~/.aws/config and the MCP configs hold fakes. So do the agents' own sign-in files: the Claude, ChatGPT, Grok and Kimi subscriptions are signed in and refreshed on the Mac.
  • Model provider keys never enter the VM, so there is no proxy database full of upstream keys to read. Bromure's proxy adds them on the Mac, as we described in The provider runs where the keys are.
  • SSH keys sign through an agent bridge to the Mac. The private key bytes never enter the VM, so ~/.ssh has nothing to take.

A stand-in works only through that machine's proxy, toward the host it was minted for. The attacker can decrypt the tarball and replay every value in it, and GitHub, AWS and Anthropic will reject each one.

The POST had nowhere to go

The bundle goes out as a plain-HTTP POST to www.litellm.dev, a domain the machine has never needed. With Unmatched traffic set to Deny in the Guardrails pane, the VM reaches only the hosts you list:

allow web pypi.org
allow web files.pythonhosted.org
allow web github.com
allow web api.anthropic.com
default deny

The switch on the Mac refuses the connection, and the Security Timeline shows a red deny row with the host and port. The loader's second stage fails the same way: whatever host domain.txt names, http://<host>/codepy isn't on the list, so the hourly exec() has nothing to run.

On a workspace running the OpenShell + Sentry sandbox engine, Show the Whole Chain on that row opens the lineage of the blocked flow. It shows the agent's pip install tool call and the reasoning the model gave for it, then the pip process in which cook.pth started the stealer, then the refused connection. You see the instruction that pulled the package in, and the model's stated reason for running it.

Four steps, four checks on the MacThe attack, October 211 · Tokens in a loggh OAuth tokens copiedfrom logged model traffic2 · Workflow force-pushedprotection overridden,build from Berri-AI/litellm3 · Wheels on PyPIlitellm==1.107.011:39 to 13:23 UTC4 · cook.pth runsharvests, then POSTs towww.litellm.devIn a Bromure machineStand-in tokenthe VM holds a fake ghp_;the log keeps the fake.GitHub rejects it elsewhere.Write policypushes and branch-protection changes waitfor a dialog on the Mac.Age gate, on by defaultpip resolves 1.106.2;a pinned 1.107.0gets HTTP 451.Fakes, and default denythe harvest is stand-ins;the POST and /codepyare refused at the switch.
The litellm 1.107.0 chain, and where a Bromure machine breaks it. Each of the four checks runs on the Mac, outside the VM the package installs into.

"Did we install it?" takes one filter

Every responder had the same first question on Tuesday afternoon: which of our machines ran 1.107.0? LiteLLM warns that finding no cook.pth proves nothing: its absence doesn't mean a host is clean.

Bromure records every package fetch the proxy sees, whatever the policy says and whether or not any check is on. Open Window → Security Timeline…, pick Supply chain in the engine filter, and search for litellm. Each row names the package and version, pypi · [email protected], and what happened to it. The record stays on the Mac for 90 days, and Export… saves it as CSV for the incident ticket. On a Mac enrolled with bromure.io, each fetch is also a supply_chain.fetch event with the ecosystem, package, version and outcome. An administrator can list every machine in the organization that downloaded [email protected] without asking anyone.

Cleaning up after a hit is quick. LiteLLM's guide says to kill the daemon holding /tmp/remote_code_loader.lock before running pip, delete the payload files from every site-packages, rebuild and rotate. In Bromure, Reset disk re-clones the system disk from the base image, and Rewind home… returns the home to a checkpoint from before the install. The keys you would otherwise rotate in a hurry were stand-ins. The real ones never left the Mac.

Fresh releases wait two days

The age gate is on for every new machine. Installs from PyPI, npm, Cargo, RubyGems and Packagist resolve to the newest version older than the cutoff. A pinned fresh version gets a 451 with the reason.

The token in the log is a fake

GitHub, cloud, registry and model credentials enter the VM as stand-ins. The proxy swaps in the real value on the Mac, for the credential's own host.

Exfiltration needs a route

With unmatched traffic set to deny, a POST to a domain registered last week dies at the switch and shows up as a red row.

Every install is on the record

The Security Timeline lists every package and version each machine fetched, for 90 days. Enrolled Macs send the same events to the organization.

Let the registry catch up first

The attackers needed minutes to publish 1.107.0. PyPI needed 1 hour 43 minutes to pull it, which is fast for a registry. Your agent installs packages all day, at any hour, and takes whatever is newest, so it lands inside windows like that one. On a Bromure machine it waits two days. By then PyPI, Wiz, StepSecurity and LiteLLM's own team have had their look at the release, and a bad one is gone.

For the stealer that went after coding-agent credentials earlier this month, read The stealer packed the stand-ins. For how tokens end up in transcripts and model logs in the first place, read The transcript kept the token.