流水线是真的,那个 fork 不是

LiteLLM 那个恶意的 1.107.0,是由 LiteLLM 自己的发布工作流构建的——只不过它读的是一个名字只差一个连字符的 fork。构建来源证明(provenance)证明的是「哪条流水线跑过」,而不是「它读了哪份源码」,所以今年多数团队采纳的那道控制,会给这件制品签上名。只有一个你手里已经有的摘要,才会拒绝你从未审阅过的字节。

作者 智能体 AI 维基 22 分钟读完

那个恶意的 LiteLLM wheel,是由 LiteLLM 自己的发布工作流构建的。不是仿名抢注,也不是被劫持的维护者账号手动发包——是这个项目真实的、有签名的、留了日志的流水线,只不过它被指向了一个叫 Berri-AI/litellm 的 fork,而不是 BerriAI/litellm。那一个连字符就是全部的教训所在,因为构建来源证明(provenance)证明的是「哪条工作流跑过」,对「它读了哪个仓库」则一个字也没说。凡是把整个 2026 年花在采纳证明机制、当作供应链答案的团队,这次得到的答案都会是「已验证」。

速览

以下时间均为 2026 年 10 月 21 日 UTC,依据 Datadog Security Labs 公布的时间线。从发布到 PyPI 隔离,窗口是 1 小时 41 分;容器镜像在那之后 33 分钟才下架。

时间事件对你意味着什么
11:39恶意的 litellm 1.107.0 发布到 PyPI从此刻起,任何未钉版本的安装都会装到它
约 11:46Socket 标记出这些恶意 wheel检测起作用了;但检测不会替你卸载任何东西
13:20PyPI 隔离 1.107.0新安装停了。已装上的没停
13:53受影响的容器镜像被移除标签 v1.107.0、latest、main-latest、main-stable
How the malicious LiteLLM release was built Stolen GitHub CLI tokens from three engineer accounts dispatch LiteLLM's genuine release workflow. The workflow reads a lookalike fork named Berri-AI slash litellm instead of BerriAI slash litellm, then publishes signed artefacts to PyPI, GitHub Container Registry and Docker Hub under the project's own identity. stolen GitHub CLI tokens from 3 engineer accounts Berri-AI/litellm lookalike fork LiteLLM's own release workflow genuine, logged, signed PyPI litellm 1.107.0 GitHub Container Registry v1.107.0, latest Docker Hub main-latest, main-stable Only the lower-left box does not belong to the project. Every other box is its real infrastructure. A provenance attestation describes the centre box, which behaved correctly throughout.
第一个框之后的每一个框,都是这个项目真实的基础设施。替换发生在输入端——而那里没有任何人在检查。

LiteLLM 是那个挡在模型供应方前面的开源网关,智能体生态里相当大的一部分都在用它——Datadog Security Labs 给出的量级是每周约 2,300 万次下载。被攻陷的这个版本带着一个窃密程序,外加一个处于休眠状态的第二阶段加载器。最后一个已知安全的版本是 1.106.2。这已是该项目 2026 年的第二次被攻陷;今年早些时候 1.82.7 与 1.82.8 两个版本受过影响,而两起事件之间尚未被确立任何关联。

被拿下的是发布路径,不是仓库

按 Datadog 的叙述,攻击者手里握着三个工程师账号的 GitHub CLI OAuth 令牌。他们不需要往真仓库里推一个提交、不需要开拉取请求、也不需要让评审通过。他们需要的只是启动一条工作流——而一枚开发者的 CLI 令牌,就足以构成做这件事的权限。

然后那条工作流把自己的活干得很对。它检出了别人叫它检出的源码、构建、并以项目自己的身份把结果发布到 PyPI、GitHub Container Registry 和 Docker Hub。没有任何东西出故障。唯一没人校验的输入,是「源码」这个词究竟解析到了哪个仓库,而 Berri-AI 与 BerriAI 之间的差别,正是为「扛过人眼扫一遍日志行」而设计的。

正是这一处特定的失效,让这起事件值得你关注,而不只是值得你走一遍轮换手册。劫持维护者账号的攻击,产出的制品签名人是错的。仿名抢注,产出的包名字是错的。而这一次产出的制品,签名人是对的、名字是对的、版本序列是对的,构建日志还显示它来自官方工作流。

这里真正起作用的凭据类别,是开发者本人的 CLI 令牌——也就是躺在三台笔记本上 ~/.config/gh/hosts.yml 里的那个东西。如果你的发布工作流可以被任何持有个人令牌的人派发,那么不管你的分支保护怎么写,你的发布权限就等于团队里最弱的那台笔记本。

哪一道控制会抓住它,哪一道会替它背书

What each supply-chain control verifies Three columns. A floating tag answers which artefact is newest and installed the malicious release. A build provenance attestation answers which pipeline built the artefact and verified successfully. A digest pin answers whether the bytes match the reviewed ones and refused the release. floating tag build provenance digest pin asks: what is the newest build? asks: whose pipeline built this? asks: are these the bytes I reviewed? installed 1.107.0 for 101 minutes verified, and passed the malicious wheel refused: no digest on record for it Two of the three ask about origin. Origin was never substituted, so only the third one moved.
三者里有两者回答的是关于来源的问题。只有第三者回答的是关于字节的问题。

按「它回答哪个问题」给你的供应链控制排序,这起事件就把它们干净地分开了。浮动引用——latest、main-stable、一条没钉版本的 pip install litellm——回答的是「哪个最新?」,而有 1 小时 41 分钟里,诚实的答案就是那个恶意程序。来源证明回答的是「这是不是由该项目的流水线构建的?」,而这里真实的答案是「是」;一个去校验它的验证器会让这件制品通过,并记下一条成功。摘要钉定回答的是「这些是不是我审阅过的那些字节?」,答案是「不是」,于是什么也没动。

这个排序让人不舒服,因为来源证明正是整个行业一直在往上建的那道控制,而它对很大一类攻击确实有价值。它只是不是一道内容检查。一份证明是关于一个过程的陈述,而一个过程可以被瞄向一个新地方,同时丝毫不失其为同一个过程。本站那篇钉版本与校验讲的是这件事的通用版本:一个要经过别人的命名空间才能解析的名字是一种偏好,而一个本身就是内容的摘要才是一把锁。这一次被解析的命名空间,是一个 GitHub 组织名,宽度为一个字符。

推论才是值得你跟平台团队争一争的部分。给一条照旧拉浮动标签的流水线加上来源证明校验,并不会让这条流水线在面对这次攻击时更安全;它只会让这条流水线的日志,对一件它反正都要装上去的制品更有信心。安全来自拒绝未经审阅的字节,而只有摘要在做这件事。

爆炸半径是那个解释器,不是那个网关

载荷往 site-packages 里丢了三个文件:cook.pth、cook.py 和 sitecustomize.py。Python 会在解释器启动时执行 .pth 文件与 sitecustomize.py,这意味着只要那个环境里有任何东西跑了 Python,窃密程序就跑了。不是在某个智能体调用网关时。不是在 litellm 被 import 时。是在 python 或 pip 启动时。

由此有两个后果,而它们都打破了人们在排查依赖事件时惯用的那套直觉。

卸载这个包并不会清掉这个恶意程序。那三个文件待在包的旁边,而不在包的里面,所以 pip uninstall litellm 会把它们留在原处——而执行这次卸载的那个 pip,正好触发了它们。Datadog 给出的处置流程第一步就是手动删掉这三个文件,原因正在此。一个推理成「我们移除了那个坏版本,我们干净了」的团队,其实还在运行它。

可触达集合就是这个环境的整个凭据面。Datadog 的分析列出了:AWS、GCP 与 Azure 的云凭据(含实例元数据);Kubernetes、Docker、Vault 与 Terraform 的密钥;SSH、GPG 与 TLS 私钥;Git、npm 与 PyPI 令牌;.env 文件与 shell 历史;浏览器保存的密码与 cookie;以及 Claude、Codex、Gemini、Cursor 与 Copilot 的供应方 API 密钥与会话。外泄数据用 AES-256 与 RSA-4096 加密,发往 www.litellm[.]dev/healthcheck——一个仿名域名,配一条专门挑来在出口日志里读起来像噪声的路径。

最后那一组值得单独说一句。如果这台 LiteLLM proxy 配了数据库,那么存在它表里的上游供应方凭据也在范围内。这是这个模式的结构性危害,而不是这个产品的一个 bug:「把供应方密钥移出模型可触及的范围、交给出口边界上的一个代理」这条建议是对的,而它同时把你拥有的每一份供应方凭据,集中进了一个进程、一条升级路径。我们把这件事单独写成了当凭据代理本身被攻陷,因为设计上的回应,和事件响应是两件不同的工作。

两小时,比一次安装长,比一次检测短

PyPI 上可装 1 小时 41 分钟,听起来熬得过去——直到你数一数在这么大的窗口里会自动触发些什么。每一个不靠锁文件解析依赖的 CI 作业。每一次拉 latest 的容器构建。每一次启动全新节点、从零安装的自动扩缩容事件。每一个那天早上跑了一次升级的开发者。这些都不涉及任何人决定去采纳一个新版本;它们就是一个平常的周三里的机械运转。

与此同时,检测干得相当漂亮,却帮不了那些已经装上的人。Socket 在发布后约七分钟标记了这些 wheel。那已经接近自动化扫描能做到的下限,而它依然晚于最早的那批安装。这个不对称才是重点:扫描缩短的是下一个受害者的窗口,对窗口之内的任何人毫无作用。这就是穿上供应链外衣的失败趋关闭问题——一个还没标记某个包的扫描器,和一个已经放行了它的扫描器,是分不出来的。

Minutes of exposure, 21 October 2026 (UTC) A timeline from 11:30 to 14:00. The malicious release was installable from PyPI for 101 minutes, from 11:39 until the 13:20 quarantine. The container images stayed available for 134 minutes, until 13:53. Socket flagged the wheels about 7 minutes after publication. Minutes of exposure, 21 October 2026 (UTC) PyPI 101 min images 134 min Socket flag 7 min 11:30 12:00 13:00 14:00 Detection beat the quarantine by 94 minutes and the first installs by none of it.
检测落在第七分钟。隔离落在第 101 分钟。中间装上去的一切,都还装着。

还有一个值得点名的回滚陷阱。一个钉着 main-stable 的团队,没有任何记录能说明自己上周跑的是哪一件不可变制品,于是「回滚到那个已知安全的版本」第一步得先把「那个已知安全的版本」重建出来。这次恢复得最快的团队,是那些锁文件里早就写着 1.106.2 外加一个哈希的团队——也就是说,是那些从来没暴露过的团队。

到底该做什么

大致按「每小时工作量换来多少保护」排序:

  • 按摘要钉定,镜像和 Python 都要。用不可变的镜像摘要而不是标签,并在锁文件里放哈希(pip install --require-hashes,或者用一个会写哈希的解析器)。这是本文里唯一一道真的挡住了这次攻击、而不是在描述它的控制。
  • 把发布工作流锁到一份仓库与分支的允许清单上。这是 Datadog 的建议,也是最贴近根因的修法:一条只能从一个来源构建的工作流,没法被瞄向一个 fork。顺手审一下哪些身份能派发你的那条。
  • 把开发者的 CLI 令牌当发布凭据对待。它们是这次的初始访问点。短生命周期、不要带 workflow 作用域的长期个人令牌,并给能触发发布的那些账号配上硬件第二因子。
  • 在你需要之前就写好「我们装了一个被攻陷的包」这份手册,并且让它的第一步是找出包管理器并不拥有的文件。.pth 这种持久化手法并不新颖,而它恰好挫败了那个最显然的处置动作。
  • 清点你的网关主机能触达什么,而不是你的网关被配置成做什么。该问的是:上面那几组凭据里有哪些活在那个环境里,以及这个答案是否有必要是这样。
  • 从一台干净的机器上做轮换。在被攻陷的主机上轮换凭据,等于把新凭据交给窃密程序。写下来显而易见,压力之下经常被跳过。

也请把这个论证的边界保持诚实。LiteLLM 响应迅速、公开了发生的事,而围绕它的检测生态表现接近其理论最优。这里没有任何一条是「该改用闭源网关」的理由——在闭源网关里,同样的替换只会干脆看不见。该在意的理由更窄、也更耐久:那道本该回答「我能信任这次构建吗?」的控制,回答了「能」——诚实地、针对一个恶意程序——而且它还会再这么回答一次,因为那正是它被设计去断言的东西。

常见问题

哪些 LiteLLM 版本受影响?

只有 PyPI 上的 litellm 1.107.0,外加 GitHub Container Registry 与 Docker Hub 上标签为 v1.107.0、latest、main-latest 与 main-stable 的容器镜像。最后一个已知安全的版本是 1.106.2。

卸载 LiteLLM 足以清理一台主机吗?

不足以。载荷把 cook.pth、cook.py 与 sitecustomize.py 写进了 site-packages,是在包的旁边而不是里面,所以卸载会把它们留下——而 Python 会在每次解释器启动时运行它们,包括执行卸载的那一次。请显式删掉这三个文件,然后重建环境。

一份来源证明会抓住这件事吗?

不会,而这正是核心。那件制品是由该项目真实的发布工作流、以该项目自己的身份构建的;被替换的只有源码仓库。一份构建来源证明会校验成功。

应该轮换哪些凭据?

凡是从受影响环境可触达的一切,都要换,并且要在一台从未暴露过的机器上换:云与编排凭据、SSH、GPG 与 TLS 私钥、Git、npm 与 PyPI 令牌、模型供应方 API 密钥,以及任何存在 LiteLLM proxy 自己数据库里的上游供应方凭据。然后审计这些凭据在窗口期间与之后的使用记录。

第二阶段载荷最终被投递了吗?

就已公开的信息看,没有。sitecustomize.py 会每小时轮询一个 GitHub 仓库里的文件,取一个域名去下载并执行,但那个文件里只有测试值,而该仓库此后已被移除。加载器在那里、也能用;载荷没有在野外被观察到。

这是否意味着自托管网关是个错误的选择?

不意味着。它意味着一个自托管网关是一个凭据集中器,而它的升级路径归你所有,所以摘要钉定与主机层面的凭据卫生是承重的,而不是可选的。托管网关只是把同一份风险挪给了一个你根本没法钉其发布流水线的供应方。

延伸阅读

本站:

来源: