发生了什么:两小时内上线又被撤下的 1.107.0
10 月 21 日 11:39:51 至 11:40:13(UTC),开源大模型网关 LiteLLM 的发布流水线在 PyPI 上连续发出 7 个 1.107.0 轮子(wheel);11:46:56,Docker Hub 与 GHCR 上的镜像同步发布。约两小时后,PyPI 于 13:23 隔离该版本,14:20 镜像标签被撤下或指回干净镜像。这不是一次普通的打包失误:轮子里被塞进了凭据窃取器和远程代码加载器。
信息来源有两处,互相独立。其一是 LiteLLM 安全负责人 Oliver Jensen 与工程师 Mateo Wang 在 10 月 23 日发布的根因复盘(官方安全博客),交代了攻击链的完整时间线;其二是 GitHub 仓库 issue #47826,由 LidorMachluf 在 10 月 21 日 12:06:41(UTC)提交,也就是发布后约 20 分钟,其中的载荷分析由其与 Upwind 安全研究团队完成。10 月 24 日 08:11--09:34(UTC),项目在 1.103.8、1.104.6、1.105.4、1.106.3 四条维护线上同时发版,我们逐个核对了 PyPI 元数据,四个版本均返回 200,而 1.107.0 返回 404。
技术拆解:三个文件,一个 12 字节的入口
投毒并非改源码,而是改了构建树。官方复盘显示,攻击者拿到了 GitHub 组织 owner 的 CLI 令牌后,把私有发布仓库的构建源指向仿冒组织 Berri-AI/litellm 的 commit 20535d8f,再用两个维护者令牌分别触发和批准生产发布门禁------这样构建日志看起来是一次正常的双人审批发版。
载荷由三个文件组成,全部被放进 wheel 的 .data/purelib/ 目录,pip 安装时会落到 site-packages 根目录:
cook.pth(12 字节),内容只有一行import cook。Python 启动时 site 模块会逐行执行.pth文件,因此不需要任何人 import 它,只要用这个解释器起进程就会执行。cook.py(22252 字节),zlib + base64 包裹的窃密程序,自称 privcollect,起一个后台线程跑一次,不依赖 C2。sitecustomize.py(25509 字节),覆盖标准库同名模块,同样在启动时自动加载。它会 fork 一个脱离终端的守护进程(setsid、stdio 指向/dev/null、用 flock 抢锁),每小时读一次远端一个 txt,非空就执行同一套窃密逻辑,并exec()拉回来的代码。
源码包(sdist)的差异更隐蔽:pyproject.toml 里多了一行 data = "cook",这行不在对应的 git tag 里。也就是说,git 仓库是干净的,被污染的是打包那一刻的构建目录------按 commit 审计代码根本看不出来。
| 对比项 | 正常版本(1.103.6--1.106.2) | 被投毒的 1.107.0 |
|---|---|---|
| site-packages 根部 | 无额外文件 | 多出 cook.pth / cook.py / sitecustomize.py |
| 解释器启动副作用 | 无 | 每次启动跑窃密线程 + 起常驻守护进程 |
| sdist 的 pyproject.toml | 无 data 字段 | 多一行 data = "cook" |
| PyPI 当前状态 | 200 | 404(已隔离) |
窃密范围按 issue 里的静态解码结果,覆盖浏览器保存的密码与 cookie、~/.ssh、云平台凭据(含实例角色与容器任务角色)、Kubernetes 服务账号、CI 与各类包管理器凭据、多个 AI 编程工具的配置,以及所有可读进程的 /proc/*/environ。最值得警惕的一条是:如果环境里有 DATABASE_URL,它会拉起 prisma 直接导出代理的凭据表与模型路由表------也就是这个网关替所有上游大模型保管的那些密钥。数据用 AES-256-CBC 加密、密钥再用 RSA-4096-OAEP 封装,POST 到仿冒域名 litellm.dev(项目真域名是 litellm.ai,前者 10 月 13 日才注册),所以从网络流量里解不出内容。
工程上怎么用:我们做了三步核对
第一,扫本机环境。我们写了一小段脚本遍历所有 site-packages,查三个文件名和对应的 __pycache__ 残留,扫了 2 个环境、耗时 5.26 秒、命中 0------这台机器没有装 litellm,属于未暴露。这只说明宿主机没暴露------网关类组件常只装在业务容器里。
第二,验证持久化机制到底有多轻。我们新建了一个干净的 venv,往它的 site-packages 里放一个 22 字节的 zz-demo.pth(一行 import 加一个自定义模块名,模块只往临时文件追加一行字),然后连续启动解释器 3 次,每次都只是跑一句 print。结果标记文件里正好 3 行。结论很清楚:.pth 不需要被 import、不需要被调用,任何一次解释器启动都是执行点 ,包括你为了排查问题而敲下的 pip list。
第三,核对分发侧状态。查 PyPI 元数据接口 /pypi/litellm/1.107.0/json 返回 404,/pypi/litellm/1.106.3/json 返回 200,含 7 个 wheel 与 1 个 sdist,单个 35--37 MB。这说明撤包与补丁都已在分发侧落地,不必再靠二手消息判断。
读者可核对清单
① 一条命令查残留(在任何 Python 环境上都能跑,先删文件再动 pip,顺序反了等于再执行一次载荷):
python3 - <<'EOF'
import glob, os, json
hits, envs = [], 0
for p in glob.glob('/**/site-packages', recursive=True):
if not os.path.isdir(p):
continue
envs += 1
for f in ('cook.pth', 'cook.py', 'sitecustomize.py'):
fp = os.path.join(p, f)
if os.path.exists(fp):
hits.append({'path': fp, 'size': os.stat(fp).st_size})
print(json.dumps({'envs': envs, 'hits': hits}, ensure_ascii=False))
EOF
生产机上把 /** 换成具体的 venv 根目录,否则全盘遍历会很慢;我们两处环境下 5 秒出结果。
② 查进程与标记文件 :ls -l /tmp/remote_code_loader.lock /tmp/.pc_once_*;ps -eo pid,cwd,cmd | grep python 里若有工作目录是 / 的孤儿 python 进程,要单独看。那个 .pc_once_* 标记只在窃密 POST 成功后写入,有它等于确认外发成功;没有它不能证明没发生------容器重建和 /tmp 清理都会抹掉痕迹。
③ 钉版本并留哈希 :短期把依赖钉到 litellm==1.106.3(或所在维护线的 1.103.8 / 1.104.6 / 1.105.4),并给 requirements 加 --require-hashes,用 pip hash 文件名.whl 生成条目。长期看,这个项目此前从未发布过构建证明(attestation),用户无法从分发侧区分"官方构建"和"别人构建",这一点比版本号本身更值得进你的准入清单。
④ 云侧回溯 :查 CloudTrail 里安装时间窗内是否有来自实例角色的 GetCallerIdentity 调用,再看该角色凭据有没有从你的网络之外被使用。载荷会主动校验偷到的角色凭据是否有效,这一步校验本身就会在审计日志里留痕。
⑤ 轮换的优先级 :实例或任务角色 → 网关门禁里存的全部上游模型密钥与主密钥、虚拟密钥 → DATABASE_URL 口令 → 该主机上任何进程环境里出现过的密钥。带外发能力的开发机、构建机要按最坏情况处理。
一句判断
我的判断是:这次事件真正的教训不在"某个包被投毒",而在审批链被降级------原本需要一台锁在办公室的物理设备批准的发布门禁,为了赶发版频率临时放宽成"任意两名工程师",攻击者拿到的恰好是两份可用令牌。对做智能体与内容工程的团队来说,模型密钥就是生产资产,把发版权限、依赖哈希、出网白名单三件事同时收紧,比事后轮换便宜一个数量级。顺带说一句,这类"动态 + 拆解"的复盘我们也在智能体世界(agentshijie.com)里持续整理,方便团队内部的准入评审直接引用。
#AI智能体 #大模型 #开源