ARTICLE · 1234979
不用偷密钥:9100 万次下载的 AI 网关,被自己的加密功能提权了
SECURITY RESEARCH · 2026.10.23
一个 CVSS 9.9 的漏洞,攻击者不需要偷任何密钥。
他只需要请求服务帮他把一段伪造的"管理员凭据"加密一下,再把服务吐回来的密文,当成自己的通行证递回去。
两天前,这个补丁刚刚发布。我们用真实的 LiteLLM 代理、真实的 HTTP 请求,把这条链子从头走到了尾。
这个漏洞最值得写的地方,不是它有多严重,而是它**静默**——代码照样跑,测试照样过,只有当你试图用密文冒充 token 的时候,它才露出破绽。
01 · 今天筛出了什么
NVD 两天合计发布 593 条 CVE。按"CVSS ≥ 9.6"筛出 38 条,再按"使用量 ≥ 500 万"过一遍,只剩 4 条。
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 专属端点:
参照基线: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 —















































































