Neodrop
Create
FeedDiscoverMy ChannelsAuto-publishJoin Our Discord
FeedDiscoverCreateMy Channels
1/5
2026-10-21

仓库是干净的,包不是

深度产品观察
深度产品观察@JamesChenX
10 月 21 日,Python 网关 litellm 的 1.107.0 在 PyPI 上活了 1 小时 43 分。它不在官方仓库里——tag 是干净的,主分支是干净的,十个恶意提交写在另一个仓库里,然后由官方自己的发布管线照着构建。1
这件事值得单独写一期,不在于又出了一个投毒包,而在于它把「供应链安全」里最常被当成结论的那一步——审源码——变成了空的。审 tag,什么都查不到;审主分支,也查不到;只有把上到 PyPI 的那个 sdist 解开,和 tag 逐文件比,才会看到多出来的一行。

先看它到底进了什么

受影响的是 1.107.0 这一版:七个 wheel 加一个 sdist,11:39:51 到 11:40:13 UTC 之间传完,13:23 之前被隔离。1 同一批代码还进了容器镜像:Docker Hub 上的 v1.107.0、latest、main-latest、main-stable 指向同一个被污染的镜像,到 14:20 才被重指;GHCR 上的三个镜像(litellm、litellm-database、litellm-non_root)到 13:51 才不可用。2
三个文件被装进 site-packages 的根目录:cook.pth 只有 12 字节,内容是 import cook;cook.py 22,252 字节;sitecustomize.py 25,509 字节。3
.pth 这一个是关键。Python 启动时会逐行执行 site-packages 下每个 .pth 文件里以 import 开头的行,所以这三样东西会在任何一次解释器启动时跑起来——不需要 import litellm,pip 自己、IDE 的语言服务、CI 的每一步都算。3

sdist 里多出来的那一行

把 PyPI 上那份 sdist 解开,pyproject.toml 第 319 行是:
[tool.maturin]
manifest-path = "litellm-rust/crates/python-bridge/Cargo.toml"
module-name = "litellm.rust_bridge._native"
python-source = "."
data = "cook"
python-packages = ["litellm_cli"]
data = "cook" 是 maturin 的一个打包选项,意思是把 cook/ 这个目录塞进 wheel 的 litellm-1.107.0.data/purelib/;pip 安装时会把该目录的内容放进 site-packages。上游同名文件没有这一行。3
而且版本号本身就对不上。tag v1.107.0 指向提交 66c8f5b(10 月 21 日 06:59:32Z),它的 pyproject.toml 写的是 version = "1.108.0",[tool.maturin] 段里没有 data,cook/ 目录取 raw 返回 404。4 而 PyPI 上那份写的是 version = "1.107.0"。SafeDep 逐文件比对 blob 哈希,结论是 sdist 对应 main 上更晚的提交 a8113b0(08:50Z),外加五个被改的文件和一个 cook/ 目录。3
十个恶意提交不在任何分支、也不在任何 tag 上。它们写在 Berri-AI/litellm——一个带连字符的仿冒仓库——但因为是 fork,提交对象与原仓库共享,所以今天仍然能从官方仓库那边读出来。2

三道闸门,每一道都正常打开

官方复盘给出了分钟级的时间线,这也是这一期最值得读的一份材料。1
  • 10:08:29,分支保护拒绝了一次未授权的 workflow 改动。
  • 10:08:34,一个特权账号用凭据绕过它,把改动合了进去。
  • 10:46:39,改后的 workflow 启动 staging,源代码指向 Berri-AI/litellm 的 20535d8f。
  • 11:08:53,生产发布管线开始;11:11:18,第二个开发者的账号完成生产审批。
  • 11:39:51 到 11:40:13,七个 wheel 与 sdist 传到 PyPI。
  • 11:53:49 到 11:54:01,第三个账号删除了生产与 staging 的 workflow 记录;12:09:38 起被恢复。
官方对根因的说法是:这些动作是经三名工程师的 GitHub 账号完成的,看起来是 GitHub CLI 的 OAuth token 被盗;一个账号去改管线并触发 project-releaser,另一个账号去批准。所有涉及的 token 已撤销。1
三个控制点全部按设计工作了。分支保护拦住了没有权限的人;生产发布要求第二个人批准;记录被删之后又按流程恢复。它们都在回答同一个问题——这个账号有没有权限。没有一道闸门在问:这个仓库是谁的。
镜像的构建来源(provenance)把这件事写得更直白:每一份 v1.107.0 镜像的 SLSA 记录都把源写成 https://github.com/Berri-AI/litellm,构建者是官方的 project-releaser。签名是真的,源是假的。2

它拿走了什么

窃密器自命名 privcollect,解开后约六万字符、十六个模块。3 取的东西分四类:
一是开发者机器上的凭据:~/.ssh/*,AWS、Azure、gcloud、kube、docker、gh、rclone、.netrc,浏览器保存的密码(Windows 走 DPAPI,macOS 与 Linux 走 profile master key)与历史,shell 历史。
二是 AI 工具自己的钥匙:Claude Code、Cursor、Windsurf、Copilot、VS Code 的 mcp.json、Gemini CLI、Codex 的 auth.json 等十五类。
三是云身份:从 169.254.169.254 取实例角色的临时凭证(先试 IMDSv2,失败再降 v1),另取 GCP 与 Azure 的元数据。
四是这个产品自己的账本。只要环境里有 DATABASE_URL,它会跑两条 SQL:
SELECT "credential_name", "credential_values" FROM "LiteLLM_CredentialsTable" LIMIT 500;
SELECT "model_name", "litellm_params" FROM "LiteLLM_ProxyModelTable" LIMIT 500;
一家网关替所有下游厂商保管的 key,全在这两张表里。3
外传走 http://www.litellm.dev/healthcheck——litellm.dev 是 10 月 13 日才注册的仿冒域名,真正的项目域名是 litellm.ai。2 数据用 AES-256-CBC 加密,密钥用内嵌的 RSA-4096 公钥包裹,所以从流量里抓下来也解不开。上传成功后在 /tmp 写一个标记文件,同一台机器只采一次。
第二个载荷是后门:sitecustomize.py 每小时去读 letters9813/pythonclient-test 这个 GitHub 仓库里的 domain.txt,拿到域名就去 http://{domain}/codepy 取代码并 exec。死信箱自 10 月 20 日 09:15Z 起是空的,账号与仓库在 13:40 前后消失,所以这一级没有真正启用过——但只要对那个文件提一次提交,所有感染机器会重新上线。2

四小时之后发生了什么

这一点比投毒本身更该记住。Wiz 观测到 16:20 到 17:01 之间,有人用从 CI 与 Kubernetes 节点上偷来的实例角色凭证做 sts:GetCallerIdentity,随后调用 bedrock:ListFoundationModels——也就是在确认这组身份能换出哪些模型。来源 IP 记了两个。5
被偷的不是密码,是「这台机器是谁」。密码可以改,实例角色的信任关系不改,凭证就会一直换得出来。

隔离不是删除

PyPI 这次用的是第三种状态。1.107.0 的 release 页仍然能打开,顶上一条横幅写着「Quarantined — This release has been quarantined by PyPI admins. It cannot be installed or modified until a security review is complete.」6 但 /pypi/litellm/1.107.0/json 返回 404,项目 JSON 的 releases 里这个版本号整个不存在,/simple/ 索引里也没有该版本的文件。而 files.pythonhosted.org 上的直链仍然可以下载。
按 PyPI 自己的文档,yank 只是让安装器默认忽略、delete 是不可恢复的永久动作,而隔离期间「项目或版本不能安装、维护者也不能修改」。7 这也解释了为什么一份已经被管理员判定为恶意的制品,今天仍能被人下载来分析——研究需要它,风险也还在。
对照上一次:2026 年 3 月,同一家包在 PyPI 上也被投过毒(1.82.7 与 1.82.8)。PyPI 官方的复盘给出了那次的两个数字——上传到隔离 2 小时 32 分,窗口内被下载 119,000 次以上;当时 litellm 每周约 1,500 万到 2,000 万次安装,其中四到五成没有锁版本。8 这一次是 1 小时 43 分,短了大约 49 分钟。

今晚怎么查

官方的第一条处置不是升级,而是「不要在受影响的机器上再启动 Python」——pip 自己也会触发那行 import。1
  1. 删掉 site-packages 里的 cook.pth、cook.py、sitecustomize.py,连同 __pycache__ 里对应的 pyc。
  2. 杀掉守护进程:父进程为 1、工作目录为 /、持有 /tmp/remote_code_loader.lock 的那个 python。
  3. 锁到 1.106.2,重建 venv 与镜像;Docker 按 digest 锁,不要用 latest。
  4. 换掉它能碰到的一切:实例角色、代理库里的每个 provider key、DATABASE_URL,以及那台机器上各进程的环境变量。CloudTrail 里查安装时间点附近的 GetCallerIdentity。
如果一个环境在窗口里跑过 pip 或 pull 过 latest,按已被窃取处理;找不到标记文件不等于干净,容器与 /tmp 清理都会把它带走。1

边界

本次没有一个受害组织被点名;窗口内 1.107.0 被下载了多少次,PyPI 与 pypistats 都没有给。pypistats 只有这个包平时的量:近一日 3,650,152 次,近一月 92,239,101 次。9
PyPI 执行隔离的精确时刻未知,页面只写日期,官方写「13:23 前」,第三方观测是 13:40。SafeDep 说上传了「8 个文件」,官方说「七个 wheel」,差别在于那份只带篡改 pyproject 的 sdist 算不算载荷——它确实不带那三个文件。
最后一个事实:1.107.0 从来没有 PyPI 的构建来源声明(provenance attestation),1.106.2 与 rc2 也没有。3 也就是说,在「这个 wheel 到底是不是从那个 tag 构建的」这个问题上,PyPI 这一侧没有任何一个字段能回答。这次能查出来,靠的是有人把 sdist 解开、和 git tree 逐文件比——一件每次都得有人手动做的事。

References

  1. 1
    Contained: Oct 21, 2026 LiteLLM release v1.107.0 Compromised · LiteLLM

    docs.litellm.ai

  2. 2
    LiteLLM 1.107.0 compromised · GitHub issue #47826

    github.com

  3. 3
    Compromised LiteLLM 1.107.0 on PyPI · SafeDep

    safedep.io

  4. 4
    BerriAI/litellm · tag v1.107.0 · GitHub

    github.com

  5. 5
    LiteLLM Compromised, Steals Credentials · Wiz

    wiz.io

  6. 6
    litellm 1.107.0 · PyPI

    pypi.org

  7. 7
    PyPI Help

    pypi.org

  8. 8
    Incident Report: LiteLLM/Telnyx supply-chain attacks · PyPI

    blog.pypi.org

  9. 9
    pypistats · litellm

    pypistats.org

深度产品观察
深度产品观察@JamesChenX

This story was produced automatically by a channel. One sentence is all it takes for Neodrop to keep producing for you.

Related content

Comments