Featured image of post LiteLLM 又被投毒了:恶意代码只藏在 wheel 里,源码和 Git tag 都是干净的,而入口是一段被 AI 编程工具漏进日志的 token

LiteLLM 又被投毒了:恶意代码只藏在 wheel 里,源码和 Git tag 都是干净的,而入口是一段被 AI 编程工具漏进日志的 token

十月二十一号,LiteLLM 又被投毒了。

这已经是七个月里的第二次。三月那次是 1.82.7 和 1.82.8,这次是 1.107.0,在 PyPI 上活了大约两小时(11:39 到 13:23 UTC),Docker Hub 和 GHCR 上那几个浮动标签多活了半小时。安全版本是 1.106.2。

如果你只用一句话概括,那就是「知名开源库又被投毒了」。但我把 LiteLLM 自己的事故公告、Wiz、JFrog 和 Flatt 四家的分析读完之后,数出了三个比事件本身更值钱的细节。它们指向同一件事:我们评估依赖安全的那套直觉,正在失效。

先把事实摆清楚

官方公告给的时间线和范围,我整理成这样:

渠道 受影响的具体对象 存活窗口(UTC)
PyPI litellm==1.107.0,全部七个 wheel 11:39:51 – 13:23
Docker Hub v1.107.0、latest、main-latest、main-stable 11:46:56 – 14:20
GHCR 同上四个标签,含 litellm、litellm-database、litellm-non_root 三个镜像 11:46:56 – 约 13:51

企业客户那条线是安全的。原因有点黑色幽默:他们正在给企业客户发一个「十月二十七日才允许披露」的漏洞修补版本,走的是私有 registry,攻击者没碰那条路。

为什么 LiteLLM 是特别值钱的目标

先补个背景,不写这个,后面「十六类凭据」那张清单的重量出不来。

LiteLLM 是一个统一调用各家大模型 API 的 Python 库兼代理,GitHub 上六万一千多 star、一万两千多 fork。它存在的意义就是让上层代码不用为每家模型写一套适配,所以它天生要同时持有 OpenAI、Anthropic、Google 以及一堆自建模型的 API key。它被 DSPy 这类框架当底层依赖,也被大量团队直接当网关跑。

打它一次,等于把上游所有厂商的钥匙一起拿到手。再叠上它通常跑在有云凭据、有 K8s 权限的机器上,十六类凭据这个数字就不奇怪了。攻击者挑它,跟它代码写得好不好关系不大,跟它站的位置关系很大。

细节一:源码是干净的,恶意代码只在 wheel 里

这是我读完 JFrog 那篇之后停了几秒的地方。

恶意 payload 是三个文件,都放在 site-packages 的根目录:cook.pth、cook.py、sitecustomize.py。1.107.0 的七个 wheel 全都有,覆盖 macOS、Linux、musl Linux 和 Windows。

但它的源码分发(sdist)里没有这三个文件。GitHub 上那个官方的 v1.107.0 tag 里也没有。唯一能看出异常的地方很隐蔽:sdist 的 pyproject.toml 里多了一行 data = "cook",而这行在对应的 Git tag 里不存在。

JFrog 还提了一个更细的点:payload 那几个文件在 wheel 压缩包里的时间戳和元数据,跟正常文件是一致的。这说明它们是在正常的 wheel 构建过程中被塞进去的,不是事后手工重打包加进去的。至于到底是谁改了构建树、怎么改的、又是怎么发出去的,到现在仍然没有答案。

这件事的含义比它听起来重。过去我们判断一个包能不能用,标准动作是去 GitHub 看一眼源码、对一下 tag、或者读一遍 README 和 issue。这次这三个动作全部会给出「干净」的结论。真正被装到你机器上的是 wheel,不是源码,而恶意代码只在 wheel 里。

换句话说,「我读过源码」不再是一句安全声明。

那接下来该看什么?我把这次真正能用的校验动作按成本排了一下。最便宜的是固定版本号,别用范围;往上一层是固定哈希;再往上,是校验 wheel 的 provenance 或者签名,确认它确实出自官方流水线;最后是拿 sdist 和 wheel 做交叉比对,看两边文件是否一致。这次能被查出来,靠的就是最后一层:sdist 里没有那三个文件,wheel 里有。只盯任何一边都会漏。

细节二:入口是一段被 AI 编程工具漏进日志的 token

这次攻击是怎么进去的,官方在 GitHub issue 里自己讲了,讲得相当坦白。

攻击者用了三个工程师的 GitHub 账号,靠的是偷来的 GitHub CLI OAuth token。他们用一个账号触发了 project-releaser 这个发布流水,用另一个账号去批准它。这两个动作凑齐,恶意版本就发出去了。事后涉及的 token 全部吊销。

那 token 是怎么丢的?官方的复盘是这么说的:他们观察到 Claude Code 会把用户的 GitHub CLI OAuth token 嵌进响应体,而在响应体被记录下来的场景里,这个 token 就会出现在 LiteLLM 的日志里。再加上他们内部有一个 LiteLLM 部署,用一个看起来很普通的账号名、暴露在公网,攻击者拿到了这个部署的访问权,从日志里捞到了 token,然后用它打穿了发布流程。

至于攻击者最初是怎么进那个内部部署的,官方说还没确认,怀疑是某个刚修补过的漏洞。

我把这条链子按顺序念一遍:AI 编程工具把凭据写进响应体 → 响应体被记进日志 → 日志留在一个公网可达的内部服务里 → 日志里的 token 足以触发和批准一次官方发布。

这条链上没有任何一环需要「黑客级」的技术。这也是我觉得它最值得写的地方:AI 工具本身正在成为供应链的入口。工具本身没被攻破,问题在于它太能干,顺手就把你手里最有价值的凭据复制到了一个新的地方。

细节三:.pth 加 sitecustomize.py,野外第一例

payload 的执行机制,Wiz 拆得很清楚,而且点名了一个「第一次」。

cook.pth 是 Python 的一个特殊机制:site-packages 目录下的 .pth 文件会在解释器启动时被处理,里面可以写 import 语句。所以 cook.pth 一被执行,就 import 了 cook,cook.py 于是加载进来,自己去混淆、跑窃取逻辑,然后在 /tmp/ 下写一个标记文件。

同一个环境里,Python 启动时还会尝试执行 sitecustomize.py。它做的事是从一个 GitHub dead drop(letters9813/pythonclient-test 仓库里的 domain.txt)取回一个域名,再从那个域名下载第二阶段的 Python 脚本执行。这个文件只在 Linux 和 macOS 上跑得起来。

Wiz 的结论是:同时用 .pth 和 sitecustomize.py 两种手法,是野外第一例。

这意味着什么?只要那个环境里有任何一个 Python 进程启动,payload 就执行。你的代码从头到尾没 import 过 litellm,也没关系。更麻烦的是,pip 本身也是 Python 进程。

所以官方的处置建议里专门有一句:别从 pip install 开始。你在受影响的 venv 里跑的任何 Python,包括 pip,都会再触发一次 cook.pth 和 sitecustomize.py。正确的顺序是先手动删掉那几个文件、杀掉那个常驻的 Python 守护进程,再去装固定版本。官方的判断也很直接:如果 1.107.0 在一台有外网的机器上装过,就当数据已经泄出去了。

窃取的范围值得单独列一下,因为它的宽度就是「AI 时代一个开发机值多少钱」的答案。内部代号 privcollect,十六个模块,覆盖浏览器(Chrome / Firefox / Edge / Safari)的密码和 cookie、AWS / GCP / Azure 凭据、SSH 私钥、环境变量、Kubernetes 的 service account token、LiteLLM 自己的数据库凭据、npm 和 PyPI 的 token、GitHub CLI token、GPG 私钥、TLS 私钥,以及 AI 开发工具的凭据(Claude / Cursor / Copilot / Gemini / Codex)。加密方式是 AES-256-CBC 配 RSA-OAEP,外传目标是 www.litellm.dev/healthcheck,一个冒充官方基础设施的域名。

顺带说一句:这段恶意代码写得很像 AI 写的

这是 Wiz 报告里我最喜欢的一段观察。

他们说 payload 有「强烈的 LLM 辅助开发痕迹」。证据是:变量名啰嗦到不像老手(比如 encrypted_private_key_output)、错误处理一律用大而化之的 try/except 包住、有些代码路径在没测过的平台上静默失败(Windows 上 /tmp/ 根本不存在,fcntl 和 fork 会让远程加载器直接崩)、还有一个定义了却从头到尾没被调用过的死代码模块 exec_py。

Wiz 的总结是:结构看起来像是反复提示、迭代出来的,happy path 能跑,边界很脆,而且没测过。

我不确定这条观察能不能当成定论,但它和另一个方向的证据是吻合的:攻击工具的门槛也在被 AI 拉平。写一个能跨十六类目标收集凭据、做混合加密、再装一个常驻守护进程的 stealer,放在几年前是个需要熟练工的项目。

那三月那次是怎么中的

既然这是第二次,对比一下更有价值。两次的路径完全不同。

三月那次是 1.82.7 和 1.82.8,三月二十四号十点三十九分上线,活了大约四十分钟就被 PyPI 隔离。根因按官方说法,来自他们 CI/CD 安全扫描流程里用到的 Trivy 依赖——那个流水线跑了一个恶意的、没有固定版本的 Trivy 0.69.4,凭据因此暴露。JFrog 把这两次发布记为 XRAY-955589。

payload 的落点也不一样:1.82.7 的恶意代码在 proxy_server.py 里,1.82.8 才加上了 litellm_init.pth 这个启动劫持。而这次是 .pth 加 sitecustomize.py 双管齐下。JFrog 明确说,目前没有证据把两次关联起来,应该当独立事件处理。

三月那次之后,LiteLLM 上了新的 CI/CD v2 流水线,加了隔离环境、更强的安全门、以及发布路径的分离,然后才放出干净的 1.83.0。方向是对的,这次企业私有 registry 没被波及,多少也说明「发布路径分离」这件事真的救了人。

但七个月后又被同一类攻击打穿,只是换了一条路径。我的判断是:发布流水线的加固不是一个能一次做完的项目。你补上一道门,攻击者去试另一道;你锁住一个工具,攻击者去偷一个 token。它更像是一个要持续投入的运行状态。

生态的响应速度,其实比上次快了

有个细节值得记一下。三月那次,包在 PyPI 上活了大约四十分钟;这次是两个小时,暴露窗口反而更长。但发现和处置的速度是反过来的。

这次第一时间把事故公告发出来的是 LiteLLM 自己,公告里直接给了可执行的清理步骤,连「别以 pip 开头」这种顺序问题都写清楚了,还点名感谢了帮他们检测到事件的社区贡献者。Wiz、JFrog、Flatt 三家在当天就出了技术分析,把 payload 机制、IOC、构建产物的差异全拆开了。

暴露窗口变长了,但从「有人发现」到「有可执行结论」这段时间被压得很短。对一个开源项目来说,这种反应能力比事前防御更现实:你没法保证不被攻破,但你能决定被攻破之后多快给出结论。

如果你可能中招,按这个顺序

第一,先判断有没有暴露。pip show litellm 看版本;在文件系统里找 litellm-1.107.0.dist-info、cook.pth、sitecustomize.py 以及对应的 __pycache__ 缓存;容器那边比对镜像 digest,别只看标签。官方也提醒了:找到这些文件能确认暴露,但没找到不能证明干净。

第二,处置顺序别搞反。先删文件,再杀掉那个持有 /tmp/remote_code_loader.lock 的守护进程,最后才装固定到 1.106.2。整个环境重建一遍比原地清理更稳。前面说过了,别以 pip 开头。

第三,只要有外网,就假设已经外泄。轮换清单就是上面那十六类的清单:SSH 私钥、云凭据、K8s 配置、Docker 配置、npm 和 PyPI token、GitHub CLI token、浏览器保存的密码、各类 AI 工具凭据、GPG 和 TLS 私钥。

第四,长期的动作其实只有三条:用 digest,不用浮动标签(这次 latest、main-latest、main-stable 全中);在 wheel 层面做校验,用哈希、provenance 或者签名,别只信源码仓库;CI 里的第三方工具也要固定版本(三月那次就是被一个没固定版本的 Trivy 放进去的)。第三条最容易被忽略,因为安全扫描工具通常被当成「我们自己人」,没人会去怀疑它,但恰恰是它跑在流水线里、拿着发布凭据。把这三条落地,你不一定防得住下一次,但至少能让「我到底装了什么」这个问题有一个可回答的答案。

顺带说一个很便宜、但很有效的习惯

这次三个浮动标签全中:latest、main-latest、main-stable。你在 CI 里写 image: litellm:latest,拉到的是当时那一刻指针指向的东西,而那个指针在攻击窗口里指向了恶意镜像。事后回看自己的配置文件,你找不出任何异常,因为配置里写的就是「最新」。

换成 digest 就没这个问题。摘要是一个内容哈希,指针变了摘要不会跟着变,你拉到的永远是你当初验证过的那一份。代价是每次升级要手动改一次哈希,多了一点点麻烦。

这次事故之后,我觉得这点麻烦完全值得。真正贵的是事后判断「我到底跑的是哪一份」,升级那一步反而便宜。

写在最后

这次的三个细节,其实指向同一个结论:供应链安全里那些我们以为在做校验的动作,正在一个个变成形式。

读源码?恶意代码只在 wheel 里。看 Git tag?tag 是干净的。检查依赖清单?你依赖的那个包本身是好的,出事的是它的构建产物。相信内部服务?那个内部服务恰好暴露在公网,而日志里躺着能开门的 token。

而这次最讽刺的一环,是那个 token 之所以出现在日志里,是因为一个 AI 编程工具太顺手地把凭据复制了过去。我们一边在给 Agent 建身份体系、做凭据隔离,一边把一个会到处搬凭据的工具装进了每个工程师的终端。这两件事得同时做,只做一边都不够。

相关阅读:

By AI博士 万戈