Python 依赖防投毒:照 LiteLLM 事件做一遍自查,5 步实测

10 月 21 日,大模型网关 LiteLLM 的 1.107.0 在 PyPI 上挂了约 2 小时,里面带窃密木马。官方的处置说明第一句是「先别运行 pip」:恶意包往 site-packages 里塞了 cook.pth 和 sitecustomize.py,之后任何一次 Python 启动都会再执行一遍,pip 也不例外。我们在本机按这次事件的路数从头做了一遍自查和加固,下面每条命令都跑过。
先搞清木马是怎么常驻的:.pth 每次启动都执行
Python 启动时会读 site-packages 里所有 .pth 文件。Python 官方文档写明:以 import 加空格开头的行会被 exec() 执行。这个机制本来给少数包做初始化,攻击者用它让木马随 Python 启动。
我们在一个干净的 venv 里放了一个无害的 .pth,只打印一行字:
python3 -m venv v && . v/bin/activate
SP=$(python -c 'import site;print(site.getsitepackages()[0])')
echo 'import sys; sys.stderr.write("[pth] 我又执行了\n")' > $SP/zz_demo.pth
python -c pass # 打印
pip --version # 打印,pip 本身也是 Python
python -I -c pass # 隔离模式,照样打印
python -S -c pass # 不加载 site,不打印
结果和 LiteLLM 公告说的一致:python -c pass、pip --version、连 -I 隔离模式都会执行它,只有 -S(不加载 site 模块)能绕开。我们这台 Debian 的 venv 里它每次还都打印了两遍,说明一次启动可能执行不止一次。Python 3.15 已经把 .pth 里的 import 行标为弃用,不过现在主流的 3.12、3.13 照跑。
中招的环境里,你为了查问题敲的第一条 pip 命令,就是在帮木马再跑一遍。
第 1 步:用 python -S 扫一遍,别碰 pip
下面这个 26 行的脚本用 python3 -S 运行,只读文件不导入任何包。它找四类东西:已公告的恶意版本目录、cook.pth/sitecustomize.py 这类启动钩子、带 import 行的 .pth、LiteLLM 木马留下的锁文件。
#!/usr/bin/env python3
"""用 python3 -S 运行:不加载 site,避免顺手执行可疑 .pth"""
import glob, os, sys
BAD = {"litellm-1.107.0.dist-info"} # 已公告的恶意版本,可自行追加
SUSPECT = {"cook.pth", "cook.py", "sitecustomize.py", "usercustomize.py"}
roots = sys.argv[1:] or ["/usr/lib", "/usr/local/lib", os.path.expanduser("~"), "/opt", "/workspace"]
hits, seen = 0, set()
for root in roots:
for d in glob.glob(f"{root}/**/*-packages", recursive=True):
if os.path.realpath(d) in seen:
continue
seen.add(os.path.realpath(d))
for name in sorted(os.listdir(d)):
p = os.path.join(d, name)
if name in BAD:
print("[恶意版本]", p); hits += 1
elif name in SUSPECT:
print("[启动钩子]", p); hits += 1
elif name.endswith(".pth"):
code = [l.strip() for l in open(p, errors="ignore")
if l.startswith(("import ", "import\t"))]
if code:
print(f"[可执行 .pth] {p}\n {code[0][:100]}"); hits += 1
if os.path.exists("/tmp/remote_code_loader.lock"):
print("[锁文件] /tmp/remote_code_loader.lock 存在,用 fuser 查进程"); hits += 1
print(f"共 {hits} 处需要人工看")
在本机所有 Python 环境上跑一遍用时 1.2 秒,报出 4 处,逐个看过都是正常包:coloredlogs、setuptools 的 distutils-precedence.pth(两个环境各一个)、matplotlib 的命名空间包。我们又造了一个模拟中招的 venv(放入同名空文件和 1.107.0 的 dist-info 目录),4 处全部报出。真查到了,按官方步骤在 shell 里删文件、用 fuser /tmp/remote_code_loader.lock 找后台进程杀掉,再从零重建环境。
第 2 步:加冷却期,新版本先晾三天
恶意版本在 PyPI 上只活了 2 小时,中招的基本是这 2 小时里没钉版本、直接装最新版的人。所以最省事的防线是只装发布满几天的版本。pip 26 和 uv 都有现成参数,我们 10 月 22 日用 LiteLLM 实测:
pip install --dry-run litellm # → 1.106.2
pip install --dry-run --uploaded-prior-to P3D litellm # → 1.106.0
uv pip compile --exclude-newer "3 days" - <<< litellm # → 1.106.0
1.106.2 是 10 月 21 日 18:44(北京时间)上传的,离投毒只差一个小时,三天冷却期会连它一起跳过。想长期生效,pip 设环境变量 PIP_UPLOADED_PRIOR_TO=P3D,uv 设 UV_EXCLUDE_NEWER="3 days" 或写进配置的 exclude-newer。
| 防线 | 挡得住 | 挡不住 |
|---|---|---|
| 冷却期 3 天 | 刚发布、还没被发现的恶意版本 | 潜伏超过 3 天的投毒;紧急安全补丁也会晚 3 天 |
| 哈希锁定 | 同版本号的包被替换、镜像源被篡改 | 你锁的那一刻就锁进了恶意版本 |
| 镜像钉 digest | latest 等标签被改指向 | 钉的 digest 本身就是坏的 |
| 日志扫 token | 凭证已经躺在日志里 | 还没写进日志的泄露途径 |
第 3 步:哈希锁定,被换掉的包直接装不上
echo 'requests==2.32.5' > req.in
uv pip compile --generate-hashes req.in -o req.txt
pip install --require-hashes -r req.txt
生成的 req.txt 给 5 个包(含间接依赖)写了 180 条 sha256。我们把 requests 的哈希全部改掉一位再装,pip 报 THESE PACKAGES DO NOT MATCH THE HASHES 并以退出码 1 结束,一个包都没装。
第 4 步:Docker 镜像钉 digest,不用 latest
这次 Docker Hub 和 GHCR 上被改指向的正是 latest、main-stable 这些标签。写进部署文件的应该是 镜像名@sha256:…。不装 Docker 也能查标签当前指向哪个 digest:
TOK=$(curl -s "https://ghcr.io/token?scope=repository:berriai/litellm:pull" | jq -r .token)
curl -sI -H "Authorization: Bearer $TOK" \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
https://ghcr.io/v2/berriai/litellm/manifests/main-stable | grep -i digest
10 月 22 日 09:05 我们查到 main-stable 指向 sha256:7294b90f…,和公告里的恶意 digest sha256:1bbcb144… 不同。拿到可信的 digest 后写进 compose 或 k8s 配置,升级时再人工换。
第 5 步:去日志里找泄露的 token,Agent 用户尤其要查
这次攻击是从日志开始的。LiteLLM 官方称,攻击者进了一台暴露在公网的内部 LiteLLM 实例,从日志里拿到 3 个开发者的 GitHub CLI OAuth token;官方还观察到 Claude Code 在某些情况下会把 GitHub CLI token 写进响应内容,开着响应日志就会落盘。用编程 Agent、又在前面挂了网关记日志的团队,可以这样扫一遍:
#!/usr/bin/env bash
# 用法:bash token-scan.sh <日志目录> 只打印文件名、行号和前 8 位
rg -n -o --no-heading --hidden --no-ignore -z \
-e '(gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,})' \
-e 'sk-ant-[A-Za-z0-9_-]{20,}' -e 'sk-(proj-)?[A-Za-z0-9_-]{32,}' \
"${1:-.}" | sed -E 's/(:[0-9]+:.{8}).*/\1…(已截断)/'
前缀规则来自 GitHub 官方文档:ghp_ 是经典个人令牌,gho_ 是 OAuth 令牌(gh 登录拿到的就是这种),github_pat_ 是细粒度令牌。我们用一份假日志测试,三处位置都扫出来了。扫到了就立刻吊销并轮换,再把网关的请求/响应正文日志关掉或做脱敏。
常见坑
- 只改一个哈希不会报错。一个版本通常有多个文件(wheel 和源码包),哈希列表的意思是「任意一个匹配就放行」。我们第一次只改了 requests 两条哈希中的一条,结果照常安装成功。要靠工具整体生成和更新锁文件,别手改。
- rg 默认跳过隐藏目录和压缩文件。不加
--hidden --no-ignore时,放在.archive/里的旧日志扫不到(实测 2 处对 4 处);轮转出来的.gz要加-z才会解压搜索。 - 可执行 .pth 不等于木马。setuptools、coloredlogs、matplotlib 都会装这种文件。扫出来要看内容和来源包,别一删了之,删错会让环境出问题。
- 冷却期会拖慢安全补丁。依赖曝出漏洞需要马上升级时,对单个包临时放开(uv 有
--exclude-newer-package),别把全局冷却关掉。
完成标准
python3 -S pth-scan.py报出的每一处你都能说清来自哪个包。- CI 和服务器上装依赖都带
--require-hashes(或用 uv.lock),并设了 3 天左右的冷却期。 - 部署文件里没有
:latest,镜像全部钉了 digest。 - 网关和 Agent 日志扫过一遍 token,扫到的已经轮换。
以上在 Debian、Python 3.13.5、pip 26.2.2、uv 0.13.4 上实测(2026-10-22)。我们没有下载或运行真实的恶意包,木马行为以 LiteLLM 官方公告为准;Docker 拉取镜像未实测(本机未装 Docker)。
最后
今天先做一件事:在你跑 Agent 或网关的机器上用 python3 -S 跑一遍上面的扫描脚本,看看报出的东西你是否都认识。事件经过可以看我们的快讯 LiteLLM 1.107.0 被投毒。