At 11:39 UTC on 21 October 2026, seven wheels for litellm 1.107.0 landed on PyPI. They were built and signed by LiteLLM’s own release pipeline. They carried a credential stealer and a remote code execution backdoor. PyPI removed the release at 13:23. The window was 103 minutes.
The coverage has been thorough on the intrusion: stolen GitHub CLI credentials, branch protection overridden, the release workflow pointed at a lookalike repository. What nobody has measured is the delivery mechanism. The payload did not run when you imported LiteLLM. It ran when any Python process on the machine started, including pip itself. That is a documented, legitimate wheel feature, and the obvious question is how many packages you already trust use it. We opened the current wheel of the top 1,000 PyPI projects and counted.
TL;DR
- The malicious
litellm1.107.0 wheel installed a.pthfile and asitecustomize.pyinto the root of site-packages, so the stealer ran in every Python process on the host, not just ones that imported LiteLLM. - We read the file listing of one current wheel from each of the top 1,000 PyPI projects. 995 ship a wheel. Exactly 1 of 995 writes into
.data/purelib/, the wheel feature the attacker used, and 0 of 995 install asitecustomize.pywhere Python runs it automatically. - Five of 995 ship a
.pthfile. All five execute code at interpreter start. Three do it unconditionally. That is your entire false-positive list. - The release was downloaded 49,043 times in those 103 minutes, roughly 476 per minute. 36,238 of them were uv, 10,654 pip. Irish IP addresses pulled it 1,078 times.
- PyPI deleted the release rather than yanking it, but 57 of those downloads went into Nexus, devpi and bandersnatch mirrors, where deletion upstream changes nothing.
What actually shipped
Installing litellm==1.107.0 placed three files directly into site-packages, alongside the packages themselves rather than inside any of them: cook.pth, cook.py and sitecustomize.py.
Both of the loaded files abuse interpreter startup. Python processes every .pth file in site-packages at launch, and any line beginning with import is executed. cook.pth was twelve bytes: import cook. Python also imports a module named sitecustomize at startup if one is on the path, a hook that has existed since the 1990s for site administrators.
So the stealer ran in your application, in your IDE’s language server, in your test runner, in your CI job, and in the very pip invocation that installed the next package. The second file forked a detached daemon that read an attacker-controlled GitHub file hourly for a server address, then downloaded and executed whatever it returned. LiteLLM’s own code was never involved. A developer who installed the wheel, realised the build was broken and never once ran import litellm was compromised exactly as thoroughly as everyone else.
SafeDep’s analysis found 16 collection modules: SSH keys, AWS and Azure and gcloud credentials, Kubernetes service account tokens, GitLab runner and Jenkins secrets, and the stored credentials of fourteen AI developer tools including Claude Code, Cursor, Copilot and Codex. On a LiteLLM proxy host it also dumped the credentials table, which is where the provider keys live.
The one line that did it
The source distribution for 1.107.0 contained no malicious code. It contained one extra line in pyproject.toml, in the maturin build section: data = "cook".
That setting tells maturin to copy a cook/ directory into the wheel as litellm-1.107.0.data/purelib/. The .data directory in a wheel is a standard installer instruction: anything under purelib is unpacked into the site-packages root, anything under scripts goes onto PATH. It is how packages ship console entry points and data files. Here it was how three files reached the one directory Python scans at startup.
The cook/ directory itself was absent from the sdist, so the only visible change in the published source was that single line, and no commit in the public repository contains it. This is the part worth internalising: the thing you can diff against GitHub did not contain the attack. The artefact you actually install did.
We opened 995 wheels
If dropping files into the site-packages root is rare among legitimate packages, it is an alert rule rather than a research finding. Nobody had published the number, so we measured it.
We took the top 1,000 PyPI projects by September 2026 download volume, resolved each to its current stable release, and read the file listing of one wheel per project, preferring the pure-Python wheel where one exists. 995 projects ship a wheel. Five ship none at all (pyspark, docopt, swifter, oss2 and ratelimit), which is its own problem for a different post. Nothing was installed and nothing was executed. Results, measured 22 October 2026:
| Wheel contains | Count (of 995) |
|---|---|
A .pth file |
5 |
A sitecustomize.py anywhere |
4 |
A sitecustomize.py Python will auto-run |
0 |
Anything under .data/purelib/ |
1 |
Anything under .data/platlib/ |
0 |
Any .data/ directory at all |
40 |
The single package writing into .data/purelib/ is semgrep 1.182.1, which installs a semdep/ tree that way. One package in a thousand, and it is a security scanner. The other 39 uses of .data/ are console scripts and data files, neither of which lands in site-packages.
The sitecustomize.py result is the cleanest. Four packages ship a file by that name (opentelemetry-instrumentation, debugpy, ddtrace and gevent) and all four keep it inside their own package tree, where Python will not import it unless something deliberately puts that directory on the path. The count of top-1,000 wheels that install a sitecustomize.py somewhere Python runs it unprompted is zero. An alert on that filename landing in site-packages has, across the thousand libraries your stack most likely depends on, no false positives at all.
The five that do use .pth
The .pth result is more interesting, because the mechanism is genuinely in use and you cannot simply ban it.
| Package | File | Runs code at startup |
|---|---|---|
| setuptools 84.2.1 | distutils-precedence.pth |
Yes, by default |
| google-cloud-aiplatform 2.6.0 | namespace package shim | Yes, always |
| pywin32 312 | pywin32.pth |
Yes, plus path entries |
| coverage 7.16.2 | a1_coverage.pth |
Only if COVERAGE_PROCESS_START is set |
| coloredlogs 15.0.1 | coloredlogs.pth |
Only if COLOREDLOGS_AUTO_INSTALL is set |
All five execute an import on every interpreter start. Three do so unconditionally. setuptools ships a line that reads an environment variable, defaults it to enabled, and imports a shim module. Functionally that is the same shape as import cook, which is exactly why the technique works.
But five is a short list. Five is an allowlist you can write down this afternoon and review once a quarter. The useful framing is not “ban .pth files”, it is “the set of packages that may install one is small, known, and should never grow without someone noticing”.
The signature was valid
The attacker did not forge anything. They pointed LiteLLM’s own private release workflow at a lookalike repository and passed the approval gate with a second developer’s credential. The official pipeline then built, signed and published the artefacts.
It is worth being precise about what that defeats. LiteLLM does not publish PyPI attestations, so none were in play. But had it done so, they would have verified. We pulled the attestation for a package that does publish them, packaging 26.3, and decoded it. The signed statement names the file and its SHA-256 digest. The publisher block reads: GitHub, repository pypa/packaging, workflow publish.yml. The predicate is null. There is no source commit, no tree hash, no link between the artefact and any particular state of the repository.
That is not a flaw in PEP 740, which does what it claims. It is a limit worth knowing before you treat a green provenance badge as a safety signal, and it is the same gap we found when we audited npm provenance across 880 packages. Attestation answers “did this come out of the official pipeline”. On 21 October, the honest answer for the backdoored wheel would have been yes.
49,043 downloads in 103 minutes
PyPI’s download telemetry is public, and it records the release by version. litellm 1.107.0 was downloaded 49,043 times, every one of them dated 21 October 2026. The file existed for 103 minutes. That is roughly 476 downloads a minute, eight a second, for a release that was online for under two hours.
| Installer | Downloads of 1.107.0 |
|---|---|
| uv | 36,312 |
| pip | 10,654 |
| Unlabelled | 1,440 |
| Browser | 461 |
| requests | 97 |
| Nexus, bandersnatch, devpi, poetry | 79 |
Resist the temptation to make this a uv story. We ran the control: on the same day, the clean 1.106.2 release was 66.2% uv against pip, and all litellm versions together were 71.2%. The malicious release was 77.3%. uv is slightly over-represented, consistent with being the resolver most likely to reach a release minutes old, but the real finding is duller and worse. The majority of Python installs now happen through tooling that will pick up a brand new version the moment it appears, and 103 minutes is more than enough.
By country, the US took 23,236, Singapore 7,748, Germany 3,450, India 2,888 and Hong Kong 2,386. Irish IP addresses pulled the backdoored wheel 1,078 times. For scale, Ireland accounted for 1.36% of the clean 1.106.2 downloads that day and 2.20% of the malicious ones. Make of the over-representation what you will, but a thousand Irish installs of a credential stealer is not an abstraction.
Deletion is not recall
PyPI did not yank this release, it deleted it. The difference matters. A yanked version stays installable for anyone who pins it, which is why litellm still lists three yanked 0.1.x versions in its index today. 1.107.0 is simply gone: pip download litellm==1.107.0 now returns “No matching distribution found”, and the release is absent from the API entirely while the 1.107.0 release candidates and dev builds remain.
That is the right call, and it protects nobody who already has the file. 26 of those downloads were Nexus, two were devpi and 29 were bandersnatch. Those are pull-through caches and mirrors, which means the artefact now sits inside private infrastructure that PyPI’s deletion cannot reach, waiting to be served to the next build that asks. Add every pip and uv cache on every laptop and CI runner that touched it. The container story is worse again: the v1.107.0, latest, main-latest and main-stable tags on Docker Hub and GHCR carried the same code from 11:46 until 14:20, a 154-minute window, and anything that pulled by tag rather than digest has it on disk.
The check takes 17 seconds
The entire sweep above cost about 3,000 HTTP requests and 17 seconds of wall clock, because you never have to download a wheel to see what is in it. A wheel is a zip file, served over HTTP by a CDN that honours range requests, and a zip file keeps its index at the end. One range request for the last 256 KB gets you the central directory of almost any wheel, and the central directory lists every path the installer will create.
Run that against your own lockfile and you have an inventory of every file your dependencies will write outside their own package directories, before a single one is installed. Flag anything matching .pth, sitecustomize.py, usercustomize.py or .data/purelib/, keep the five known exceptions in an allowlist, and you have a gate that would have caught 1.107.0 at 11:40 UTC rather than at 13:23.
What to do on Monday
Four things, in order of how cheap they are.
Check whether you have it. Search site-packages across your estate for cook.pth, cook.py and a root-level sitecustomize.py, and look for /tmp/.pc_once_ and /tmp/remote_code_loader.lock. A detached Python process with parent PID 1 and a working directory of / is the backdoor daemon. If you find any of it, every credential on that host is gone, including the ones in your environment variables and your cloud instance metadata.
Add the install-time file check to CI. It is a few dozen lines, it needs no agent, no scanner licence and no network egress beyond the index you already use, and the false-positive list is five packages long.
Stop installing from a floating range in production builds. Hashes in your lockfile would have failed this install outright. Pinning alone would have done it, as LiteLLM’s own pinned companion packages were untouched.
Treat your release pipeline as production. The credentials here came out of the logs of an internet-exposed internal deployment, and then walked straight through branch protection and a two-person approval gate. Signing infrastructure that any one compromised token can redirect is signing infrastructure that attests to the attacker’s build.
Where we come in
REPTILEHAUS builds and runs production AI infrastructure, and LLM gateways like LiteLLM sit at the centre of most of it, holding every provider key a client has. We treat the install step as part of the threat model: pinned and hashed dependencies, artefact inventories built from the registry rather than the manifest, egress controls on build agents, and release pipelines whose credentials cannot be pointed somewhere else by a single stolen token.
If you are running an LLM gateway in production and cannot currently answer what your last deployment wrote into site-packages, that is a half-day of work and a conversation worth having. Get in touch.
Method: top 1,000 PyPI projects from the hugovk top-pypi-packages snapshot of 1 October 2026, ranked by September 2026 downloads. One wheel per project, current stable release, file listings read via HTTP range requests on 22 October 2026. Download figures from the public PyPI download dataset. Timeline from LiteLLM’s incident report as published by SafeDep; PyPI state, attestation decoding and GitHub repository checks verified first-hand. Limitation: we read one wheel per project, so a package that ships a .pth only on a platform we did not sample would not appear in our counts.
Photo by Luke Heibert on Unsplash


