安全#安全扫描#安全

LiteLLM 1.107.0 的 wheel 与 Docker 镜像被植入窃密器:三名工程师的 GitHub 令牌被攻破,发布链成了入口

LiteLLM 1.107.0 七个 wheel 全含窃密器,PyPI 存活不到两小时;攻击者用三名工程师的 GitHub 令牌改掉发布链。

深色背景上发着蓝光的三维几何块,几条红色分段虫状物体攀在块体边缘,周围散落蓝色小方块

LiteLLM 是 BerriAI 维护的开源 LLM 网关,用一套 OpenAI 兼容接口代理各家模型提供商,Socket 与 The Register 都给出约每周 2,300 万次下载的量级。2026 年 10 月 21 日,它的 1.107.0 版本从发布链上被换成了带窃密器的构建:PyPI 上七个 wheel 全部中招,Docker 的 v1.107.0、latest、main-latest、main-stable 四个标签指向同一个恶意镜像。LiteLLM 在官方公告里写明 PyPI 的存活窗口是 11:39:51 到 13:23 UTC,Docker Hub 到 14:20 UTC。

要点

  • 七个 wheel 分别覆盖 macOS x86_64 与 arm64、manylinux aarch64 与 x86_64、musllinux aarch64 与 x86_64、win_amd64;每个 wheel 多出三个文件,装在 site-packages 根目录下,cook.pth 只有 12 字节,内容是 import cook。
  • 恶意构建来自仿冒仓库 Berri-AI/litellm(真仓库是 BerriAI/litellm),revision 为 20535d8f,容器镜像的 SLSA provenance 里记着 source_owner: Berri-AI 和发布工作流 run 39587059944。
  • Wiz 在 16:20 与 17:01 UTC 观测到攻击者用从 CI runner 与 K8s 节点窃得的 AWS 实例角色凭据执行 sts:GetCallerIdentity,随后调用 bedrock:ListFoundationModels。

时间线

官方公告给出的时间线全部是 10 月 21 日 UTC。10:08:29,GitHub 的分支保护拦下了一次未授权的工作流改动,因为它没过必需的 PR 与状态检查;5 秒后,一个组织所有者级别的令牌越过保护,把提交 9cf97e93 强推上去。10:46:39,改过的工作流启动了一次官方 staging 运行,源码指向攻击者控制的 Berri-AI/litellm 的 20535d8f。11:11:18,第二个开发者账号的凭据通过了生产审批。

11:39:51 到 11:40:13,七个 wheel 上传 PyPI。11:46:56,同一版本的容器发布任务开始跑。11:53:49 到 11:54:01,第三个账号的凭据删掉了生产与 staging 的工作流记录,调查能看的日志因此变少。12:09:38 到 12:24:52,未授权的工作流修订被移除;13:23 之前 PyPI 移除了 1.107.0;13:51 GHCR 上的受影响标签已不可用,Docker Hub 上四个标签仍解析到恶意 index sha256:1bbcb1443bc9…;14:20 全部处理完。

前提条件写在公告的「How it was undermined」一节:10 月 19 日星期一,团队临时放宽了发布权限,让两名内部工程师能给客户发布每日 bugfix 回移。攻击者手上的三套 GitHub CLI 凭据里,有一套管得着工作流的执行权限,另一套属于两名组织所有者之一。

渠道受影响标签或文件官方给出的窗口(UTC)
PyPIlitellm==1.107.0 七个 wheel,另有一个 sdist11:39:51–13:23
Docker Hubv1.107.0、latest、main-latest、main-stable11:46:56–14:20
GHCR同上,覆盖 litellm、litellm-database、litellm-non_root11:46:56–13:51

为什么装了就等于中招

三个多出来的文件里,两个会在 Python 解释器启动时自动执行,跟应用有没有 import litellm 无关。cook.pth 只有一行 import cook;CPython 的 site 模块在初始化时会处理 site-packages 下的 .pth 文件,遇到以 import 开头的行就直接执行。第二个是 sitecustomize.py,解释器在初始化末尾会自动尝试导入同名模块,于是这段代码也有了固定的执行时机。

JFrog 与 Socket 各自静态解码了载荷:cook.py 是个两层包装,exec(zlib.decompress(base64.b64decode(...))),解开后约 61 KB(文件本身 22,252 字节,这个长度来自 wheel 的 RECORD)。内部自称 privcollect,按 16 个模块收集:浏览器保存的密码与 cookie、~/.ssh、shell 历史、AWS/Azure/gcloud/kube/docker/gh 的凭据文件、Claude Code 与 Cursor、Codex 等 AI 工具的配置、/proc/*/environ 里所有可读进程的环境变量,以及 GPG 与 TLS 私钥。设了 DATABASE_URL 的话,它会启一个 prisma 进程把 LiteLLM_CredentialsTable 和 LiteLLM_ProxyModelTable 导出来——也就是这个网关替客户存着的所有上游厂商密钥。数据用 AES-256-CBC 加密,密钥用 RSA-4096-OAEP 包一层,再 POST 到 www.litellm.dev/healthcheck;这个域名 10 月 13 日注册,跟官方的 litellm.ai 不是一回事。

sitecustomize.py 那条是后门:它 fork 一个脱离终端的守护进程,每小时读一次 GitHub 上 letters9813/pythonclient-test 仓库里的 domain.txt,拿到域名就再跑一遍窃密,并从 http://<domain>/codepy 取代码 exec()。按 Wiz 的分析记录,这个文件除了最初的测试值之外没有被写入过内容;JFrog 在分析时做只读拉取,拿到的是零字节。所以第二阶段处于休眠,改一个文件就能重新激活。Windows 上 fork 不可用,这段会静默失败,但 cook.py 照跑。

源码审查为什么查不出

这一点是本次事件最值得记的工程细节。按 JFrog、SafeDep 与 Socket 三家的比对,v1.107.0 的 git tag 是干净的,sdist 里也没有那三个载荷文件。

区别只在一行。sdist 的 pyproject.toml 在 [tool.maturin] 段里多了一句 data = "cook",maturin 会把这个目录的内容复制进 wheel 的 litellm-1.107.0.data/purelib/,pip 再把它们装到 site-packages 根目录。SafeDep 按 blob 哈希逐个文件比对,结论是 sdist 匹配 main 上的 a8113b0d(08:50 UTC),另有五处被改动的文件,其中四处 Cargo.toml 加 readme 行在正常版本里也有,剩下那一处就是版本号回退到 1.107.0、依赖钉回 rc2 的值,再加上 data = "cook"。这样一来,跟 rc2 做依赖 diff 看不出异常。

data = "cook" 只影响 wheel 构建,sdist 打包时不含 cook/ 目录。所以看源码、看 tag、看 sdist 都干净,只有拆开 wheel 读 RECORD 才能看见那三行。Socket 的说法更直接:审查 tag 会漏掉打包层面的改动。

失窃之后

Wiz 的观测是目前唯一公开的「窃取之后发生了什么」。外泄后约 4.5 小时,也就是 16:20 到 17:01 UTC,攻击者从两个 IP(168.100.8[.]57、165.227.201[.]8)验证了一批 AWS 实例角色凭据,来源是受影响的 CI runner 与 Kubernetes 节点。每个凭据先跑 sts:GetCallerIdentity 确认可用,再调 bedrock:ListFoundationModels 和一次 ListModels;少数环境里还有 ListRoles、ListUsers、ListAccessKeys 与 ListAccounts、DescribeTrails。Wiz 把这个模式归为与 LLMjacking 一致——拿到云身份后先看有哪些模型能用。

处置顺序来自官方公告的「What to do if you are affected」与 Socket 的「Contain Affected Environments」两节,两边都把同一步放在最前面:先别启动这个环境里的 Python,pip 也算。issue 里最早贴出这套步骤的是 Upwind 团队的 LidorMachluf。任何一次 Python 启动都会再触发那两个钩子,装包本身也算。顺序是:从 shell 删掉每个受影响 site-packages 里的 cook.pth、cook.py、sitecustomize.py 和对应的 __pycache__;杀掉持有 /tmp/remote_code_loader.lock 的那个脱管进程;再按 litellm==1.106.2 重装——Socket 注明这是 Upwind 推荐的版本——或者干脆用审过的产物重建镜像。/tmp/.pc_once_72129118c59f5636c0bb 存在就说明外泄已经发生,但不存在不能证明干净——容器与 tmp 清理都会带走它。

需要轮换的清单不短:实例或任务的 IAM 角色、LiteLLM 代理数据库里的所有厂商密钥(含 master key 与 virtual key)、DATABASE_URL 的密码,以及那台机器上任何进程环境里出现过的密钥。CloudTrail 要查两个方向:安装时间点前后该角色有没有 GetCallerIdentity,以及之后有没有人从你的基础设施之外用这套凭据。

官方在 issue 里的最新一条维护者更新(10 月 22 日 02:19 UTC)说,registry 仍锁着、常规发布暂停、没有实质进展,更新改为有进展才发。本文在 10 月 22 日 03:16 UTC 核对制品状态:pypi.org/pypi/litellm/1.107.0/json 返回 404,latest 是 1.106.2(上传时间 10:44:22Z);Docker Hub 的 v1.107.0 返回 404,latest、main-latest、main-stable 都指向 sha256:7294b90f…,与 v1.106.2 同一个 digest;GHCR 匿名拉取的 v1.107.0 也是 404。制品层面已经收住了。

各方说法

Hacker News 上这条讨论在本文核对时(10 月 22 日 03:21 UTC)是 453 分、153 条评论,主流反应冲着「一年两次」去。用户 stbenjam 写「Once is forgivable, twice is malpractice」,同一串讨论里他接着主张 uv、npm 这类工具应该默认给依赖加 24 到 48 小时冷却期,想装更新的版本得自己动手。也有人从反面质疑冷却期的价值:用户 0c3ca83 说供应链攻击在发现前的中位存活时间是数月到数年,冷却期短了像安慰剂;whatshisface 顺着这个逻辑开玩笑:如果所有人都加冷却,攻击就没人发现,那冷却期得是冷却期本身再加一个月。

对 LiteLLM 的具体质疑集中在两处。一是项目把「3 月事件后做了实质性加固」当作叙事主线,而按证据看,那套加固大部分按设计工作了:私有仓库、Trusted Publishers 和 provenance 都正常运转,恶意产物恰恰是从官方发布工作流出来的,provenance 里还写着 Berri-AI/litellm;分支保护也拦了,随后被一个组织所有者令牌按机制覆盖。这一周真正变化的,是 10 月 19 日那次临时放权。二是公告里那句「企业私有 registry 的客户不受影响,我们在那里分发一个 10 月 27 日才披露的漏洞的补丁」,有读者认为等于承认开源用户在等一个已经修好的洞;LiteLLM 员工 chias 在讨论里解释,那个漏洞只影响企业部署、不影响 OSS 部署,先给客户补丁是为了让他们在公开披露前有时间升级。这个解释合理,但它是当事方的说法。

3 月的对照要小心用。那次是 1.82.7 与 1.82.8 两个版本,JFrog 的记录是流水线跑了一个未锁版本的恶意 Trivy 0.69.4;The Register 引 CloudSEK 的数字说可能波及 2,500 家组织与 434,000 条 CI/CD 流水线。这两个数字属于 3 月那次,本次规模官方还没有给数,别混着引用。JFrog 明确写了没有证据把两次连到同一个团伙,应视为两起独立行动。本站此前对 n8n 的 DGX Spark 教程的解读里已经核过 3 月那两个版本与相关公告,这里不再重述。

两次事件真正不同的地方在注入点。3 月是流水线里的第三方工具被换掉,属于依赖链;这次是发布工作流的源码指向被改,属于发布链本身。前者能靠锁定依赖版本、校验工具哈希来防,后者要防的是「谁有权改这个工作流、改了以后谁能批」。LiteLLM 在 issue 里说会继续公布调查进展;截至这篇成稿时,他们还没回应是否公开 project-releaser 的工作流定义——issue 里有人明确问过这一点,理由是下游用户想自己核对发布是怎么构建的。

编辑判断

这条新闻最值得记的是一个时间差:一个团队花几个月把发布链收紧到「只能由办公室里那台笔记本上的账号发布」,然后为了给客户发每日 bugfix,在周一临时把权限放开给两个人。IOC 清单换一版就过期,这个模式不会。攻击者只需要一条能过的路径;三个令牌分别负责触发、审批、擦日志,各司其职,这不像顺手捡来的机会。

对用 LiteLLM 当网关的团队,有几件事现在就能做。检查所有部署里 litellm 的版本是不是恰好落在 1.107.0,别只看 requirements 里写没写——浮动依赖和 latest 标签都会漂过去。把 docker pull 的 tag 换成 digest:latest、main-latest、main-stable 现在指回 sha256:7294b90f…(1.106.2 那个 digest),但这次正是浮动标签把恶意镜像送进了不写版本号的环境。翻一遍 CloudTrail 里 10 月 21 日 11:39 UTC 之后的 GetCallerIdentity 与 Bedrock 调用。最后,如果你们的 CI 里跑过 1.107.0,把那台 runner 的凭据全换掉——它同时看得见部署密钥和云角色。

还有两个没答案的问题。初始入口没确认,维护者只说怀疑是一个刚修补的漏洞。以及「Claude Code 会把用户的 GitHub CLI OAuth 令牌嵌进响应体,日志里就会出现」这句,只在 LiteLLM 的公告与 issue 里读到,我们没找到 Anthropic 侧的确认,也没有独立复现,读者应按「LiteLLM 称」来读。如果这句成立,它影响的不止 LiteLLM 一家:任何把 AI 编码代理的输出写进日志、而日志又能被外人读到的部署,都在同一条链上。这和今年几起智能体越权事故的教训落在同一批位置上——沙箱、出网控制、日志里能放什么。

怎么自查

在自己的环境里按顺序跑,别先装包:

# 1. 找证据(先不启动 Python)
find / -path '*/site-packages/cook.pth' -o -path '*/site-packages/cook.py' \
  -o -path '*/site-packages/sitecustomize.py' 2>/dev/null
ls -l /tmp/.pc_once_* /tmp/remote_code_loader.lock 2>/dev/null

# 2. 删文件、杀脱管进程,再按固定版本重装
pip install 'litellm==1.106.2'

/tmp/.pc_once_* 存在说明数据已经发出去了;不存在只说明这台机器上看不到痕迹。Docker 用户把 tag 换成 sha256:7294b90f868c… 这个 digest,或者直接重建。