自建 AI 网关的好处是所有模型调用都集中在一个进程里,坏处也一样——这个进程能看见你所有的上游密钥。所以当 PyPI 上某个刚发布的 LiteLLM 版本被曝出植入凭据窃取后门时,它就不是“某个依赖出了点问题”,而是一次全量密钥泄露事件。
下面这套流程以 LiteLLM 1.107.0 被植入后门这一情形为例,但里面的命令和思路对任何一次 Python 供应链投毒都通用。
报错现象
往往先出现的不是报错,而是告警。常见的几种现场:
1. 依赖侧告警。 安全平台或 PyPI 侧通知某个版本被标记为恶意、已 yank;CI 里的依赖扫描直接失败。如果你已经加了哈希锁定,安装时会看到:
```
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE.
```
这条的意思是:你下载到的文件和锁文件里记录的 sha256 对不上。要么源被换了,要么包内容变了。
2. 上游厂商的用量告警。 OpenAI / Anthropic / Azure OpenAI 控制台里出现你没有发起过的调用,某个 key 的消费曲线突然抬头,或者收到额度异常邮件。
3. 网关容器出站异常。 docker logs 里多出无关的 traceback,主机连接表里出现指向陌生域名的 443 长连接,且不是任何一家模型厂商的域名。
4. 启动行为变化。 升级到该版本后启动变慢,pip check 报依赖冲突,或者容器启动时有几秒钟的空白期——那可能是在做网络请求。
影响范围要判断清楚:只要这个进程能读到的环境变量、能读到的配置文件、能连到的数据库,都算潜在泄露面。典型自建网关里同时存在:多个上游模型 key、master key、数据库连接串、Redis 密码、可观测平台的 key。清单比你想的长。
可能原因
按出现概率从高到低:
1. 直接装了被投毒的发布版本。 出现频率较高。pip install litellm 或 pip install "litellm[proxy]" 没锁版本,Dockerfile 里写了 RUN pip install -U litellm,构建那一刻拉到了有问题的版本。
2. 间接引入。 你的依赖树里某个包依赖了 LiteLLM,或者基础镜像里预装了它。你锁了自己的直接依赖,却没锁传递依赖。
3. 镜像 tag 被覆盖。 用的是 latest 或浮动 tag,CI 重新构建时底层内容换了,docker history 看不出来。
4. 并不是投毒,是网关被打穿。 master key 泄露、admin UI 暴露公网、/v1/* 没鉴权,攻击者直接拿你的网关刷模型。这种情况的账单异常和后门非常像。
5. 密钥以别的方式泄露。 .env 被 COPY . . 打进镜像层、日志里打印了 key、Git 历史里有明文。这类问题常被误判成“后门干的”。
6. 误报。 正常的版本检查、telemetry、健康探针被判成恶意外连。概率不高,但不能先自我安慰——先按真事处理。
逐条排查与解决
排查一:确认实际跑的到底是哪个版本
```bash
容器内
docker exec -it <container> pip show litellm
docker exec -it <container> python -c "import litellm, os; print(getattr(litellm,'__version__','?'), os.path.dirname(litellm.__file__))"
不启动进程,直接查镜像
docker run --rm --entrypoint pip <image> show litellm
docker run --rm --entrypoint pip <image> freeze | grep -i litellm
```
判断依据:pip show 的版本和 lock 文件里的版本是否一致。不一致,就是原因 1 或 2;一致但正好是问题版本,那就是命中了。记下 Location 字段,后面取证要用。
排查二:看包内容有没有被动过
```bash
SITE=$(docker exec <container> python -c "import litellm,os;print(os.path.dirname(litellm.__file__))")
docker exec <container> ls -la "$SITE"
docker exec <container> sh -c "grep -rnE 'subprocess|os\.system|eval\(|exec\(|b64decode|requests\.post' $SITE | head -40"
```
正常的库也会有 subprocess 之类的命中(比如调用本地 CLI),所以看上下文:如果入口文件里出现向硬编码域名 POST、启动时读 ~/.aws/credentials、或者把整个 os.environ 打包发出去,基本可以定性。
再看网络:
```bash
docker exec <container> cat /proc/net/tcp | head
宿主机侧更直观
ss -tunp | grep -i python
lsof -i -P -n -p $(pgrep -f litellm | head -1)
```
持续重连的外部 443 就值得追:
```bash
tcpdump -i any -n 'tcp port 53 or tcp port 443' -c 200
```
排查三:确认密钥有没有被用过
到每个上游厂商控制台看账单、看 API key 的最近调用时间与来源 IP。这一步比在服务器上翻文件有用——后门不一定留痕,但调用一定留记录。
同时在网关侧列出“有哪些密钥进过这个环境”,这就是轮换范围:
```bash
docker exec <container> env | grep -iE 'key|token|secret|password' | sed 's/=.*/=<redacted>/'
```
注意别把带值的输出贴进工单。
处置一:先隔离,再动手
顺序很重要:取证 → 摘流量 → 重建。发现问题后直接 docker compose down,现场就没了。
```bash
1. 摘流量
kubectl scale deploy/litellm --replicas=0
或
docker compose stop litellm
2. 留证
docker commit <container> litellm-forensic:$(date +%Y%m%d)
docker logs <container> > litellm.log 2>&1
docker exec <container> tar czf - "$SITE" > litellm-site-packages.tgz
```
跑在 K8s 上的,kubectl get pod -o yaml 存一份,外发前先把 env 里的值抹掉。
处置二:回滚到已知安全版本
不要在原有环境里 pip install 降级——被投毒的进程已经跑过,容器文件系统不可信。用干净的构建重来:
```dockerfile
版本以官方 release 页面当前版本为准,这里钉一个内部验证过的版本
RUN pip install --no-cache-dir --require-hashes -r /tmp/requirements.lock
```
生成带哈希的锁文件:
```bash
pip-compile --generate-hashes -o requirements.lock requirements.in
pip install --require-hashes -r requirements.lock
```
用 uv 的话:
```bash
uv lock
uv sync --frozen
```
关键在 --require-hashes:它保证装到的文件和锁文件记录的 sha256 完全一致,源被替换、包被重传都会直接失败。
镜像侧同时把基础镜像钉到 digest:
```dockerfile
FROM python:<version>-slim@sha256:<digest>
```
重建加 --pull --no-cache,避免复用本地旧层。
处置三:凭据轮换清单
划范围的标准是“这个秘密在网关环境里出现过吗”,宁可多换。顺序是:先建新、再切流、后吊销。
| 类别 | 具体项 | 备注 |
|---|---|---|
| 上游模型 | OpenAI / Anthropic / Azure OpenAI / 自建推理服务的 token | 逐个建新 key,顺手设预算上限 |
| 网关自身 | LITELLM_MASTER_KEY、UI 账号密码 | master key 泄露等于网关全开 |
| 加密盐 | LITELLM_SALT_KEY | 用于加密数据库里存储的上游 key,更换后旧密文无法解密,需重新录入,操作前看官方文档当前版本说明 |
| 数据层 | Postgres 密码、Redis 密码 | 连接串里出现的都算 |
| 云与编排 | 云厂商 AK/SK、K8s ServiceAccount token | 有 Workload Identity 就轮换角色信任关系 |
| 可观测 | Langfuse / OTel 后端 / webhook secret | 常被忽略,但往往带写权限 |
生成随机值:
```bash
openssl rand -hex 32
```
轮换后确认一遍:
```bash
kubectl rollout restart deploy/litellm
kubectl exec deploy/litellm -- env | grep -c 'sk-'
kubectl exec deploy/litellm -- sh -c 'grep -rl "sk-" /app 2>/dev/null' # 期望输出为空
```
旧 key 不要立刻删,先观察 24 小时调用量是否归零,再吊销。归零之前别宣布处置结束。
都不管用时的兜底方案
如果重建之后仍有异常外连,或者你没法确认泄露面,按保守假设处理:
1. 换环境重建。 新命名空间、新主机、全新密钥,从 IaC 重新拉起。旧环境整体保留做取证,不再接生产流量。
2. 出站默认拒绝。 网关只需要访问少数几个上游域名,其余全部拒掉。示例思路(具体写法按你的 CNI 或安全组来):
```bash
iptables -A OUTPUT -p tcp --dport 443 -d <上游地址段> -j ACCEPT
iptables -A OUTPUT -p tcp --dport 443 -j LOG --log-prefix "EGRESS-DENY: "
iptables -A OUTPUT -p tcp --dport 443 -j REJECT
```
3. 业务侧降级。 临时摘掉网关,业务代码直连各家 SDK,先保可用性,再慢慢修网关。
4. 请安全团队做主机取证。 保留镜像、内存、日志、抓包,别自己清理文件。
5. 通知相关方。 上游厂商可以协助查看 key 的滥用情况、冻结额度;如果涉及的密钥关联第三方数据,按公司合规流程上报。
如何预防再次发生
- 锁死依赖。 提交
requirements.lock/uv.lock/poetry.lock,CI 里只允许--require-hashes或--frozen安装,绕过 lock 的安装命令直接让流水线失败。 - 走内部源。 用 devpi / Nexus / Artifactory 做 PyPI 代理,只允许从内部源装包,外部包进内部源前过一遍扫描。上游被投毒时,这是一层缓冲。
- 镜像不用浮动 tag。
latest、1.x这类 tag 不进生产,用 digest 固定;构建加--pull --no-cache。 - 依赖升级走 PR。 版本变更产生 diff,人工看一眼 changelog 再合。自动升级很好用,但它也会自动帮你把后门装上。
- CI 里跑审计。
pip-audit、safety、SBOM 生成加漏洞库比对,失败即阻断。同时订阅 PyPI 的 yank 通知和上游项目的安全公告。 - 密钥按需授权。 每个上游 key 只开需要的模型、设预算上限、设到期时间。这样即使泄露,损失也有上限。
- 网关不裸露。 admin UI 和
/v1/*走内网或 VPN,master key 只发给确实需要它的服务;虚拟 key 按团队分发,能单独吊销。 - 出站白名单。 生产网关只放行上游域名,DNS 查询异常也纳入告警。
- 留好基线和日志。 记录“正常情况下的出站域名清单”和“正常用量曲线”,异常才有对照物。
供应链投毒的特点是,它不需要攻破你的任何一台机器,只要你在合适的时间执行了一次 pip install。所以防线的重心不在防火墙,而在“你装的东西是不是你以为什么”——版本、哈希、来源,这三件事守住,大部分事故就挡在门外了。
