LiteLLM 1.107.0 無須 import 即觸發 .pth 鈎子,作者實測免啟動盤查與 uv 冷卻
LiteLLM 1.107.0 Ran Without an Import: The .pth Startup Hook, a No-Python Audit, and a uv Cooldown
LiteLLM 1.107.0 曾於 10 月 21 日在 PyPI 上架約 100 分鐘,安裝套件後,.pth 啟動鈎子可在 Python 程序啟動時執行負載,無須 import litellm。
文章以無害替身展示 .pth 啟動鈎子的觸發範圍,並提供不啟動可疑 Python 環境的盤查方式及依賴冷卻設定,方便讀者檢視事故應對措施。
Attributed compile + three small runs on my box
Primary source: Kunal Singh, Compromised LiteLLM 1.107.0 on PyPI Installs a Credential Stealer and Backdoor (SafeDep, 2026-10-21).
Vendor incident report: Krrish Dholakia, Ishaan Jaffer, Oliver Jensen, Nate Armstrong, Contained: Oct 21, 2026 LiteLLM release v1.107.0 Compromised (LiteLLM, last updated 2026-10-21 20:30 UTC).
Advisory: OSV MAL-2026-2144. Discussion: Hacker News thread (548 points on 2026-10-22).
I did not install, download, or run the malicious package. The runs below use harmless stand-ins and the public PyPI index. Attack details are credited to the sources above.
On October 21, litellm==1.107.0 was on PyPI for about 100 minutes (11:39 to 13:23 UTC) with a credential stealer and a backdoor inside. The v1.107.0, latest, main-latest, and main-stable Docker tags pointed at a poisoned image from 11:46 to 14:20 UTC. The detail almost every post repeats is that you didn't have to import litellm to get hit. Installing it was enough, and after that, any Python process on the host ran the payload. That includes pip itself.
That detail changes how you check for it and how you clean up. Below: the mechanism on a harmless stand-in, a scanner that reads environments without starting them, and a pinning setup that would have skipped this release.
The mechanism: three files at the site-packages root
SafeDep's analysis says each of the seven wheels carried three extra files under litellm-1.107.0.data/purelib/. When a wheel has a <name>.data/purelib/ folder, pip copies its contents to the root of site-packages, next to every other package. The files were:
-
cook.pth: one line,import cook -
cook.py: the stealer, wrapped inexec(zlib.decompress(b64decode(...))) -
sitecustomize.py: a daemon that, every hour, read a server address from a GitHub file and ran whatever code that server returned
Both hooks rely on documented Python behavior. The site module docs say that at startup, lines in a .pth file that start with import plus a space or tab "are executed with exec()", and that site then tries to import a module called sitecustomize. Neither depends on what your code imports. Only -S, which turns off site entirely, stops them.
SafeDep found the only visible change was one build-config line (data = "cook" under [tool.maturin]); the sdist omitted the cook/ folder. LiteLLM says the v1.107.0 Git tag is clean and the wheels were built from a lookalike repo (Berri-AI/litellm). So reading the tag or the sdist would not have shown you what pip installed.
Run 1: a harmless startup hook
I built a throwaway venv with a .pth containing import demo_hook and a sitecustomize.py; both only append a line to a log. I ran it under Debian's system Python 3.13.5 and a uv-managed CPython 3.10.21 (pth_demo.sh, output trimmed):
=== base interpreter: /usr/bin/python3 (3.13.5)
stdlib sitecustomize that would shadow ours: /usr/lib/python3.13/sitecustomize.py
$ python -c pass -> .pth hook ran
$ python -m pip --version -> .pth hook ran
$ python -I -c pass -> .pth hook ran
$ python -E -s -c pass -> .pth hook ran
$ python -S -c pass -> (nothing ran)
=== base interpreter: uv cpython-3.10.21
stdlib sitecustomize that would shadow ours: None
$ python -c pass -> .pth hook ran / sitecustomize ran
$ python -m pip --version -> .pth hook ran / sitecustomize ran
$ python -I -c pass -> .pth hook ran / sitecustomize ran
$ python -S -c pass -> (nothing ran)
What I took from this:
-
pip --versionruns the hook. That's why the LiteLLM report says "Don't start withpip install." Running pip in an infected venv to "fix" it just runs the stealer again. -
-I(isolated mode) doesn't help. It ignores env vars and the user site directory, but.pthfiles in the environment's ownsite-packagesstill run. Only-Sturned the hook off. -
Whether the
sitecustomizepayload fires depends on your interpreter build. Debian ships its ownsitecustomize.pyin the stdlib directory, which comes earlier onsys.path, so my planted one never loaded. On the uv-managed Python, which has no stdlib copy, it did. I tested this with my dummy file, not the real payload. Don't treat it as protection, because the.pthstealer fired on both.
Run 2: audit environments without starting them
Since starting Python in a suspect environment re-runs the payload, the scanner reads files and never executes the interpreter it's checking. Run it from a clean interpreter with -S -I:
python3 -S -I scan_site.py ~/code ~/.cache /usr/lib /usr/local/lib /opt
scan_site.py (stdlib only, ~120 lines) walks the roots for site-packages/dist-packages and, for each:
- lists every
.pthfile withimportlines, and uses each package'sRECORDto find which distribution installed it (a file no package claims gets flagged) - flags any
sitecustomize.py/usercustomize.pyinsidesite-packages, plus.startfiles, the new startup entry-point format in Python 3.15 (PEP 829) - flags
RECORDentries under*.data/purelib/that land at the site-packages root, which is exactly how 1.107.0 was packaged - checks for
litellm-1.107.0.dist-infoand the two/tmpmarkers listed in the advisories
Against a fixture made only of text files (nothing installed or executed), it found every planted indicator and exited with code 2. On the box itself it checked 8 site-packages directories in 0.27 s and found no indicators. It flagged one legitimate executable .pth: setuptools' distutils-precedence.pth. That's the practical catch. Legitimate packages ship executable .pth lines too (setuptools, editable installs, some coverage and virtualenv tooling), so "any import line" alerts on clean machines. Checking ownership against RECORD is what makes the output short enough to actually read.
Per LiteLLM's report, an indicator confirms exposure but finding nothing doesn't prove you're clean: temp cleanup and fresh containers erase markers. If 1.107.0 ever ran on a host with outbound internet, assume exfiltration worked. From a shell, delete the three files and their __pycache__ entries, kill the daemon holding /tmp/remote_code_loader.lock, rebuild, and rotate everything the host could reach: cloud roles, proxy-DB provider keys, master and virtual keys, the DATABASE_URL password, env-var secrets.
Run 3: what a cooldown would have resolved to
The HN thread kept returning to dependency cooldowns (skip anything uploaded in the last N hours), with the counterpoint that cooldowns also delay urgent fixes. Using PyPI's upload times (plus 1.107.0's 11:39:51 UTC time from LiteLLM's timeline, since it's deleted), cooldown_check.py computes what a resolver would have picked at 12:00 UTC, twenty minutes into the window:
no cooldown -> 1.107.0 (malicious, deleted)
1 hour -> 1.106.2
24 hours -> 1.106.0
7 days -> 1.105.1
It picks the highest allowed version, not the newest upload, because backports like 1.103.7 (09:35 UTC that day) land after 1.106.x. Cross-checks against today's index with uv 0.13.4: uv pip compile --exclude-newer "2026-10-20T12:00:00Z" resolves litellm==1.106.0, matching the 24-hour row, and --exclude-newer "7 days" resolves 1.105.1.
The 1-hour row is lucky timing. Run the same resolver at 12:45 and a 1-hour cooldown lets 1.107.0 through, because it stayed up until 13:23. Twenty-four hours covers this incident with room to spare.
To make that the default for a project, uv takes a relative duration in pyproject.toml. I locked a one-dependency project with this:
[tool.uv]
exclude-newer = "7 days"
uv lock resolved 65 packages, pinned litellm at 1.105.1, and stored exclude-newer-span = "P7D" in uv.lock. Combined with a committed lockfile (or pip install --require-hashes -r requirements.txt for pip users), a new upload can't slip into a rebuild unnoticed. For containers, a cooldown does nothing about a moving tag like latest. Pin the image digest instead.
The coding-agent angle
LiteLLM's working theory (unconfirmed as of its 20:30 UTC update): it observed cases where Claude Code embedded a user's GitHub CLI OAuth token in a response body, those bodies landed in logs of an internet-exposed internal LiteLLM deployment, and the attacker took three developers' tokens from there. Whatever the final finding, the cheap takeaway holds: if your LLM gateway logs full request/response bodies, treat those logs as secrets, redact token patterns, and keep the admin UI off the public internet.
What I didn't test
- The real malicious wheels or Docker images. I never downloaded or ran them. The payload behavior above is from SafeDep and LiteLLM.
- Whether the Debian
sitecustomizeshadowing holds for the realsitecustomize.pypayload, or on macOS/Windows. - The scanner on macOS or Windows paths, conda environments, or inside container images. Export the image filesystem and point the scanner at it; I didn't test that.
- Hash-pinned pip installs and other tools' cooldown settings (pip, Poetry, Renovate). I only ran uv.
Why this matters
Most AI stacks pull in an LLM router or SDK somewhere, often via a framework you didn't pick, and the .pth trick makes its install step part of every Python process on the machine. Scan without starting Python, adopt a 24-hour-plus cooldown with a lockfile, pin image digests, and treat gateway logs as credential stores.
Written by YongBo Yu, AI engineer in Toronto. More at yongbo-yu.vercel.app and GitHub.
來源:DEV Community · dev.to