两个小时四十分钟
10 月 21 日,一个每周下载量约 2300 万次的 Python 包,把自己签过名的恶意版本推上了 PyPI 和 Docker Hub。PyPI 上 11:39 到 13:23,Docker Hub 上到 14:20——这两个多小时里,LiteLLM 1.107.0 是一颗已经插上引信的雷。
这不是「依赖里有个漏洞」。漏洞是别人在你用的代码里留了道口子;这次是发布者用自己的签名、自己的流水线、自己的生产审批,把一个带后门的包正式发了出去。下游再怎么「只用官方源」,也防不住这个。
时间线:分支保护是怎么被关掉的
攻击者手里有三个不同开发者的 GitHub CLI 凭据。注意这三个凭据的分工——它比漏洞本身更值得看:
| UTC | 发生了什么 |
|---|---|
| 10:08:29 | 私有发布 workflow 的改动被分支保护拦下,理由很标准:没过 PR 与状态检查 |
| 10:08:34 | 一个有权限的开发者凭据覆盖保护,强推 commit 9cf97e93 |
| 10:46:39 | 改过的 workflow 跑了一次 staging,源码指向 Berri-AI/litellm |
| 11:08:53 | 生产发布流水线启动 |
| 11:11:18 | 第二个开发者凭据通过了生产审批 |
| 11:39:51 | 七个 1.107.0 的 wheel 上传到 PyPI |
| 11:46:56 | 同名镜像开始发布,latest 等三个标签一起被指向恶意镜像 |
| 11:53:49 | 第三个凭据删掉了 production 与 staging 的 workflow 记录 |
| 13:23 | PyPI 移除该版本 |
| 14:20 | Docker Hub 上的相关标签全部撤下或指回干净镜像 |
Berri-AI/litellm 和官方的 BerriAI/litellm,差别只有一个连字符。官方的源码 tag 是干净的——恶意文件只存在于那个仿冒仓库构建出来的产物里。
三个凭据分别干三件事:改 workflow、过审批、删日志。三件凑齐,一套「双人审批 + 分支保护」就形同虚设。这也解释了为什么事后调查先卡在「凭据怎么丢的」这一步:流程本身没坏,是流程依赖的那个假设坏了。
为什么它能活过 pip uninstall
恶意 wheel 往 site-packages 里塞了三个文件:cook.pth、cook.py、sitecustomize.py。
关键在 .pth。Python 启动时,site 模块会读 site-packages 下的 .pth 文件,凡是以 import 开头的行都会被当成代码执行。这套机制本来只是给包加搜索路径,副作用是它变成了一个「解释器一启动就执行」的持久化位置。
sitecustomize.py 更直接:site 初始化时会自动导入它。于是只要这个环境里有 Python 进程起来,载荷就跑——哪怕你从头到尾没 import 过 litellm。
所以官方处置说明的第一句是「别先跑 pip install」。pip 自己就是 Python 程序,在受污染的环境里执行任何 Python 命令,都等于把载荷再点一次火。删掉 litellm 包目录同样没用,那三个文件不在包目录里。
第二阶段更省事:sitecustomize.py 每小时去一个 GitHub 仓库读 domain.txt,拿到域名就下载代码执行。本次那个文件里只放测试值,所以没观察到真正的二次载荷。但设计意图很清楚——攻击者可以随时换远程内容,不必再碰一次发布流程。
它到底在偷什么
按分析报告的分类,清单长得有点吓人:
- 云与编排:AWS / GCP / Azure 凭据,包括实例元数据;Kubernetes、Docker、Vault、Terraform 的密钥;
- 开发者:SSH、GPG、TLS 私钥,git / npm / PyPI 的 token,
.env文件,shell 历史; - AI 工具:各家模型 API key,以及 Claude、Codex、Gemini、Cursor、Copilot 这些工具的登录态;
- 浏览器:保存的密码与 cookie;
- LiteLLM 自己:如果配了数据库,会直接查代理存上游 key 的那几张表。
看这份清单就明白攻击者图什么了。LiteLLM 在很多团队里是「所有模型 key 的唯一入口」。投毒一个网关,等于一次拿到一屋子钥匙——这比挨个偷开发机划算得多。
如果这台机器装过 1.107.0
顺序别跳,跳一步就可能白做:
- 先隔离(断网或停容器),不要在环境里执行 python / pip;
- 删掉 site-packages 下的
cook.pth、cook.py、sitecustomize.py,连__pycache__里的对应缓存一起; - 杀掉那个脱离终端的 python 守护进程,它握着
/tmp/remote_code_loader.lock; - 重建 venv 或镜像,pin 回
litellm==1.106.2;能重建就别就地修; - 把这台机器能碰到的凭据全部轮换,包括 LiteLLM 的 master key 和上游 key;
- 翻云审计日志:安装时段有没有
GetCallerIdentity之类的调用,之后有没有人从你的网络外面用这套凭据。
取证时有个小技巧:python -S 会跳过 site 初始化,.pth 和 sitecustomize 都不会被执行。查文件的时候用它起解释器,能少踩一次雷。
三条能落地的纪律
第一条,生产环境别让包管理器自己决定装什么:
# requirements.txt 里每个包都带 --hash,装不上就失败,而不是装个新版本
pip install --require-hashes -r requirements.txt
第二条,镜像用 digest,不用 tag。这次 Docker Hub 上的 v1.107.0、latest、main-latest、main-stable 四个标签一起中招——「跟 latest」在供应链攻击面前等于没有版本控制,而同一个 tag 前后指向不同内容这件事,只有 digest 能戳穿。
第三条,把「密钥中心」单独划区。LiteLLM、CI runner、私有包代理这类服务,天然握着比别人多的凭据,就不该和业务跑在同一台机器、共用同一套云角色。划区的意义不是让它不出事,而是让它出事时损失可数。
一年两次,问题不在某个人
这是 LiteLLM 在 2026 年的第二次投毒事件,前一次是 1.82.7 / 1.82.8。同一个项目一年两次,与其归因于某个维护者手滑,不如看结构:发布凭据的集中度太高,而三道关卡(改流程、过审批、删日志)都建立在「凭据没被偷」这一个假设上。假设一破,层层审批只是让攻击者多花十分钟。
对自建 LiteLLM 的人,最该改的不是「以后小心点」。先把它的定位改掉:它不是普通依赖,它是密钥仓库。密钥仓库要有自己的网络段、自己的凭据集、自己的重建流程。剩下的,交给锁版本和轮换周期。










暂无评论内容