GitLab AI Gateway 的 CVSS 9.9:当提示词沙箱本身成了攻击面
GitLab 在 2026 年 10 月初披露了自托管 AI Gateway 中的一个严重漏洞,编号 CVE-2026-90970,由 GitLab 作为 CNA 给出 CVSS 3.1 的 9.9 分(满分 10)。描述比分数更重要:一个拥有 Duo Agent Platform 权限的已认证用户,可以用特制的 flow 配置逃出提示词模板沙箱,最终在 AI Gateway 本身实现任意命令执行。受影响区间为:18.1.6 起至 19.2.4(不含)之前的所有版本、19.3 至 19.3.2 之前、19.4 至 19.4.1 之前。
沙箱只有被当作边界测试过,才算边界
把访问链讲明白
把这段 CVE 描述当成一条序列来读,因为序列才告诉你该做什么。攻击者先得是一个已认证用户,并拥有 Duo Agent Platform 权限;然后提交一份特制的 flow 配置;提示词模板引擎没有正确中性化特殊元素,于是特制输入逃出了模板沙箱;一旦在沙箱外,它就能触达底层操作系统,在 AI Gateway 主机上实现任意命令执行。链条里没有任何一环是奇技淫巧:它就是一个带权限前置条件的模板注入,最后却拿到了严重性量表上第二高的分数。
# Know what you are running before you read the rest of this.
# Affected ranges, per the CVE record (GitLab Inc. as CNA):
# AI Gateway 18.1.x-19.2.x : 18.1.6 up to (not including) 19.2.4
# 19.3.x : before 19.3.2
# 19.4.x : before 19.4.1
import re
AFFECTED = [(18, 1, 6, 19, 2, 4), (19, 3, 0, 19, 3, 2), (19, 4, 0, 19, 4, 1)]
def parse(v):
m = re.match(r'(\d+)\.(\d+)\.(\d+)', v)
return tuple(int(x) for x in m.groups()) if m else None
def is_affected(version):
t = parse(version)
if t is None:
return None
for lo_a, lo_b, lo_c, hi_a, hi_b, hi_c in AFFECTED:
if (lo_a, lo_b, lo_c) <= t < (hi_a, hi_b, hi_c):
return True
return False
# Enumerate every deployment, including the ones nobody remembers.
print(is_affected('19.2.3')) # True - still in the affected range
CWE-1336 就是全部故事
CWE 归类是 CWE-1336:模板引擎中特殊元素未被正确中性化。这既不是新类别,也不是机器学习问题。它和过去二十年里打穿无数 Web 框架的漏洞属于同一类:系统接受用户输入,把它喂给某个会求值或插值的东西,并且在「输入只是数据」的假设下运行——而这份输入其实是代码。让这一例特别有意思的细节在于它的标签:这个引擎存在的目的恰恰是隔离用户编写的 flow,也就是说,那个「负责兜住不可信输入」的组件,本身就是那个没能兜住不可信输入的组件。
# The access chain is the defence plan. Break one link, close the path.
# authenticated user -> Duo Agent Platform permission
# -> crafted flow -> sandbox escape -> command execution
CHAIN = {
'authenticated_user': 'enforce SSO + MFA; no shared bot accounts',
'duo_agent_platform_perm': 'grant to the smallest group that needs it',
'crafted_flow': 'no public self-service flow authoring',
'sandbox_escape': 'patch to a fixed version',
'command_execution': 'least-privilege service account, egress deny',
}
def residual_risk(controls_in_place):
open_links = [k for k in CHAIN if k not in controls_in_place]
return {'open_links': open_links,
'note': 'patching is the only link that fully closes this one'}
# Lateral movement is the part nobody plans for: what can the gateway
# reach? Model credentials, other repos, the database it brokers for.
网关位于每一次 AI 辅助评审的路径上
为什么影响范围大于一个仓库
AI Gateway 是 GitLab Duo(AI 编码助手与 Agent 套件)底下的算力层。它中转那些喂给语言模型集成的提示词流,并且跑在自托管实例上。那里的一个命令执行立足点,不是某一个仓库的立足点:每一次 AI 辅助的建议、评审与自动化 flow,都要经过那台机器。值得对自己部署问的问题是那些关于凭据与可达范围的问题:网关握着哪些模型 API Key、它还能跟哪些服务对话、它需要的数据库访问有多宽、攻击者拿到这三样东西会干什么。最后这份清单才是真正的损失评估所在。
# Look for the shape of the exploit in your own logs, not the payload.
# The CWE here is CWE-1336: template engine, special elements not neutralised.
import re, json
SUSPICIOUS = [
re.compile(r'\$\{'), # template interpolation
re.compile(r'\{\{'), # handlebars / jinja style
re.compile(r'<%'), # ERB style
re.compile(r'__\w+__'), # dunder reach-through
re.compile(r'\bimport\b|\brequire\b|\bos\b\.'),
]
def flag_flow(entry):
blob = json.dumps(entry, ensure_ascii=False)
hits = [p.pattern for p in SUSPICIOUS if p.search(blob)]
return {'user': entry.get('user'), 'flow_id': entry.get('flow_id'),
'signals': hits, 'action': 'review' if hits else 'none'}
# This finds attempts, not proofs. It is triage, not a conviction.
防守方清单
先升级到修复版本——按你的发布线是 19.2.4、19.3.2 或 19.4.1——并且从正在跑的服务上确认版本号,而不是从变更日志里确认。然后削减权限前置:到底谁真的需要 Duo Agent Platform 访问权,这个组还能更小吗?然后限制「逃逸后的进程能做什么」:最小权限的服务账号、受限的出网、别把长期有效的模型凭据放在网关的环境里。最后看 flow 编写面:如果任何已认证用户都能编写 flow,那对一个本该被隔离的功能来说,这就是一个很大的输入面。
# The durable fix: never render user-supplied templates with a
# general-purpose engine. Validate against a schema instead.
from string import Template
ALLOWED = {'repo_name', 'branch', 'max_files'} # exhaustive, by design
def render_flow(template: str, params: dict) -> str:
unknown = set(params) - ALLOWED
if unknown:
raise ValueError(f'rejecting flow: unknown params {sorted(unknown)}')
for k, v in params.items():
if not isinstance(v, (str, int)):
raise TypeError(f'{k} must be a scalar')
if isinstance(v, str) and any(c in v for c in '{}<>'):
raise ValueError(f'{k} contains template metacharacters')
return Template(template).safe_substitute(**params)
# safe_substitute + an exhaustive allowlist + no evaluation. If a
string can be evaluated, it will be.
切断链条上的任何一环,攻击路径就断了
该记什么日志,以及日志做不到什么
把 flow 编写事件连同用户身份、flow 标识与完整配置记下来。留意模板元字符——插值分隔符、双下划线「触达」模式、shell 调用动词——把它们当作分诊信号,而不是证据。要清楚这是事后检测:当你在日志里看到特制 flow 时,它已经被提交了。检测告诉你「有没有被针对」并给你范围,它不能替代边界。该 CVE 的公开通告里没有包含 PoC 利用代码,而为了验证一个「直接打补丁就能解决」的漏洞去重建一份 PoC,并不值得。
# Prove the patch landed and prove the boundary holds. Both.
#!/usr/bin/env bash
set -euo pipefail
AI_GATEWAY="${AI_GATEWAY:-https://gitlab.example.com}"
TOKEN="${DUO_TOKEN:?set DUO_TOKEN}"
ver=$(curl -sf -H "Authorization: Bearer $TOKEN" \
"$AI_GATEWAY/api/v4/ai_gateway/version" | python3 -c 'import sys,json;print(json.load(sys.stdin)["version"])')
echo "AI Gateway version: $ver"
case "$ver" in
19.2.[4-9]*|19.2.[1-9][0-9]*|19.[3-9].*|2[0-9].*) echo 'version is at or above a fixed line' ;;
*) echo 'STILL AFFECTED - patch before doing anything else'; exit 1 ;;
esac
grep -c 'prompt_template' /var/log/gitlab/ai-gateway/current || true
# Version checks are necessary and they are not sufficient. Check the
# boundary too: can the gateway process spawn a shell at all?
能长期成立的工程教训
永远不要用通用引擎去渲染用户提供的模板。这不是关于这个 CVE 或这家厂商的规则,而是关于「可被求值的文本」的规则。如果某种配置格式需要参数化,就给它一份穷尽式的可替换名白名单,只接受标量,拒绝含模板元字符的输入,并且优先使用「不求值」的替换调用——模板与 eval 之间的区别,就是表单与远程代码执行原语之间的区别。把这条规则写进开发者会读到的地方,因为下一个提示词沙箱,会由一个没听过这个 CVE 的人来写。
📌 常见问题 FAQ
CVE-2026-90970 是什么?
GitLab AI Gateway 组件中的一个严重漏洞。按其 CNA 的描述,一个拥有 Duo Agent Platform 权限的已认证用户,可以通过特制的 flow 配置逃出提示词模板沙箱,导致在 AI Gateway 上实现任意命令执行。
严重程度如何?
GitLab Inc. 作为 CNA 给出 CVSS 3.1 基础分 9.9,向量为 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H。CWE 归类为 CWE-1336(模板引擎中特殊元素未被正确中性化)。
哪些版本受影响?
按 CVE 记录:AI Gateway 从 18.1.6 起、至 19.2.4(不含)之前的所有版本;19.3 线中 19.3.2 之前的版本;19.4 线中 19.4.1 之前的版本。升级前请在 GitLab 通告上按你的发布线确认修复版本。
攻击者需要凭据吗?
需要。攻击者必须已经是拥有 Duo Agent Platform 访问权的已认证用户。这让它更偏向权限提升与沙箱逃逸,而不是一个对公网匿名开放的漏洞;这也让权限卫生成为真正的缓解措施,而不只是形式。
能从日志里检测吗?
部分可以。flow 编写事件应记录用户身份、flow 标识与完整配置,这些配置里出现的模板元字符值得分诊。但这里的检测是事后的:特制 flow 出现在日志里时,它已被提交。请把日志分析当作划定范围的手段,而不是预防手段。