深度解析塑造未来的技术文章。

PyPI 供应链攻击:LiteLLM 给我们的教训

LiteLLM 在 PyPI 上的投毒版本,每个 wheel 都夹带了凭证窃取程序。哈希锁定和 lockfile 能挡住什么,又挡不住什么?

传送带上一排密封箱子,其中一个被篡改的箱子正向其他箱子泄漏电线。
一个被攻破的包在传送带上悄无声息地污染了下游的所有东西。

三个文件,就是这起 Python 供应链攻击史上最恶劣事件之一的全部痕迹:一个 12 字节的 cook.pth、一个 22 KB 的 cook.py,以及一个 25 KB 的 sitecustomize.py。LiteLLM 1.107.0 发布到 PyPI 时,这三个文件被打包进了它的全部七个 wheel 里。由于 Python 的启动机制,它们会在受感染主机上的每一个 Python 进程中执行:你的应用、你的 CI 运行器、pip 本身,甚至编辑器里的语言服务器。你不需要 import litellm,也不需要运行它,只要启动任意一个 Python 解释器就够了。

LiteLLM 团队确认了这次入侵,PyPI 也在几小时内下架了该版本,但这段时间已经足够长。恶意 Docker 镜像(包括 latest 标签)大约存活了两个半小时。如果你的 CI 在此期间拉取了镜像,窃密程序早已收割了 SSH 密钥、云凭证、浏览器密码、CI/CD 密钥和 AI 工具令牌,加密后发送到攻击者控制的域名。这不是仿冒包,也不是长得像的山寨包,而是真正的包,由真正的流水线构建,经过真正的发布流程签名。这一点应该让你重新思考对依赖的信任方式。

三个文件如何劫持官方发布流水线

攻击者并没有入侵 PyPI,也没有把提交偷偷塞进公开仓库,上游 GitHub 源码是干净的。根据 LiteLLM 的事件报告,攻击者掌握了三名开发者的 GitHub CLI 凭证,显然是从一个暴露在公网上的 LiteLLM 内部部署的日志中窃取的。攻击者借助一个组织所有者的令牌绕过了分支保护,并把私有发布工作流指向了一个仿冒仓库 Berri-AI/litellm,而不是 BerriAI/litellm。另一名开发者的凭证则满足了审批关卡的要求。随后,官方流水线就构建、签名并发布了攻击者的产物。

真正的注入手法小得惊人,只在 sdist 的 pyproject.toml 里多加了一行:

[tool.maturin]
python-source = "."
data = "cook"            # <-- injected, copies cook/ into the wheel
python-packages = ["litellm_cli"]   # legitimate, present upstream

这条 data = "cook" 指令让 maturin 把一个 cook/ 目录打包进 wheel 的 purelib 路径,pip 随后就乖乖把它装进了 site-packages。sdist 本身并没有包含这个目录,所以即便有人草草看一遍发布内容,也只会看到一行看不懂的配置,别无其他。载荷藏在构建产物里,而不是源码里。就算去翻 git 历史——大多数人视为事实依据的东西——也什么都看不出来。

为什么 .pth 文件和 sitecustomize.py 是恶意软件的温床

Python 启动流程中的两项机制让这次攻击格外致命,而且说实话,大多数 Python 开发者从没想过它们。第一,site-packages 中的任何 .pth 文件都会在解释器启动时被处理,其中以 import 开头的行会被直接执行。这个 12 字节的 cook.pth 只有一行——import cook——就足以让窃密程序在机器上的每个 Python 进程中运行。第二,只要在路径中找到,Python 启动时会自动导入名为 sitecustomize 的模块。攻击者投放了自己的版本,它会派生出一个独立运行的守护进程,每小时回连一个托管在 GitHub 上的文本文件,下载攻击者指定的任何代码,并通过 exec() 执行。

这比一个典型的恶意包(比如 setup.py 里埋了毒,或者 __init__.py 里有恶意导入)严重得多。那些代码只会在安装或导入该包时执行,而这次只要你在 Python 里运行任何东西就会触发。你在 CI 里跑的 pytest,你为了排查问题敲的 pip list,IDE 里的 black 格式化器。每一次调用都会重新布下陷阱。

值得停下来看看这个窃密程序到底收集了什么,因为这份清单几乎就是现代开发者工作生活的全景图:AWS、Azure 和 gcloud 凭证、kubeconfig、GitHub CLI 令牌、SSH 和 GPG 密钥、浏览器密码库、shell 历史记录、来自 GitLab Runner 和 Jenkins 的 CI 密钥、Terraform 凭证,还有通过 /proc/<pid>/environ 读取的其他进程的环境变量、AWS 实例元数据凭证,以及极具讽刺意味的 Claude Code、Cursor、Copilot 等十几种 AI 编程工具的令牌,外加 LiteLLM 自身代理数据库里存放的各家大模型服务商密钥。只需一次 pip install,攻击者就几乎掌握了你作为工程师的全部身份。

你不需要 import litellm。只要在该主机上启动任意一个 Python 解释器,就足以触发这个窃密程序。

哈希锁定与 Lockfile:必要,但不充分

每份供应链安全清单都是从 lockfile 和哈希锁定开始的,这没错。如果你用 pip install --require-hashes 锁定哈希,或者使用 uv、Poetry 之类带锁定哈希的工具,那么一个在你审核之后悄悄变化的 wheel 会被直接拒绝。这对一整类攻击都确实有效,比如仓库篡改、镜像投毒、产物替换。

但坦白说,在这件事上它几乎没用,至少对那些最先升级的人没用。哈希锁定保证的是:你明天安装的产物与你今天安装的字节级一致。而在这次攻击中,恶意 wheel 本身就是官方产物:来自一方、由流水线构建、哈希也一贯正确。有人一旦执行了例行升级,解析出新版本,并提交了更新后的 lockfile,恶意哈希就带着绿色的 CI 检查被钉进了仓库。哈希锁定能防止变化,却无法辨别意图。

在这类事件里,哈希锁定真正发挥作用的地方是影响范围。那些固定了哈希、并且有明确升级节奏的团队,不会在每个版本发布的那一刻就自动跟进。如果你的策略是「每周通过一个 PR 升级依赖,非关键修复需要等待几天的冷却期」,那么 PyPI 在 UTC 时间 13:23 下架该版本时,你的升级窗口还根本没开始。无聊的流程往往比巧妙的工具更管用,这一点我们不太愿意承认,但事实如此。

所以,lockfile 要留着,哈希也要生成进去。但别再告诉自己它能防住被攻陷的维护者流水线了。它防的是漂移和篡改,是对糟糕发布的一道减速带,仅此而已。

一扇坚固的金库门旁边,薄弱的墙壁被打出一个大洞。
哈希锁定守住了前门,而这次攻击直接穿墙而过——从构建流水线本身下手。

溯源证明本可以发现这次攻击

这里有个令人不太舒服的反面:这次攻击有一个非常明显、可检测的异常,而能捕捉它的生态工具现在就已经存在。恶意 wheel 并非由公开仓库构建,而是由攻击者创建的仿冒仓库构建。任何校验步骤只要问一句「这个产物是否确实由它声称的提交和仓库构建」,都会大声报错。

这正是溯源证明(provenance attestation)提供的能力。PyPI 支持 PEP 740 证明:当包通过 Trusted Publishers 从 GitHub Actions 发布时,工作流可以生成一份签名声明,把产物与生成它的确切仓库、工作流和提交绑定起来。使用方的检查就变成了机械化的操作:验证证明,确认源仓库是 BerriAI/litellm,而不是 Berri-AI/litellm。Sigstore 包、SLSA 溯源以及类似 pip-audit 的验证工具,都属于这一体系。

目前的差距在于采用率和强制执行。对发布者而言证明是可选项,而即便证明存在,几乎没人会在安装时去验证。这正是需要改变的部分,它也呼应了我们之前提过的一个观点,即信任基础设施只有在有人真正去检查时才有效:没人验证的签名只是装饰。如果你的组织运行自己的包索引或代理(而只要工程师超过几个人,你就应该这么做),那么「拒绝来自预期源、且无法验证溯源的产物」就是一条可以集中执行的策略,一次配置,覆盖所有开发者。

还有一个更微妙的教训。攻击者需要三个开发者凭证:一个用来改写工作流,一个用来批准,一个用来规避检查。这是凭证管理的系统性失败,而非单一失误。PyPI 上基于 OIDC 的短期 Trusted Publisher 令牌能大幅缩小攻击面,因为日志文件里不会留着可供窃取的长期令牌。如果你的项目仍然把 API 令牌存为 CI 密钥来发布,那就是该动手改的时候了。

沙箱化安装与冷却期的必要性

假设最坏的情况:某个恶意版本绕过了你的 lockfile 规范和溯源检查,因为这种事迟早会发生。接下来怎么办?遏制。问题就不再是「如何防止恶意包」,而变成了「恶意包运行时,它能触及什么」。

对 CI 而言,这个答案确实可以实现。构建应当在临时环境中运行,不持有长期凭证,使用范围受限的令牌,并启用出口过滤。如果你在监听网络,LiteLLM 窃密程序的行为其实很吵闹:向陌生域名发送带有自定义认证头的 POST 请求,每小时循环一次从 raw GitHub 地址拉取代码,以及最明显的标志——某个 Python 进程在查询 169.254.169.254,也就是 AWS 实例元数据服务。应用代码几乎没有正当理由直接访问 IMDS,SDK 会替它处理。对触碰该端点的 Python 进程设置告警,你抓到的就不只是这一个窃贼,而是一整类凭证窃取程序。我们之前写过云认证日志的盲点,这就是其中之一,而且堵上它的成本很低。

开发者机器更难处理,我不打算假装并非如此。在一台笔记本上,同一个用户既运行编辑器、浏览器、SSH 代理,又随意执行 pip install,这对窃密程序来说就是一场盛宴。务实的做法是:探索性安装在容器或 devcontainer 中进行,而不是在宿主机环境中;把项目依赖的安装限定在项目的 devcontainer 内,这样恶意的 sitecustomize.py 就永远不会落到宿主机的 site-packages 里;并且把 ~/.aws/credentials 视为一种负债,使用带短期令牌的 SSO(如 aws sso login、gcloud auth),磁盘上可供窃取的东西就少得多。这些都不算什么高深技术,只是增加一些摩擦,而摩擦正是目的所在。

  • 在 lockfile 中同时锁定版本和哈希,但要把升级视为有计划的事件,而不是背景噪音。
  • 对非关键依赖升级设置冷却窗口——48 到 72 小时——之后才允许进入生产构建。大多数恶意版本都会在几小时内被下架。
  • 在存在溯源证明的地方进行验证,并优先选择通过 Trusted Publishers(基于 OIDC)发布的包,而不是长期 API 令牌。
  • 在 CI 中使用临时、无凭证的环境运行构建,并启用出口过滤;对任何触碰实例元数据端点的 Python 进程设置告警。
  • 在开发者机器上,把第三方代码的安装和运行放进容器中,并用短期 SSO 会话替换静态的云凭证文件。
  • 如果你在事件窗口内拉取过任何东西,请检查环境中是否存在这些 IoC:site-packages 中的 cook.pth、cook.py 或多余的 sitecustomize.py,以及磁盘上的 /tmp/.pc_once_* 或 /tmp/remote_code_loader.lock。

这次攻击说明真正的风险在哪里

注意攻击者并没有去折腾什么。他们没有利用代码漏洞,也不需要零日漏洞。他们瞄准的是现代软件中最软的目标:持有长期凭证的人和机器。三个 GitHub CLI 令牌,显然是从一个面向公网的内部部署的日志里捡来的,就足以接管一条服务数百万次下载的发布流水线。这与我们在整个行业里反复看到的模式如出一辙:密钥四处扩散,流入日志、代理和配置文件,每一个都是一把万能钥匙。如果你运行着任何内部实例并把它暴露在公网上,那么它的日志现在也属于你的威胁模型。这句话应该让一些人感到不安。

还有一个值得点明的速度问题。LiteLLM 是一个迭代很快的项目,提交数以万计,发布频繁,而且它处在 AI 工具栈的核心位置,被许多团队甚至不知道依赖了它的框架和 SDK 拉入。传递性依赖意味着,你无法通过选择直接依赖来决定自己的暴露面。在这种环境下,SBOM 不再只是合规文书,而是你能否在几分钟而非几天内回答「我们受影响了吗?」的关键。基于真实构建证据、而非仅靠清单解析生成物料清单的工具,才能发现你的 requirements.txt 没有提到的依赖。

一个提着灯笼的人,站在无尽的仓库货架前检查其中一个箱子。
现代依赖树就像仓库,而大多数团队从未清点过货架上到底有什么。

我的看法是:业界对供应链攻击的应对,过度偏向于「防御表演」——事后扫描器标记已知的恶意版本——而对那些真正能改变这个故事的、不起眼的控制措施投入不足。短期发布凭证。在代理层进行溯源验证。冷却期。对元数据端点的出口告警。沙箱化安装。这些都不是研究难题,只是配置决策,今天就能做,而大多数团队一项都没落实。LiteLLM 的攻击者只需要构建配置里的一行坏代码和三个被盗的令牌。而上面这些防护,本可以迫使他们突破四层相互独立的防线,而不是一层。这才是纵深防御的真正含义:不是更多的扫描器,而是让攻击者有更多相互独立的失败机会。这与那些能抵御灾难的工程系统背后的理念一致,而不是寄希望于一切顺利。

下一个被攻陷的版本,此刻已经有人在策划了。你无法阻止它被发布。但你完全可以决定它运行时,AWS 密钥是否触手可及、SSH 代理是否处于解锁状态、它是否有通往攻击者域名的清晰网络路径,还是它只能在一个没有凭证、带防火墙的临时容器里走向死亡。这个决定现在就要在你的 CI 配置和 devcontainer 设置中做出,而不是等事件报告发布的那一天。