← 返回所有文章
发布于 · 作者 Renaud Deraison

那个版本才发布两小时

10 月 21 日,攻击者拿着从三位 LiteLLM 开发者那里窃取的 GitHub CLI 令牌,把 litellm 1.107.0 发布到 PyPI。每个 wheel 都带着一个在每次 Python 启动时运行的凭据窃密木马,以及一个每小时去拉取远程代码的加载器。PyPI 在 1 小时 43 分钟后将其下架,此前估计已被下载约 49,000 次。在 Bromure Agentic Coding 中,默认开启的存在时间闸门会隐藏任何发布不满两天的版本,所以 Bromure 机器上的 pip 从未见过 1.107.0。

恶意版本在 PyPI 上存在了 1 小时 43 分钟。在 Bromure 的默认设置下,代理所在机器上的 pip 要到它发布两天后才会看到它。

10 月 21 日星期二 11:39 UTC,七个 litellm==1.107.0 的 wheel 到达 PyPI。LiteLLM 是许多团队用来转发模型流量的 Python 库兼代理服务器,大量 AI 工具都内置了它。新版本看上去就像项目自己发布的:项目的发布工作流为它签了名,还附带 SLSA 来源证明。它同时也带着一个凭据窃密木马和一个后门。

PyPI 在 13:23 UTC 将其移除。标签为 v1.107.0、latest、main-latest 和 main-stable 的受感染 Docker 镜像,则在 Docker Hub 上留到了 14:20。Wiz 估计,这段时间里恶意版本被下载了约 49,000 次。LiteLLM 的事件报告要求所有在能访问外网的主机上安装过它的人:“假定数据外泄已经成功”。

这是该项目今年第二次遭到入侵。第一次在 3 月,经由 CI 中一个被投毒的安全扫描工具。那次我们写过应急响应人员如何查错了时间窗口。

攻击者是怎么拿到钥匙的

LiteLLM 团队公布了逐分钟的时间线。攻击者持有三位开发者的 GitHub CLI OAuth 令牌。那个星期一,为了让两位工程师能发布缺陷修复的回移版本,发布权限被放宽了。星期二 10:08,分支保护拒绝了一次对私有发布工作流的修改。五秒钟后,组织两位所有者之一的令牌覆盖了保护,推送了这次修改。修改后的工作流从 Berri-AI/litellm 构建,那是仿冒真正 BerriAI/litellm 的账号;第二位开发者的令牌通过了生产环境的审批关卡。11:53,第三位开发者的令牌删除了工作流的运行记录。

LiteLLM 把令牌的来源追溯到两项发现。一项是其内部 LiteLLM 部署上有一个“意料之外、名字看起来无害的用户账号”,而该部署暴露在互联网上。另一项与编程代理有关。报告称,LiteLLM“观察到在某些特定情况下,Claude Code 会把用户的 GitHub CLI OAuth 令牌嵌入响应正文;在响应正文会被记录的情况下,这些令牌可能出现在 LiteLLM 的日志中”。

记录模型流量的网关,会保留代理写下的所有内容的副本。这一次,副本里有能够发布这个包的令牌。

那些 wheel 做了什么

StepSecurity 的拆解分析解释了为什么 GitHub 上的源代码看起来是干净的。攻击者只在构建配置里加了一行:[tool.maturin] 下的 data = "cook"。它把一个 cook/ 目录复制进每个 wheel。这个目录从未出现在源码发行包中,GitHub 上的 v1.107.0 标签里也没有它的任何痕迹。

每个 wheel 会在 site-packages 中安装三个文件:

  • cook.pth,12 字节:import cook。Python 的 site 模块在每次解释器启动时都会执行 .pth 文件中的 import 行。没有人需要 import litellm,启动 pip 就够了。
  • cook.py,一行经过混淆的代码,解码后约 61,000 字节。这是一个窃密木马,会把输出标记为 "tool": "privcollect",并运行 16 个收集模块。
  • sitecustomize.py,一个会 fork 成独立守护进程的加载器。它每小时从攻击者控制的 GitHub 仓库读取 domain.txt,拉取 http://<host>/codepy,再用 exec() 执行结果。

窃密木马会拿走 SSH 和 GPG 密钥、云凭据和实例元数据、~/.kube/config、Docker 配置、GitHub CLI 令牌、.git-credentials、.netrc、npm、PyPI、Cargo 等仓库的令牌、向下两层的 .env 文件、浏览器登录信息和 Cookie、shell 与数据库的历史记录,以及通过 /proc/<pid>/environ 能读到的每个进程的环境变量。它还列出了 Claude Code、Claude Desktop、Cursor、Windsurf、Kiro、GitHub Copilot、Gemini CLI、Codex、Continue、Aider、Goose 以及另外十几种 AI 工具的认证和配置文件。在运行 LiteLLM 代理服务器的主机上,它会从代理服务器的数据库中读出各提供商的 API 密钥。

窃密木马用 AES-256 加密打包的内容,用攻击者的 RSA-4096 公钥封装密钥,再通过未加密的 HTTP 把结果发送到 http://www.litellm.dev/healthcheck。攻击者在 10 月 13 日注册了这个域名。在服务器返回 200 之前,窃密木马会在下一次 Python 启动时再运行一次。

加载器的 domain.txt 在发布时是空的,所以第二阶段从未启动。背后的 GitHub 账号已被删除,但受感染的主机仍在每小时轮询那个网址。如果有人重新注册这个用户名,所有这些主机都会运行对方的代码。

为什么编程代理格外暴露

在 Python 项目上工作的代理,会因为 README 或错误信息的提示而运行 pip install -U litellm。它会运行 pip install -r requirements.txt,而其中的 litellm 那一行没有锁定版本。它会新建一个 virtualenv,安装最新的东西,不分时段,也没有人去看装进来的是哪个版本。

星期二 11:39 到 13:23 UTC 之间,“最新”就是 1.107.0。在代理的主机上,此后整个会话里,代理每调用一次 python、pytest 和 pip,cook.pth 就运行一次。开发者自己的凭据在窃密木马的触及范围内,而代理的登录信息就在它的目标清单上。

Bromure 从未提供 1.107.0

Bromure Agentic Coding 在你 Mac 上的 Linux 虚拟机里运行 Claude Code、Codex、Grok、Kimi 和 omp。虚拟机发出的每一次包下载都会经过 Mac 上的代理服务器,它在响应到达 pip 之前应用该机器的供应链策略。虚拟机无法修改这项策略。虚拟机内的 pip.conf 只能让它更严格。

这项策略中有一层在每台新机器上都是开启的:存在时间闸门,最短存在时间为 2 天。当 pip 向 PyPI 的 simple 索引查询 litellm 有哪些版本时,代理服务器会在 pip 读取之前,把所有比阈值新的文件从列表中去掉。星期二 11:45 UTC,一台运行 pip install -U litellm 的 Bromure 机器拿到的列表止于 1.106.2,即最后一个干净的版本,并安装了它。对代理来说,1.107.0 还不存在;等到闸门本该打开时,PyPI 早已把它删除。

如果代理锁定了版本,下载文件本身就是第二道检查。代理服务器会去 PyPI 查询该文件的发布时间,并以 HTTP 451 拒绝下载:

$ pip install litellm==1.107.0
ERROR: HTTP error 451 while getting https://files.pythonhosted.org/.../litellm-1.107.0-...whl
Bromure Supply-Chain Security blocked this request: pypi package [email protected]
published 1 hour ago — policy requires 2 days minimum

pip 会原样打印出原因,所以代理也会读到,而且往往会自己改为锁定一个较旧的版本。

闸门还没打开,版本早已消失10/21 08:0010/22 00:0010/23 00:0010/23 14:00PyPI 上的 litellm 1.107.011:39 至 13:23 · 约 49,000 次下载Docker Hub 标签留到 14:20Bromure 存在时间闸门,默认 2 天Bromure 机器上的 pip 把 1.106.2 视为最新的 litellm10/23 11:39 闸门才会打开,已无可提供已移除时间取自 LiteLLM 事件报告,下载数为 Wiz 的估计。
PyPI 上的 litellm 1.107.0,10 月 21 日至 23 日,UTC。恶意版本上线了 1 小时 43 分钟,在 Bromure 默认的存在时间闸门让 pip 看到它之前 46 小时以上就已消失。

闸门不需要安全公告、不需要恶意软件特征,也不需要有人点名这个包。包仓库和研究人员通常在几小时内就会发现并下架被劫持的版本,而在日常开发中,等两天几乎没有成本。如果你自己的团队发布的包必须一发布就能安装,就在机器的 Supply Chain 面板里,把它以 pypi:yourpkg 的形式加入 Exempt packages。PyPI 上的其余包仍然要等两天。

同一个面板还能再加几层。OSV 检查会拒绝任何已有公开公告、且严重程度达到你所选级别以上的版本。socket.dev 过滤器会拦截被标记为恶意软件的包。如果你开启的某个信誉来源给不出判定,下载会暂停,Bromure 会就那个包和版本询问你。

日志里的令牌是替身

这次发布之所以能够发生,只是因为攻击者持有真正的 GitHub 令牌。LiteLLM 认为,令牌来自一个记录模型流量的部署,而 Claude Code 曾在那里把一位开发者的 GitHub CLI 令牌写进响应。

在 Bromure 机器里,那个令牌是替身。虚拟机启动时,Bromure 会把一个假的 ghp_ 令牌写入 gh 的配置和 ~/.git-credentials,并导出为 GH_TOKEN。这个假令牌的前缀和长度都正确,所以 gh 会接受它。真正的令牌留在 Mac 上。当请求离开虚拟机前往 GitHub 时,代理服务器会在传输途中把假值换成真值,而且只针对 GitHub 的主机。

代理仍然可能把令牌粘贴进回复里,但它手上只有替身可贴。模型提供商和中间任何网关的日志,记下的也都是替身。在别人的笔记本上重放它,GitHub 会拒绝,因为那里没有 Bromure 代理服务器替它换值。

发布权限也受到同样的保护。在机器的 Guardrails 面板中,每个代码托管平台的令牌都有一条写入策略,新机器一开始就是 Prompt before write。对发布工作流的 force-push,或关闭分支保护的 API 调用,都要等待你 Mac 上显示 METHOD /path 的对话框,由你来决定。

privcollect 会找到什么

假设 1.107.0 真的进入了一台机器,因为有人关掉了闸门或者添加了豁免。窃密木马的清单很长。在 Bromure 虚拟机里,它能打开的东西大多装的是替身:

  • GH_TOKEN、ANTHROPIC_API_KEY 以及机器拿到的所有其他凭据,在窃密木马通过 /proc 读取的每个进程环境中,都是假的。
  • ~/.git-credentials、~/.docker/config.json、~/.kube/config、~/.aws/config 和 MCP 配置里装的都是假值。代理自己的登录文件也一样:Claude、ChatGPT、Grok 和 Kimi 的订阅都在 Mac 上登录和续期。
  • 模型提供商的密钥从不进入虚拟机,所以根本不存在一个装满上游密钥、可供读取的代理服务器数据库。Bromure 的代理服务器在 Mac 上把密钥加上去,正如我们在 provider 在密钥所在的地方运行中所介绍的。
  • SSH 密钥通过通往 Mac 的 agent 桥接来签名。私钥的字节从不进入虚拟机,所以 ~/.ssh 里没有东西可拿。

替身只有经过那台机器的代理服务器、发往它被签发的目标主机时才有效。攻击者可以解密打包文件、重放其中的每一个值,而 GitHub、AWS 和 Anthropic 会逐一拒绝。

那个 POST 无处可去

打包的内容会以未加密的 HTTP POST 发往 www.litellm.dev,一个这台机器从来不需要的域名。在 Guardrails 面板里把 Unmatched traffic 设为 Deny,虚拟机就只能连到你列出的主机:

allow web pypi.org
allow web files.pythonhosted.org
allow web github.com
allow web api.anthropic.com
default deny

Mac 上的交换机拒绝连接,Security Timeline 会显示一行红色的 deny,附带主机和端口。加载器的第二阶段也以同样的方式失败:无论 domain.txt 指向哪台主机,http://<host>/codepy 都不在列表上,所以每小时一次的 exec() 没有东西可执行。

在使用 OpenShell + Sentry 沙箱引擎的工作区里,在那一行点击 Show the Whole Chain,就会打开这条被拦截流量的来龙去脉。它会显示代理的 pip install 工具调用和模型给出的理由,接着是 cook.pth 在其中启动窃密木马的 pip 进程,然后是被拒绝的连接。你能看到把包拉进来的那条指令,以及模型自称执行它的理由。

四个步骤,Mac 上的四道检查攻击,10 月 21 日1 · 日志里的令牌gh OAuth 令牌从被记录的模型流量中被复制2 · 工作流被强推保护被覆盖,从 Berri-AI/litellm 构建3 · PyPI 上的 wheellitellm==1.107.011:39 至 13:23 UTC4 · cook.pth 运行收集后 POST 到www.litellm.dev在 Bromure 机器里替身令牌虚拟机里是假的 ghp_;日志留下的也是假值。换个地方 GitHub 就拒绝。写入策略推送与分支保护的变更,都要等待 Mac上的对话框。存在时间闸门,默认开启pip 解析到 1.106.2;锁定的 1.107.0收到 HTTP 451。假值与默认拒绝收集到的全是替身;POST 和 /codepy都被交换机拒绝。
litellm 1.107.0 的攻击链,以及 Bromure 机器在哪里切断它。四道检查都在 Mac 上运行,位于包安装所在的虚拟机之外。

“我们装了吗?”一个过滤器就有答案

星期二下午,每个应急响应团队的第一个问题都一样:我们哪些机器运行过 1.107.0?LiteLLM 提醒,找不到 cook.pth 什么也证明不了:没有它,不代表主机是干净的。

Bromure 会记录代理服务器看到的每一次包下载,不论策略如何设置,也不论是否开启了任何检查。打开 Window → Security Timeline…,在引擎过滤器中选择 Supply chain,搜索 litellm。每一行都写明包和版本 pypi · [email protected],以及它的处理结果。记录在 Mac 上保留 90 天,Export… 可以把它保存为 CSV,附到事件工单上。在已向 bromure.io 注册的 Mac 上,每次下载也都是一个 supply_chain.fetch 事件,带有生态系统、包、版本和结果。管理员无需询问任何人,就能列出组织中所有下载过 [email protected] 的机器。

中招之后的清理也很快。LiteLLM 的指南说,要在运行 pip 之前先终止持有 /tmp/remote_code_loader.lock 的守护进程,从每个 site-packages 中删除恶意文件,重新构建并轮换密钥。在 Bromure 里,Reset disk 会从基础镜像重新克隆系统盘,Rewind home… 会把主目录恢复到安装之前的检查点。那些你原本需要匆忙轮换的密钥都是替身。真正的密钥从未离开 Mac。

新版本要等两天

存在时间闸门在每台新机器上都是开启的。从 PyPI、npm、Cargo、RubyGems 和 Packagist 安装时,会解析到比阈值更旧的最新版本。锁定一个太新的版本,会收到附带原因的 451。

日志里的令牌是假的

GitHub、云、包仓库和模型的凭据都以替身的形式进入虚拟机。代理服务器在 Mac 上填入真值,只针对该凭据自己的主机。

外泄需要一条路

把未列出的流量设为 deny,发往上周才注册的域名的 POST 就会死在交换机上,并显示为一行红色记录。

每次安装都有记录

Security Timeline 列出每台机器 90 天内下载过的每个包和版本。已注册的 Mac 会把同样的事件发送给组织。

先让包仓库跟上

攻击者只花了几分钟就发布了 1.107.0。PyPI 花了 1 小时 43 分钟把它下架,对包仓库来说算快的。你的代理整天都在安装包,不分时段,而且拿最新的,所以它会落进这样的时间窗口。在 Bromure 机器上,它会等两天。到那时,PyPI、Wiz、StepSecurity 和 LiteLLM 团队自己都已经检查过这个版本,坏的早就不见了。

关于本月早些时候盯上编程代理凭据的窃密木马,请阅读窃密木马打包带走的是替身。至于令牌最初是怎么跑进对话记录和模型日志的,请阅读令牌留在了对话记录里。