<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>GitHub Actions on AI博士 万戈</title>
        <link>https://www.yesmiracle.net/tags/github-actions/</link>
        <description>AI博士万戈的技术博客，聚焦 Agentic AI、AI Infra 与 Agent Security，分享 AI 基础设施与工程落地实践。</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh-cn</language>
        <managingEditor>admin@yesmiracle.net (万戈)</managingEditor>
        <webMaster>admin@yesmiracle.net (万戈)</webMaster>
        <lastBuildDate>Wed, 21 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://www.yesmiracle.net/tags/github-actions/index.xml" rel="self" type="application/rss+xml" /><item>
        <title>LiteLLM 又被投毒了：恶意代码只藏在 wheel 里，源码和 Git tag 都是干净的，而入口是一段被 AI 编程工具漏进日志的 token</title>
        <link>https://www.yesmiracle.net/post/20261021-litellm-supply-chain-attack-1070/</link>
        <pubDate>Wed, 21 Oct 2026 00:00:00 +0000</pubDate>
        <author>admin@yesmiracle.net (万戈)</author>
        <guid>https://www.yesmiracle.net/post/20261021-litellm-supply-chain-attack-1070/</guid>
        <description>&lt;img src="https://www.yesmiracle.net/post/20261021-litellm-supply-chain-attack-1070/cover.svg" alt="Featured image of post LiteLLM 又被投毒了：恶意代码只藏在 wheel 里，源码和 Git tag 都是干净的，而入口是一段被 AI 编程工具漏进日志的 token" /&gt;&lt;p&gt;十月二十一号，LiteLLM 又被投毒了。&lt;/p&gt;
&lt;p&gt;这已经是七个月里的第二次。三月那次是 1.82.7 和 1.82.8，这次是 1.107.0，在 PyPI 上活了大约两小时（11:39 到 13:23 UTC），Docker Hub 和 GHCR 上那几个浮动标签多活了半小时。安全版本是 1.106.2。&lt;/p&gt;
&lt;p&gt;如果你只用一句话概括，那就是「知名开源库又被投毒了」。但我把 LiteLLM 自己的事故公告、Wiz、JFrog 和 Flatt 四家的分析读完之后，数出了三个比事件本身更值钱的细节。它们指向同一件事：我们评估依赖安全的那套直觉，正在失效。&lt;/p&gt;
&lt;h2 id=&#34;先把事实摆清楚&#34;&gt;先把事实摆清楚&lt;/h2&gt;
&lt;p&gt;官方公告给的时间线和范围，我整理成这样：&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;渠道&lt;/th&gt;
          &lt;th&gt;受影响的具体对象&lt;/th&gt;
          &lt;th&gt;存活窗口（UTC）&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;PyPI&lt;/td&gt;
          &lt;td&gt;&lt;code&gt;litellm==1.107.0&lt;/code&gt;，全部七个 wheel&lt;/td&gt;
          &lt;td&gt;11:39:51 – 13:23&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Docker Hub&lt;/td&gt;
          &lt;td&gt;&lt;code&gt;v1.107.0&lt;/code&gt;、&lt;code&gt;latest&lt;/code&gt;、&lt;code&gt;main-latest&lt;/code&gt;、&lt;code&gt;main-stable&lt;/code&gt;&lt;/td&gt;
          &lt;td&gt;11:46:56 – 14:20&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;GHCR&lt;/td&gt;
          &lt;td&gt;同上四个标签，含 &lt;code&gt;litellm&lt;/code&gt;、&lt;code&gt;litellm-database&lt;/code&gt;、&lt;code&gt;litellm-non_root&lt;/code&gt; 三个镜像&lt;/td&gt;
          &lt;td&gt;11:46:56 – 约 13:51&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;企业客户那条线是安全的。原因有点黑色幽默：他们正在给企业客户发一个「十月二十七日才允许披露」的漏洞修补版本，走的是私有 registry，攻击者没碰那条路。&lt;/p&gt;
&lt;h2 id=&#34;为什么-litellm-是特别值钱的目标&#34;&gt;为什么 LiteLLM 是特别值钱的目标&lt;/h2&gt;
&lt;p&gt;先补个背景，不写这个，后面「十六类凭据」那张清单的重量出不来。&lt;/p&gt;
&lt;p&gt;LiteLLM 是一个统一调用各家大模型 API 的 Python 库兼代理，GitHub 上六万一千多 star、一万两千多 fork。它存在的意义就是让上层代码不用为每家模型写一套适配，所以它天生要同时持有 OpenAI、Anthropic、Google 以及一堆自建模型的 API key。它被 DSPy 这类框架当底层依赖，也被大量团队直接当网关跑。&lt;/p&gt;
&lt;p&gt;打它一次，等于把上游所有厂商的钥匙一起拿到手。再叠上它通常跑在有云凭据、有 K8s 权限的机器上，十六类凭据这个数字就不奇怪了。攻击者挑它，跟它代码写得好不好关系不大，跟它站的位置关系很大。&lt;/p&gt;
&lt;h2 id=&#34;细节一源码是干净的恶意代码只在-wheel-里&#34;&gt;细节一：源码是干净的，恶意代码只在 wheel 里&lt;/h2&gt;
&lt;p&gt;这是我读完 JFrog 那篇之后停了几秒的地方。&lt;/p&gt;
&lt;p&gt;恶意 payload 是三个文件，都放在 site-packages 的根目录：&lt;code&gt;cook.pth&lt;/code&gt;、&lt;code&gt;cook.py&lt;/code&gt;、&lt;code&gt;sitecustomize.py&lt;/code&gt;。1.107.0 的&lt;strong&gt;七个 wheel 全都有&lt;/strong&gt;，覆盖 macOS、Linux、musl Linux 和 Windows。&lt;/p&gt;
&lt;p&gt;但它的&lt;strong&gt;源码分发（sdist）里没有这三个文件&lt;/strong&gt;。GitHub 上那个官方的 &lt;code&gt;v1.107.0&lt;/code&gt; tag 里也没有。唯一能看出异常的地方很隐蔽：sdist 的 &lt;code&gt;pyproject.toml&lt;/code&gt; 里多了一行 &lt;code&gt;data = &amp;quot;cook&amp;quot;&lt;/code&gt;，而这行在对应的 Git tag 里不存在。&lt;/p&gt;
&lt;p&gt;JFrog 还提了一个更细的点：payload 那几个文件在 wheel 压缩包里的时间戳和元数据，跟正常文件是一致的。这说明它们是在&lt;strong&gt;正常的 wheel 构建过程中&lt;/strong&gt;被塞进去的，不是事后手工重打包加进去的。至于到底是谁改了构建树、怎么改的、又是怎么发出去的，到现在仍然没有答案。&lt;/p&gt;
&lt;p&gt;这件事的含义比它听起来重。过去我们判断一个包能不能用，标准动作是去 GitHub 看一眼源码、对一下 tag、或者读一遍 README 和 issue。这次这三个动作&lt;strong&gt;全部会给出「干净」的结论&lt;/strong&gt;。真正被装到你机器上的是 wheel，不是源码，而恶意代码只在 wheel 里。&lt;/p&gt;
&lt;p&gt;换句话说，「我读过源码」不再是一句安全声明。&lt;/p&gt;
&lt;p&gt;那接下来该看什么？我把这次真正能用的校验动作按成本排了一下。最便宜的是固定版本号，别用范围；往上一层是固定哈希；再往上，是校验 wheel 的 provenance 或者签名，确认它确实出自官方流水线；最后是拿 sdist 和 wheel 做交叉比对，看两边文件是否一致。这次能被查出来，靠的就是最后一层：sdist 里没有那三个文件，wheel 里有。只盯任何一边都会漏。&lt;/p&gt;
&lt;h2 id=&#34;细节二入口是一段被-ai-编程工具漏进日志的-token&#34;&gt;细节二：入口是一段被 AI 编程工具漏进日志的 token&lt;/h2&gt;
&lt;p&gt;这次攻击是怎么进去的，官方在 GitHub issue 里自己讲了，讲得相当坦白。&lt;/p&gt;
&lt;p&gt;攻击者用了&lt;strong&gt;三个工程师的 GitHub 账号&lt;/strong&gt;，靠的是偷来的 &lt;strong&gt;GitHub CLI OAuth token&lt;/strong&gt;。他们用一个账号触发了 &lt;code&gt;project-releaser&lt;/code&gt; 这个发布流水，用另一个账号去批准它。这两个动作凑齐，恶意版本就发出去了。事后涉及的 token 全部吊销。&lt;/p&gt;
&lt;p&gt;那 token 是怎么丢的？官方的复盘是这么说的：他们观察到 &lt;strong&gt;Claude Code 会把用户的 GitHub CLI OAuth token 嵌进响应体&lt;/strong&gt;，而在响应体被记录下来的场景里，这个 token 就会出现在 LiteLLM 的日志里。再加上他们内部有一个 LiteLLM 部署，&lt;strong&gt;用一个看起来很普通的账号名、暴露在公网&lt;/strong&gt;，攻击者拿到了这个部署的访问权，从日志里捞到了 token，然后用它打穿了发布流程。&lt;/p&gt;
&lt;p&gt;至于攻击者最初是怎么进那个内部部署的，官方说还没确认，怀疑是某个刚修补过的漏洞。&lt;/p&gt;
&lt;p&gt;我把这条链子按顺序念一遍：AI 编程工具把凭据写进响应体 → 响应体被记进日志 → 日志留在一个公网可达的内部服务里 → 日志里的 token 足以触发和批准一次官方发布。&lt;/p&gt;
&lt;p&gt;这条链上没有任何一环需要「黑客级」的技术。这也是我觉得它最值得写的地方：&lt;strong&gt;AI 工具本身正在成为供应链的入口&lt;/strong&gt;。工具本身没被攻破，问题在于它太能干，顺手就把你手里最有价值的凭据复制到了一个新的地方。&lt;/p&gt;
&lt;h2 id=&#34;细节三pth-加-sitecustomizepy野外第一例&#34;&gt;细节三：.pth 加 sitecustomize.py，野外第一例&lt;/h2&gt;
&lt;p&gt;payload 的执行机制，Wiz 拆得很清楚，而且点名了一个「第一次」。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cook.pth&lt;/code&gt; 是 Python 的一个特殊机制：site-packages 目录下的 &lt;code&gt;.pth&lt;/code&gt; 文件会在解释器启动时被处理，里面可以写 &lt;code&gt;import&lt;/code&gt; 语句。所以 &lt;code&gt;cook.pth&lt;/code&gt; 一被执行，就 import 了 &lt;code&gt;cook&lt;/code&gt;，&lt;code&gt;cook.py&lt;/code&gt; 于是加载进来，自己去混淆、跑窃取逻辑，然后在 &lt;code&gt;/tmp/&lt;/code&gt; 下写一个标记文件。&lt;/p&gt;
&lt;p&gt;同一个环境里，Python 启动时还会尝试执行 &lt;code&gt;sitecustomize.py&lt;/code&gt;。它做的事是从一个 GitHub dead drop（&lt;code&gt;letters9813/pythonclient-test&lt;/code&gt; 仓库里的 &lt;code&gt;domain.txt&lt;/code&gt;）取回一个域名，再从那个域名下载第二阶段的 Python 脚本执行。这个文件只在 Linux 和 macOS 上跑得起来。&lt;/p&gt;
&lt;p&gt;Wiz 的结论是：&lt;strong&gt;同时用 &lt;code&gt;.pth&lt;/code&gt; 和 &lt;code&gt;sitecustomize.py&lt;/code&gt; 两种手法，是野外第一例。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这意味着什么？只要那个环境里有任何一个 Python 进程启动，payload 就执行。你的代码从头到尾没 import 过 litellm，也没关系。更麻烦的是，&lt;strong&gt;pip 本身也是 Python 进程&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;所以官方的处置建议里专门有一句：别从 &lt;code&gt;pip install&lt;/code&gt; 开始。你在受影响的 venv 里跑的任何 Python，包括 pip，都会再触发一次 &lt;code&gt;cook.pth&lt;/code&gt; 和 &lt;code&gt;sitecustomize.py&lt;/code&gt;。正确的顺序是先手动删掉那几个文件、杀掉那个常驻的 Python 守护进程，再去装固定版本。官方的判断也很直接：如果 1.107.0 在一台有外网的机器上装过，就当数据已经泄出去了。&lt;/p&gt;
&lt;p&gt;窃取的范围值得单独列一下，因为它的宽度就是「AI 时代一个开发机值多少钱」的答案。内部代号 &lt;code&gt;privcollect&lt;/code&gt;，十六个模块，覆盖浏览器（Chrome / Firefox / Edge / Safari）的密码和 cookie、AWS / GCP / Azure 凭据、SSH 私钥、环境变量、Kubernetes 的 service account token、LiteLLM 自己的数据库凭据、npm 和 PyPI 的 token、GitHub CLI token、GPG 私钥、TLS 私钥，以及 AI 开发工具的凭据（Claude / Cursor / Copilot / Gemini / Codex）。加密方式是 AES-256-CBC 配 RSA-OAEP，外传目标是 &lt;code&gt;www.litellm.dev/healthcheck&lt;/code&gt;，一个冒充官方基础设施的域名。&lt;/p&gt;
&lt;h2 id=&#34;顺带说一句这段恶意代码写得很像-ai-写的&#34;&gt;顺带说一句：这段恶意代码写得很像 AI 写的&lt;/h2&gt;
&lt;p&gt;这是 Wiz 报告里我最喜欢的一段观察。&lt;/p&gt;
&lt;p&gt;他们说 payload 有「强烈的 LLM 辅助开发痕迹」。证据是：变量名啰嗦到不像老手（比如 &lt;code&gt;encrypted_private_key_output&lt;/code&gt;）、错误处理一律用大而化之的 &lt;code&gt;try/except&lt;/code&gt; 包住、有些代码路径在没测过的平台上静默失败（Windows 上 &lt;code&gt;/tmp/&lt;/code&gt; 根本不存在，&lt;code&gt;fcntl&lt;/code&gt; 和 &lt;code&gt;fork&lt;/code&gt; 会让远程加载器直接崩）、还有一个定义了却从头到尾没被调用过的死代码模块 &lt;code&gt;exec_py&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;Wiz 的总结是：结构看起来像是反复提示、迭代出来的，happy path 能跑，边界很脆，而且没测过。&lt;/p&gt;
&lt;p&gt;我不确定这条观察能不能当成定论，但它和另一个方向的证据是吻合的：攻击工具的门槛也在被 AI 拉平。写一个能跨十六类目标收集凭据、做混合加密、再装一个常驻守护进程的 stealer，放在几年前是个需要熟练工的项目。&lt;/p&gt;
&lt;h2 id=&#34;那三月那次是怎么中的&#34;&gt;那三月那次是怎么中的&lt;/h2&gt;
&lt;p&gt;既然这是第二次，对比一下更有价值。两次的路径&lt;strong&gt;完全不同&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;三月那次是 1.82.7 和 1.82.8，三月二十四号十点三十九分上线，活了大约四十分钟就被 PyPI 隔离。根因按官方说法，来自他们 CI/CD 安全扫描流程里用到的 &lt;strong&gt;Trivy 依赖&lt;/strong&gt;——那个流水线跑了一个恶意的、没有固定版本的 Trivy 0.69.4，凭据因此暴露。JFrog 把这两次发布记为 XRAY-955589。&lt;/p&gt;
&lt;p&gt;payload 的落点也不一样：1.82.7 的恶意代码在 &lt;code&gt;proxy_server.py&lt;/code&gt; 里，1.82.8 才加上了 &lt;code&gt;litellm_init.pth&lt;/code&gt; 这个启动劫持。而这次是 &lt;code&gt;.pth&lt;/code&gt; 加 &lt;code&gt;sitecustomize.py&lt;/code&gt; 双管齐下。JFrog 明确说，目前没有证据把两次关联起来，应该当独立事件处理。&lt;/p&gt;
&lt;p&gt;三月那次之后，LiteLLM 上了新的 CI/CD v2 流水线，加了隔离环境、更强的安全门、以及发布路径的分离，然后才放出干净的 1.83.0。方向是对的，这次企业私有 registry 没被波及，多少也说明「发布路径分离」这件事真的救了人。&lt;/p&gt;
&lt;p&gt;但七个月后又被同一类攻击打穿，只是换了一条路径。我的判断是：&lt;strong&gt;发布流水线的加固不是一个能一次做完的项目&lt;/strong&gt;。你补上一道门，攻击者去试另一道；你锁住一个工具，攻击者去偷一个 token。它更像是一个要持续投入的运行状态。&lt;/p&gt;
&lt;h2 id=&#34;生态的响应速度其实比上次快了&#34;&gt;生态的响应速度，其实比上次快了&lt;/h2&gt;
&lt;p&gt;有个细节值得记一下。三月那次，包在 PyPI 上活了大约四十分钟；这次是两个小时，暴露窗口反而更长。但发现和处置的速度是反过来的。&lt;/p&gt;
&lt;p&gt;这次第一时间把事故公告发出来的是 LiteLLM 自己，公告里直接给了可执行的清理步骤，连「别以 pip 开头」这种顺序问题都写清楚了，还点名感谢了帮他们检测到事件的社区贡献者。Wiz、JFrog、Flatt 三家在当天就出了技术分析，把 payload 机制、IOC、构建产物的差异全拆开了。&lt;/p&gt;
&lt;p&gt;暴露窗口变长了，但从「有人发现」到「有可执行结论」这段时间被压得很短。对一个开源项目来说，这种反应能力比事前防御更现实：你没法保证不被攻破，但你能决定被攻破之后多快给出结论。&lt;/p&gt;
&lt;h2 id=&#34;如果你可能中招按这个顺序&#34;&gt;如果你可能中招，按这个顺序&lt;/h2&gt;
&lt;p&gt;第一，先判断有没有暴露。&lt;code&gt;pip show litellm&lt;/code&gt; 看版本；在文件系统里找 &lt;code&gt;litellm-1.107.0.dist-info&lt;/code&gt;、&lt;code&gt;cook.pth&lt;/code&gt;、&lt;code&gt;sitecustomize.py&lt;/code&gt; 以及对应的 &lt;code&gt;__pycache__&lt;/code&gt; 缓存；容器那边比对镜像 digest，别只看标签。官方也提醒了：找到这些文件能确认暴露，但&lt;strong&gt;没找到不能证明干净&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;第二，处置顺序别搞反。先删文件，再杀掉那个持有 &lt;code&gt;/tmp/remote_code_loader.lock&lt;/code&gt; 的守护进程，最后才装固定到 1.106.2。整个环境重建一遍比原地清理更稳。前面说过了，别以 pip 开头。&lt;/p&gt;
&lt;p&gt;第三，只要有外网，就假设已经外泄。轮换清单就是上面那十六类的清单：SSH 私钥、云凭据、K8s 配置、Docker 配置、npm 和 PyPI token、GitHub CLI token、浏览器保存的密码、各类 AI 工具凭据、GPG 和 TLS 私钥。&lt;/p&gt;
&lt;p&gt;第四，长期的动作其实只有三条：&lt;strong&gt;用 digest，不用浮动标签&lt;/strong&gt;（这次 &lt;code&gt;latest&lt;/code&gt;、&lt;code&gt;main-latest&lt;/code&gt;、&lt;code&gt;main-stable&lt;/code&gt; 全中）；&lt;strong&gt;在 wheel 层面做校验&lt;/strong&gt;，用哈希、provenance 或者签名，别只信源码仓库；&lt;strong&gt;CI 里的第三方工具也要固定版本&lt;/strong&gt;（三月那次就是被一个没固定版本的 Trivy 放进去的）。第三条最容易被忽略，因为安全扫描工具通常被当成「我们自己人」，没人会去怀疑它，但恰恰是它跑在流水线里、拿着发布凭据。把这三条落地，你不一定防得住下一次，但至少能让「我到底装了什么」这个问题有一个可回答的答案。&lt;/p&gt;
&lt;h2 id=&#34;顺带说一个很便宜但很有效的习惯&#34;&gt;顺带说一个很便宜、但很有效的习惯&lt;/h2&gt;
&lt;p&gt;这次三个浮动标签全中：&lt;code&gt;latest&lt;/code&gt;、&lt;code&gt;main-latest&lt;/code&gt;、&lt;code&gt;main-stable&lt;/code&gt;。你在 CI 里写 &lt;code&gt;image: litellm:latest&lt;/code&gt;，拉到的是当时那一刻指针指向的东西，而那个指针在攻击窗口里指向了恶意镜像。事后回看自己的配置文件，你找不出任何异常，因为配置里写的就是「最新」。&lt;/p&gt;
&lt;p&gt;换成 digest 就没这个问题。摘要是一个内容哈希，指针变了摘要不会跟着变，你拉到的永远是你当初验证过的那一份。代价是每次升级要手动改一次哈希，多了一点点麻烦。&lt;/p&gt;
&lt;p&gt;这次事故之后，我觉得这点麻烦完全值得。真正贵的是事后判断「我到底跑的是哪一份」，升级那一步反而便宜。&lt;/p&gt;
&lt;h2 id=&#34;写在最后&#34;&gt;写在最后&lt;/h2&gt;
&lt;p&gt;这次的三个细节，其实指向同一个结论：供应链安全里那些我们以为在做校验的动作，正在一个个变成形式。&lt;/p&gt;
&lt;p&gt;读源码？恶意代码只在 wheel 里。看 Git tag？tag 是干净的。检查依赖清单？你依赖的那个包本身是好的，出事的是它的构建产物。相信内部服务？那个内部服务恰好暴露在公网，而日志里躺着能开门的 token。&lt;/p&gt;
&lt;p&gt;而这次最讽刺的一环，是那个 token 之所以出现在日志里，是因为一个 AI 编程工具太顺手地把凭据复制了过去。我们一边在给 Agent 建身份体系、做凭据隔离，一边把一个会到处搬凭据的工具装进了每个工程师的终端。这两件事得同时做，只做一边都不够。&lt;/p&gt;
&lt;p&gt;相关阅读：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://www.yesmiracle.net/post/20260820-agent-supply-chain-poisonedskills/&#34; &gt;《Agent Supply Chain Attack: PoisonedSkills 解析——当技能文档变成可执行武器》&lt;/a&gt;：Agent 技能供给链那一侧的同源问题，可以和这篇的包管理侧对照看。&lt;/li&gt;
&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://www.yesmiracle.net/post/20261019-opencode-byok-console-credential-breach/&#34; &gt;《OpenCode 被「蒸馏攻击」打穿：1.4% 用户的前沿模型 API Key 泄露》&lt;/a&gt;：同样是凭据集中化带来的单点风险，只是那次出事的是 Console。&lt;/li&gt;
&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://www.yesmiracle.net/post/20261021-agent-identity-federation-spiffe-workload-identity/&#34; &gt;《Agent 的身份联邦：SPIFFE 那套工作负载身份正在搬进 Agent 世界》&lt;/a&gt;：那篇讲身份体系怎么建，这篇讲凭据为什么会从想不到的地方漏出去。&lt;/li&gt;
&lt;/ul&gt;
</description>
        </item>
        
    </channel>
</rss>
