Safety#Security scan#AI safety

LiteLLM 1.107.0 wheels and Docker images carried a credential stealer after three GitHub tokens opened the release pipeline

All seven litellm 1.107.0 wheels carried a credential stealer and PyPI served them under two hours; three stolen GitHub tokens rerouted the release workflow.

Glowing blue geometric blocks on a dark background with segmented red worm-like shapes climbing their edges, small blue cubes scattered around

LiteLLM is BerriAI’s open-source gateway that puts one OpenAI-compatible interface in front of many model providers; Socket and The Register both put it at roughly 23 million weekly downloads. On October 21, 2026, version 1.107.0 came off its release pipeline carrying a credential stealer: all seven wheels on PyPI were affected, and the Docker tags v1.107.0, latest, main-latest and main-stable resolved to the same malicious image index. LiteLLM’s own incident post dates the PyPI window at 11:39:51 to 13:23 UTC and the Docker Hub window at 11:46:56 to 14:20 UTC.

Key points

  • All seven wheels were affected — macOS x86_64 and arm64, manylinux aarch64 and x86_64, musllinux aarch64 and x86_64, win_amd64 — each carrying three extra files installed into the root of site-packages, one of them a 12-byte cook.pth whose entire contents are import cook.
  • The artifacts were built from the lookalike repository Berri-AI/litellm (the real one is BerriAI/litellm) at revision 20535d8f; the container provenance records source_owner: Berri-AI and release workflow run 39587059944.
  • Wiz observed stolen AWS instance-role credentials used at 16:20 and 17:01 UTC to call sts:GetCallerIdentity and then bedrock:ListFoundationModels, the pattern it associates with LLMjacking.

How the pipeline was taken

LiteLLM published a five-step release process, all of it hardened after a first compromise in March: a release candidate cut on Friday, qualified through CircleCI and Buildkite, built by a workflow in a private repo, tested from the produced artifacts, and published through Trusted GitHub Publishers by an account locked to one laptop kept in the office. Docker tags were immutable and images were signed.

Most of the March hardening held. What moved was who could publish: on Monday, October 19, the team temporarily widened release rights so two engineers could ship daily bugfix backports to customers.

The published timeline, all UTC on October 21: at 10:08:29 branch protection rejected an unauthorized edit to the private release workflow because it had not passed the required pull request and status checks. Five seconds later a credential tied to a privileged developer account overrode the protection and force-pushed commit 9cf97e93. At 10:46:39 the modified workflow started an official staging run pointed at the attacker’s repository, commit 20535d8f. At 11:11:18 a second developer account satisfied the production approval gate. Wheels were uploaded from 11:39:51 to 11:40:13, the container jobs ran at 11:46:56, and from 11:53:49 to 11:54:01 a third account deleted the production and staging workflow records, thinning the logs available to investigators.

ChannelAffected tags or filesWindow per LiteLLM (UTC)
PyPIlitellm==1.107.0, seven wheels plus one sdist11:39:51–13:23
Docker Hubv1.107.0, latest, main-latest, main-stable11:46:56–14:20
GHCRSame tags, across litellm, litellm-database, litellm-non_root11:46:56–13:51

LiteLLM says the attacker held three sets of developer GitHub CLI credentials, one of which could run workflows and one of which belonged to one of the organization’s two owners. It also says it has observed situations where Claude Code embeds a user’s GitHub CLI OAuth token into a response body, and that where response bodies are logged those tokens can surface in LiteLLM logs. The company found an unexpected, innocuously named account in an internal LiteLLM deployment that was reachable from the public internet, and believes that is where the tokens were taken from — though it has not confirmed how the attacker got into that deployment, and currently suspects a recently patched vulnerability. Treat the Claude Code part as LiteLLM’s claim: we found no confirmation from Anthropic and no independent reproduction.

Why installing was enough

Two of the three extra files run whenever a Python interpreter starts, whether or not the application ever imports litellm. cook.pth holds one line, import cook; CPython’s site module processes .pth files during initialization and executes any line beginning with import. sitecustomize.py gets its turn because the interpreter automatically tries to import a module by that name at the end of startup.

JFrog and Socket each decoded the payload statically. cook.py is a wrapper around exec(zlib.decompress(base64.b64decode(...))); the file is 22,252 bytes in the wheel RECORD, and the decoded source runs to roughly 61 KB — two different measurements of the same thing, not a contradiction. The program calls itself privcollect and works through 16 modules: saved browser passwords and cookies, ~/.ssh, shell history, AWS, Azure, gcloud, kube, docker and gh credential files, configuration for AI coding tools including Claude Code, Cursor and Codex, the environment of every readable process via /proc/*/environ, and GPG and TLS private keys. If DATABASE_URL is set, it spawns prisma and dumps LiteLLM_CredentialsTable and LiteLLM_ProxyModelTable — every upstream provider key the gateway stores on a customer’s behalf. Results are encrypted with AES-256-CBC under a key wrapped in RSA-4096-OAEP and POSTed to www.litellm.dev/healthcheck, a domain registered on October 13 that is not the project’s litellm.ai.

The second payload is the backdoor. sitecustomize.py forks a detached daemon that wakes hourly, reads a domain from domain.txt in the GitHub repository letters9813/pythonclient-test, re-runs the stealer, and exec()s whatever http://<domain>/codepy returns. Wiz reports the file was never populated beyond initial test values, and JFrog’s read-only retrieval got a zero-byte body. The second stage stayed dormant, and one commit re-arms it. On Windows the fork fails silently, but cook.py still runs.

Why source review missed it

This is the part worth remembering. According to JFrog, SafeDep and Socket, the v1.107.0 git tag is clean and the sdist does not contain the three payload files.

The difference is one line. The sdist’s pyproject.toml adds data = "cook" under [tool.maturin]; maturin copies that directory into litellm-1.107.0.data/purelib/ inside the wheel, and pip installs those files into the root of site-packages. SafeDep compared every sdist file against git trees by blob hash and found the archive matches a8113b0d on main (08:50 UTC), with five files edited. Four are Cargo.toml files gaining a readme line that legitimate releases also carry; the fifth is the version rolled back to 1.107.0, the companion packages pinned to their release-candidate values, and the injected data key. A dependency diff against 1.107.0rc2 therefore shows nothing unusual.

The sdist packaging never included the cook/ directory, so reviewing the code, the tag or the sdist all come back clean. Only opening the wheel and reading its RECORD exposes the three extra entries. Socket’s version of the point: reviewing the tag alone misses a change made at the packaging layer.

After the theft

Wiz’s observation is the only public account of what happened to the credentials. About 4.5 hours after exfiltration, between 16:20 and 17:01 UTC, the attacker validated AWS instance-role credentials harvested from compromised CI runners and Kubernetes nodes across multiple environments, from two IP addresses. Each credential was checked with sts:GetCallerIdentity, then used for bedrock:ListFoundationModels and a ListModels call; in a small number of environments the same actor enumerated IAM with ListRoles, ListUsers and ListAccessKeys, and ran general discovery with ListAccounts and DescribeTrails. Wiz calls the pattern consistent with LLMjacking — confirming a cloud identity, then inventorying which models it can reach.

The remediation order comes from two places that agree on the first step: LiteLLM’s own “What to do if you are affected” and Socket’s “Contain Affected Environments”, both of which say not to start the affected environment’s Python — pip included — as the first move. The steps first appeared in the issue thread, posted by Upwind’s LidorMachluf. Any Python start runs the hooks again. From a shell, delete cook.pth, cook.py, sitecustomize.py and the matching __pycache__ entries from every affected site-packages; kill the detached process holding /tmp/remote_code_loader.lock; then reinstall pinned to litellm==1.106.2 — the version Socket notes Upwind recommends — or rebuild from a reviewed artifact. The marker /tmp/.pc_once_72129118c59f5636c0bb is written only after a successful upload, so finding it confirms exfiltration — not finding it proves nothing, because containers and tmp cleanup remove it.

Then rotate: the instance or task IAM role, every provider key in the LiteLLM proxy database including master and virtual keys, the DATABASE_URL password, and any secret that appeared in the environment of any process on the host. In CloudTrail, look for GetCallerIdentity from the instance role around install time and for use of that role’s credentials from outside your infrastructure.

The latest maintainer update in the issue, posted at 02:19 UTC on October 22, says the registries remain locked to new releases, ordinary releases are still paused, there are no material developments, and updates now come only when there is progress. Our artifact checks at 03:16 UTC on October 22: pypi.org/pypi/litellm/1.107.0/json returns 404 and the latest release is 1.106.2, uploaded at 10:44:22Z; Docker Hub’s v1.107.0 tag returns 404 and latest, main-latest and main-stable all resolve to sha256:7294b90f…, the same digest as v1.106.2; GHCR’s v1.107.0 also returns 404. Containment at the artifact level holds.

What others say

The Hacker News thread read 453 points and 153 comments at 03:21 UTC on October 22, and the dominant reaction was about frequency. User stbenjam wrote “Once is forgivable, twice is malpractice,” and in the same thread argued that uv, npm and similar tools should default to a 24-to-48-hour dependency cooldown, with installing anything newer requiring deliberate effort. Others pushed back on cooldowns as security theater: user 0c3ca83 noted that the median supply-chain attack sits undiscovered for months to years, so a one- or two-day delay buys little; whatshisface followed the logic to its joke: if everyone implements a cooldown, nobody will find the attack, so the cooldown length has to be the cooldown length plus one month.

Specific criticism of LiteLLM landed in two places. First, the project’s own narrative is that March led to meaningful hardening, and on the evidence most of it worked as designed: the private repo, Trusted Publishers and provenance all did their jobs, which is why the poisoned artifacts came out of the official release workflow with provenance naming Berri-AI/litellm as the source. Branch protection fired and was then overridden, as designed, by a token belonging to an organization owner. The change that mattered this week was the October 19 permission grant. Second, the line in the advisory about an enterprise-only private registry distributing a patch for a vulnerability scheduled for disclosure on October 27 read to some as open-source users waiting on a fix that already exists. LiteLLM employee chias replied in the thread that the vulnerability affects enterprise deployments only, not open source, and that customers get the patch first so they can upgrade before public disclosure. That is a coherent explanation and also the interested party’s account.

Use the March comparison carefully. That incident was versions 1.82.7 and 1.82.8, which JFrog traces to a malicious unpinned Trivy 0.69.4 in the pipeline; The Register cites CloudSEK for an estimate of more than 2,500 organizations and 434,000 CI/CD pipelines potentially exposed. Those numbers belong to March; LiteLLM has given no scale estimate for October, so don’t mix them. JFrog states there is no evidence linking the two campaigns. Our write-up of the n8n DGX Spark tutorial already verified the March versions and the related advisories, so we won’t re-tell that story here.

The two incidents differ where it counts: the injection point. March replaced a third-party tool inside the pipeline, which is a dependency-chain problem. October changed where the release workflow pointed, which is a release-chain problem. Pinning dependency versions and checking tool hashes defends against the first; the second turns on who may edit the workflow and who may approve the edit. LiteLLM has said it will keep posting investigation updates. As of this writing it has not answered whether it will publish the project-releaser workflow definitions — a reader in the issue thread asked exactly that, on the grounds that downstream users want to verify how releases are built.

Our take

The transferable lesson is a timing detail. A team spends months tightening a release process until only an account bound to one laptop in the office can publish, then relaxes it on a Monday so two people can ship daily bugfixes to customers. An IOC list expires with the next campaign; that shape does not. An attacker needs one path through, and here three separate tokens handled triggering, approving and log deletion — closer to a plan than to opportunist’s luck.

For teams running LiteLLM as a gateway, several things are worth doing now. Check whether litellm in any deployment is exactly 1.107.0 — don’t trust the requirements file alone, since floating ranges and the latest tag both drift. Pin docker pull by digest: the floating tags resolve to sha256:7294b90f… again, the clean 1.106.2 digest, but this incident is a case study in what a floating tag does to an environment that never writes a version down. Walk CloudTrail for GetCallerIdentity and Bedrock calls after 11:39 UTC on October 21. And if a CI runner executed 1.107.0, rotate that runner’s credentials wholesale — it could see both deployment keys and a cloud role.

Two questions remain open. The initial access vector is unconfirmed; the maintainers say they suspect a recently patched vulnerability. And the claim that Claude Code embeds a user’s GitHub CLI OAuth token into a response body appears only in LiteLLM’s advisory and issue thread, with no Anthropic confirmation and no independent reproduction we could find. Read it as “LiteLLM says.” If it holds, it reaches well past LiteLLM: any deployment that writes a coding agent’s output into logs that outsiders can read sits on the same chain. That lands on the same controls as this year’s agent overreach incidents — sandboxing, egress control, and what is allowed to enter a log.

How to check

Run these in order, and do not install anything first:

# 1. Look for evidence before starting any Python
find / -path '*/site-packages/cook.pth' -o -path '*/site-packages/cook.py' \
  -o -path '*/site-packages/sitecustomize.py' 2>/dev/null
ls -l /tmp/.pc_once_* /tmp/remote_code_loader.lock 2>/dev/null

# 2. Delete the files, kill the detached process, reinstall pinned
pip install 'litellm==1.106.2'

If /tmp/.pc_once_* exists, data already left the host; if it does not, that only means this machine shows no trace. Docker users should pin to the digest sha256:7294b90f868c… or rebuild.