夜雨聆风学习资料网

ARTICLE · 1234979

不用偷密钥:9100 万次下载的 AI 网关,被自己的加密功能提权了

不用偷密钥:9100 万次下载的 AI 网关,被自己的加密功能提权了

SECURITY RESEARCH · 2026.10.23

一个 CVSS 9.9 的漏洞,攻击者不需要偷任何密钥。

他只需要请求服务帮他把一段伪造的"管理员凭据"加密一下,再把服务吐回来的密文,当成自己的通行证递回去。

两天前,这个补丁刚刚发布。我们用真实的 LiteLLM 代理、真实的 HTTP 请求,把这条链子从头走到了尾。

这个漏洞最值得写的地方,不是它有多严重,而是它**静默**——代码照样跑,测试照样过,只有当你试图用密文冒充 token 的时候,它才露出破绽。

01 · 今天筛出了什么

NVD 两天合计发布 593 条 CVE。按"CVSS ≥ 9.6"筛出 38 条,再按"使用量 ≥ 500 万"过一遍,只剩 4 条。

指标
10-22
10-23
NVD 当日发布
374
219
CVSS ≥ 9.6
7
31
使用量达标
3
1
闭源商业产品(已排除)
3
30

10-23 那 31 条里,有 30 条是闭源商业产品——27 条 HPE 设备固件、3 条微软。它们不适用"下载量"这套口径,直接出局。

真正进入复现环节的是四个:LiteLLM、Pillow、Apache Pulsar Manager(部分复现)、GitLab(未复现)。其中 Pillow 那条之前已经单独发过,今天只写 LiteLLM。

一个 9100 万次下载的 AI 网关,权限提升的根因是一行代码里的一个 None。

02 · LiteLLM:一把钥匙,开两个门

LiteLLM 是一个 AI 网关。所有模型调用的入口都在这儿,它手里攥着全公司的 API key、团队配额、计费账本。

FACT SHEET

CVE CVE-2026-114612 / GHSA-7hp6-4w63-5g45 CVSS 9.9 CRITICAL CWE CWE-269 / 345 / 441 项目 BerriAI/litellm(MIT,61.8k star) 使用量 PyPI 近 30 天 91,222,162 次 受影响 >= 1.87.0,多分支修复 危害 internal_user → proxy_admin → 宿主机命令执行

根因一句话就能说完,但这句话背后是一个很容易被忽略的设计决定:它用同一把 salt key 派生密钥、同一个 AAD(受影响版本里写死 None),去服务两个完全不同的安全域——存进数据库的密文,和发给浏览器的会话凭证。

看代码会更清楚:

def _encrypt_aes_gcm(value, key):     nonce = os.urandom(12)     aes = AESGCM(_derive_key(key))     blob = aes.encrypt(         nonce, value.encode(),         None,     # ← AAD 写死 None     )     return blob  def _decrypt_aes_gcm(value, key):     return AESGCM(         _derive_key(key)     ).decrypt(nonce, blob, None)

AAD 是空,意味着密文不携带任何"用途标签"。而这个模块只有这一对函数——存密码用它,发 token 也用它。

两个安全域,在密码学上完全不可区分。

03 · 把"加密"本身当成 oracle

GHSA 原文把整条链写得很清楚,精妙之处在于:攻击者不需要偷任何密钥。

① 攻击者持有 internal_user 身份——已认证,低权限。

② 调一次 POST /key/generate,在 metadata 的敏感字段里塞入一段伪造的 admin 凭据。

③ 代理命中敏感键规则,自己动手把这段伪造凭据加密了,密文落库,并原样回显在响应里。

④ 攻击者剥掉前缀,把剩下的那段字符串当作 Authorization: Bearer 递回去。

⑤ 鉴权模块:不是 sk- 前缀 → 不走虚拟 key 分支;会话 token 分支启用 → 用同一把 salt key 把它解开。

⑥ 解出来的 JSON 里写着 user_role = "proxy_admin"——代理直接采信。

⑦ 授予 admin:可读全部 key、团队、配置、全局花费;并可通过 MCP stdio 端点在宿主机执行任意命令。

"加密"这个动作本身,被当成了 oracle。

攻击者全程没有窃取任何密钥——密文是服务自己生成、自己回显的。

04 · 实测:四个端点,两种命运

光看 advisory 不够。我们自建了 Postgres(用 PyPI 的 pgserver 自带二进制,不需要 Docker),起了两个真实的 LiteLLM 代理——一个 1.99.0(受影响),一个 1.104.0(修复版)。

同一段伪造载荷、同一批 proxy_admin 专属端点:

端点
低权限身份
1.99.0伪造密文
1.104.0伪造密文
/user/list
403
200 ✓
401 ✗
/team/list
401
200 ✓
401 ✗
/settings
401
200 ✓
401 ✗
/global/spend
401
200 ✓
401 ✗

参照基线:admin 身份对这四个端点均为 200。判据是同一端点、同一时刻,低权限身份得 401/403,而同一段密文重放得 200——这个差值就是提权本身。

还有一个细节值得说:这段密文去 /key/info 查,返回 500。说明它不是数据库里的任何一把虚拟 key——纯粹是密码学重放。

05 · 修复:密文照解,只是不认

这是整篇最容易写错的地方,值得单独一段。

在 1.104.0 上,decrypt_value_helper()仍然能解开存储密文,密文也仍然会被回显。如果只看到这一步,很容易误判成"漏洞没修干净"。

修复不是"让密文不可解密",而是"让密文不再被当作会话 token 接受"。

补丁(commit 1d5fdc87,6 files / +235 / −28)引入了 encrypt_bearer_token / decrypt_bearer_token,把 prefix同时用作字符串前缀和 AES-GCM 的 AAD。会话鉴权改走新函数,对同一段存储密文直接返回 None。

补丁里的注释就一句话:"a token can't change kind."

06 · 如果你在跑 LiteLLM

① 先确认自己在哪个分支。这是多分支修复,修复版分别是 1.100.4 / 1.101.3 / 1.102.2 / 1.103.1 / 1.104.0-rc.2。对着自己那条线升级,别只看一个大版本号。

② 注意 1.87.0 ~ 1.90.x 的特殊性。这几个小版本默认不开会话 token 通道,只有显式设了 EXPERIMENTAL_UI_LOGIN=true 才可利用。1.91.0 起是默认配置即可利用。

③ 一时升不了,就先关掉那个开关。设 EXPERIMENTAL_UI_LOGIN=false。代价是真实的:会破坏 CLI SSO 和 Claude Code gateway 登录,不是零成本的开关。

④ 如果你的网关对低权限用户开放,按"已失陷"排查。提权后可达 MCP stdio 端点,也就是宿主机命令执行。检查宿主机的异常进程、新增凭据,以及这个网关保管的所有上游 key。

写在最后

今天这条,核心其实只有一个观察:一个设计决定能有多贵。

AAD 少写一个参数——None——两个安全域就合并了。更麻烦的是这个合并是静默的:加密解密都正常工作,接口都返回 200,测试也全都过。只有当你试图用存储密文冒充会话 token 的时候,它才露出破绽。

而攻击者甚至不需要知道任何密钥。他只需要让服务用自己的加密功能,把一段伪造的管理员凭据加密一遍,再把服务吐出来的密文原样递回去。"加密"这个动作本身,被当成了 oracle。

这也是为什么修复方式值得琢磨:补丁没有让密文变得不可解密——1.104.0 上存储密文照样能解开、照样被回显。它做的是给两种用途打上不同的标签,让「一个 token 不能改变自己的种类」。安全边界往往不是靠"把东西藏得更深"建立的,而是靠让不同用途的东西在密码学上可区分。

研究边界

所有复现仅在本机隔离环境进行(服务硬编码绑定 127.0.0.1),出站网络在 socket 层封死;未配置任何真实 LLM 凭据或 endpoint;未对任何外部或生产系统做过扫描探测;未执行持久化、后门、横向移动、DoS 或数据破坏类操作;未构造武器化载荷。LiteLLM 的 RCE 落点仅做代码核实,未执行任何命令。全部复现环境已销毁,0 残留。

— 漏洞研究日报 · 2026.10.23 —

相关学习资料