⚠️ 免责声明:本文内容仅用于安全研究与教育目的。文中涉及的漏洞均已公开披露且已有官方修复方案。所有复现示例均基于公开安全公告,不构成直接可用的攻击代码。请勿将相关技术用于未经授权的测试或攻击行为。

漏洞等级:CVSS 8.8 高危(NVD/GitHub 双源一致,S:U)
影响版本:Agenta < 0.86.8(自托管/托管平台,评测器模板渲染)
修复版本:0.86.8(模板渲染前对输入做中和处理)
漏洞类型:SSTI 服务端模板注入 → 认证后 RCE,CWE-1336
攻击前提:任意已登录账号,创建/使用 template_format="jinja2" 的评测器
影响范围:API 服务器进程:文件、环境变量(各家 LLM Key)、内网;独立使用 SDK 不受影响
利用状态:🟡 利用链已公开,暂无野外利用记录(EPSS < 0.4%,未入 KEV)
当前状态:✅ 已修复
公告编号:GHSA-cfr2-mp74-3763;RAXE-2026-012(双洞联合公告)

一、漏洞背景

1.1 老朋友,第二回

这个系列写到第 7 篇,第一次遇到同一个产品两次上榜

上一篇(第 6 篇)我们讲了 Agenta 的 CVE-2026-27952:自定义代码评测器的 RestrictedPython 沙箱被 numpy 白名单三行代码打穿,CVSS 9.9。当时官方的应对堪称果决——0.48.1 删掉 numpy 白名单止血,v0.60 干脆把整个 RestrictedPython 沙箱连根拔掉,换了一套执行模型。

按正常剧本,这事到这就该结束了:沙箱都拆了,还能逃逸个啥?

但 Agenta 评测器里还藏着第二条"用户输入→服务端执行"的通道,它连沙箱都不需要。

2026年2月26日,就在 CVE-2026-27952 披露的第二天,CVE-2026-27961 进入 NVD:Agenta 评测器的模板渲染存在服务端模板注入(SSTI)。攻击者不需要研究白名单、不需要找对象图捷径,只要在评测器输入里写两个花括号——

{{ 模板表达式 }}

服务端就会把它当代码执行。影响版本一路到 0.86.7,修复版本 0.86.8——比沙箱漏洞的修复版本晚了近 40 个小版本。换句话说,拆掉沙箱之后,这个洞还在裸奔了很久。

没读过第 6 篇的同学建议先读,Agenta 是什么、评测器为什么是高危管道、环境变量里躺着什么,那篇讲过的本文不重复。这篇只讲一件事:为什么一个 2000 年代就被 Web 安全圈讲烂的模板注入老洞,2026 年还能在 AI 基础设施里直接换走一台服务器。

1.2 SSTI 是什么:模板引擎的本职工作,就是"把字符串变成程序"

先说模板引擎是干嘛的。

你给用户发一封注册成功邮件,标题要带用户名:

template = "你好,{{ username }},欢迎注册"
render(template, username="张三")   # → "你好,张三,欢迎注册"

{{ username }} 是占位符,渲染时被替换成变量的值。这是模板引擎最正当的用途。

危险发生在一个前提被破坏时:模板内容本身,来自用户输入。

正常设计里,模板是开发者写的(存在代码仓库里),用户只提供填进占位符的数据。但如果开发者图省事,把用户输入直接拼进了模板字符串——

template = "你好," + user_input + ",欢迎注册"   # user_input 是用户可控的
Template(template).render()

那用户输入的就不再是"数据",而是"模板指令"。模板引擎会忠实地解析、执行里面所有的 {{ }}。于是:

用户输入 服务端渲染结果
张三 你好,张三,欢迎注册
{{ 7*7 }} 你好,49,欢迎注册
{{ 恶意表达式 }} 你好,表达式在服务器上执行的结果,欢迎注册

{{ 7*7 }} 返回 49 而不是原样输出,就是 SSTI 探测的经典信号——你写的算式被服务器算了,说明输入进了模板引擎的解析器。 从"能算 7×7"到"能执行系统命令",在 Python 模板引擎里只差一段对象图漫游(第二节细讲)。

SSTI(Server-Side Template Injection,服务端模板注入)这个漏洞类别至少有 15 年历史:PHP 的 Twig/Smarty、Node 的 Pug/EJS、Python 的 Jinja2/Mako/Django Template,各家模板引擎都有著名案例。OWASP 把它和 SQL 注入、命令注入并列在注入大类下——本质是同一句话:不可信数据被当成了可执行语法。

1.3 Agenta 的模板评测器:定制化卖点 = 注入面

Agenta 为什么要在评测器里引入模板引擎?

因为评测场景天然需要"格式化"。比如你想评测模型的输出是否符合某种结构,可以定义一个 Jinja2 格式的评测模板,把模型输出、参考答案、输入变量拼进去,再交给打分逻辑。平台提供 template_format 参数让用户选模板格式,选 jinja2 时,用户提供的模板内容会被 Jinja2 引擎渲染。

问题出在渲染调用的方式。GHSA-cfr2-mp74-3763 确认,漏洞代码是无沙箱的裸调用

Template(content).render()

content 里带着用户可控的内容,外面没有包 Jinja2 官方提供的 SandboxedEnvironment,也没有对 {{{%__class__ 这类模板/反射特征做中和。模板引擎拿到什么就解析什么。

一个有经验的 Web 安全工程师看到 Template(用户输入).render() 这个形状,肌肉记忆都会报警——这和 Flask 应用里把用户输入喂给 render_template_string 是同一个错误,在普通 Web 项目里属于代码审计的必查项,修法十年没变过。但它出现在一个 AI 基础设施产品的核心组件里,一直活到了 2026 年。


二、漏洞原理

2.1 从 {{ 7*7 }} 到 RCE:又是对象图漫游

Jinja2 模板里能写表达式,而 Jinja2 的表达式求值最终落在 Python 对象模型上。这意味着模板里可以摸到 Python 对象的属性——比如:

{{ ''.__class__ }}

这行模板在问:空字符串的类是什么?渲染结果是 <class 'str'>

接下来就是 Python 安全圈的祖传手艺了,和第 6 篇 numpy 逃逸链在哲学上完全同构

{{ ''.__class__ }}                    拿到 str 类
       .__mro__                       方法解析顺序(继承链)
       [1]                            走到 object——所有类的祖宗
       .__subclasses__()              列出进程里所有已加载的类(几百个)

__subclasses__() 返回当前进程加载过的全部类。里面一定有"能执行命令"或"能读文件"的类,比如 subprocess.Popenos._wrap_closewarnings.catch_warnings。攻击者遍历这个列表找到目标类的下标,就能从它的初始化函数里摸到 __globals__(全局命名空间),取出 os 模块,调用 popen

{{ ''.__class__.__mro__[1].__subclasses__()[下标].__init__.__globals__['popen']('id').read() }}

下标因环境而异,要试,但完全可自动化。实际攻击中更常用 Jinja2 自带全局对象的短链,连遍历都省了——Jinja2 模板环境默认内置了 cyclerjoinernamespacelipsum 这些辅助对象,它们的 __init__ 就定义在 Jinja2 模块里,__globals__ 里直接挂着 os

{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ lipsum.__globals__['os'].popen('cat /etc/passwd').read() }}

这是 SSTI 教科书里最经典的 payload 形态。注意它和第 6 篇的 numpy 链有多像:

CVE-2026-27952(沙箱逃逸) CVE-2026-27961(本篇 SSTI)
起点 白名单放行的 numpy 对象 模板里能摸到的任意对象
跳板 numpy.ma.core.inspect(模块内部 import 引用) __class__/__mro__/__globals__(反射元属性)
终点 sys.modules["os"].system() os.popen()
底层原理 Python 对象图全连通,任意对象可达系统模块 完全相同

第 6 篇我说过一句话:"RestrictedPython 能限制你写什么代码,但限制不了你拿到的对象认识谁。" 这篇可以补下半句:Jinja2 模板表达式也是在写 Python——模板引擎本质上就是一个内置的 Python 表达式求值器,而裸用的它,连 RestrictedPython 那层形同虚设的守卫都没有。

一个是"做了沙箱没守住",一个是"压根没做沙箱"。攻击者殊途同归。

2.2 漏洞链完整流程

攻击者(任意注册用户)
    │
    ├─① 登录Agenta平台,创建/编辑评测器
    │     template_format 选择 "jinja2"
    │
    ├─② 在模板内容字段填入:
    │     {{ cycler.__init__.__globals__.os.popen('id').read() }}
    │     (或 __class__.__mro__.__subclasses__() 遍历链)
    │
    ├─③ 触发一次评测运行(任意测试数据即可)
    │
    └─④ 服务端 Template(content).render()
       │
       ├─ Jinja2解析 {{ }} 表达式
       ├─ 经 cycler.__init__.__globals__ 摸到 os 模块
       ├─ os.popen() 以API服务器进程权限执行命令
       └─ 命令输出被当成渲染结果返回(回显型,盲注也可外带)

回显是这个洞的另一个危险点:模板渲染结果通常会展示在评测结果里,命令输出能直接看到,连反弹 shell 都不用,探测和利用成本极低。

2.3 得手之后:和第 6 篇一模一样的战利品

代码在 API 服务器进程上下文执行,攻击者继承 API 进程的全部权限:

目标 后果
环境变量 LLM API Key 重灾区——OpenAI/Anthropic/国产模型密钥、数据库凭据,明文可拖
文件系统 评测数据集、prompt 库、.env文件、容器内任意读写
数据库 PostgreSQL/Redis,全租户评测数据和账号
内网横向 以容器为跳板摸内网;云环境撞 169.254.169.254 元数据接口偷临时凭据
持久化 写定时任务、篡改评测器模板留后门

同样的,攻击门槛只是一个普通账号。两个洞的入口不同,终点完全重合。

2.4 一个容易被忽略的范围细节:漏洞代码在 SDK 包里,但只在平台端可利用

NVD 描述里有句很绕的话值得掰开说:漏洞代码虽然位于 SDK 包(agenta Python 包)中,但只有在 API 服务器进程内运行评测器时才会被服务端触发。

这和第 6 篇的范围划分逻辑一致。判断一个"库漏洞"会不会伤到你,要看这个库把外部输入送到执行点时,你处在什么信任位置


三、模拟复现

⚠️ 以下 payload 整理自公开安全公告(GHSA-cfr2-mp74-3763)与 RAXE-2026-012,仅用于理解漏洞原理与加固验证。请勿在未经授权的系统上执行。

3.1 探测:模板引擎在不在

SSTI 利用的第一步永远是良性探测,确认输入是否被当模板解析。在 jinja2 格式评测器的模板字段依次尝试:

{{ 7*7 }}
{{ 7*'7' }}

3.2 验证:最小 RCE 链(模拟环境)

在自己搭建的 0.86.7 测试实例上,模板字段填入 Jinja2 内置对象短链:

{{ cycler.__init__.__globals__.os.popen('id').read() }}

评测结果里返回的是 uid=...(agenta) gid=... 这样的进程身份信息,即证明模板注入已达成命令执行。换成读取环境变量的命令,即可验证密钥暴露面:

{{ lipsum.__globals__['os'].popen('env').read() }}

3.3 通用遍历链(短链被环境限制时)

当 Jinja2 内置对象被裁剪时,攻击者退回到标准的子类遍历法(下标需根据实际环境枚举):

{{ ''.__class__.__mro__[1].__subclasses__() }}

先让服务端把全部已加载类吐出来,找到 subprocess.Popen 或带 __globals__ 的类的下标,再构造命令执行调用。这一步解释了为什么"输入校验只拦几个关键词"防不住 SSTI——通往执行原语的路径有几十条,黑名单永远列不完。

3.4 被打过怎么查

RAXE 在双洞公告里给了 SSTI 专项的行为指标(IOC):

日志审计时重点看评测器的创建/修改记录和模板字段内容,这是攻击的必经路径。


四、修复记录

4.1 官方修复:0.86.8,在渲染前中和

0.86.8 的修法是在模板渲染之前对用户输入做恰当的中和(neutralisation),阻止模板指令被解析。这是 SSTI 的标准修法路径,按成本从低到高有三档:

  1. 能不渲染用户输入就不渲染(最优)——用户内容只作为 render()数据参数传入,绝不拼进模板字符串。占位符由开发者写死: python Template("你好,{{ name }},欢迎注册").render(name=user_input) # ✅ 用户输入只是数据 Template("你好," + user_input).render() # ❌ 用户输入成了模板
  2. 必须渲染用户模板时,上沙箱环境——Jinja2 官方提供了 SandboxedEnvironment,对不安全的属性访问(__globals____init____mro__ 等)做访问控制: python from jinja2.sandbox import SandboxedEnvironment SandboxedEnvironment().from_string(user_template).render()
  3. 输入中和/特征拦截作为兜底——过滤或转义 {{{%、反射双下划线属性。注意:autoescape=True 只防 XSS(HTML 转义),不防 SSTI,这是常见误解。

Agenta 在 0.86.8 采用的是渲染前中和路线。升级命令:

pip install --upgrade "agenta>=0.86.8"

4.2 同一个组件的三次还债:这才是本案最值得看的东西

把第 6 篇和本篇的修复线并在一起,Agenta 评测器管道(evaluator pipeline)的完整"还债史"是这样的:

时间点 版本 动作 堵的是哪条路
2026-02 公告 0.48.1 从 RestrictedPython 白名单删除 numpy 用户代码→沙箱对象图逃逸
后续架构重做 v0.60+ 彻底移除 RestrictedPython,换执行模型 语言级沙箱整体弃用
2026-02 披露,0.86.8 修 0.86.8 模板渲染前输入中和 用户模板→SSTI 对象图漫游

三次修复,三种手法,打的是同一个组件、同一类病根:评测器需要让用户提供"可执行的定制逻辑",而一切用户可控的可执行内容,都在向宿主进程的权限伸手。

RAXE 实验室把这两个洞打包成一份公告(RAXE-2026-012)的理由写得很直白:它们影响同一产品、共用评测器管道作为攻击面、造成完全相同的影响(宿主任意代码执行)。公告里有个概念我很认同——评测器这类组件存在"功能性与隔离性的根本张力"(fundamental tension between functionality and isolation):你让用户自定义评测逻辑,就必然要把用户输入送进某个解释器(Python 解释器、模板解释器);解释器离宿主权限越近,定制能力越强,安全边界越薄。

第 6 篇我说"看修复方式比看漏洞本身更有信息量,换架构才是真修复"。这篇要再补一刀:换了架构也不等于还完了债——你得把同一个组件里所有"输入→执行"的原语全部盘一遍。 沙箱拆了,模板还在;堵了代码执行面,模板执行面一直开着。安全审计的单位不应该是"CVE",应该是"组件"和"数据流"。

4.3 8.8 分还是 9.9 分?这次没有分歧,但要知道 1.1 分差在哪

上一篇的 9.9 引发过讨论,这篇评分反而异常统一:

评分方 分数 要点
GitHub(CNA) 8.8 高危 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
NVD(NIST) 收录 CNA 分值,未另行改分 页面无独立 NVD assessment
Snyk(CVSS 4.0) 7.7(推荐分 6.9) 4.0 体系计入攻击前提(AT:P)等因素

和 CVE-2026-27952 的差别只在一个维度:Scope

但两者得手后的实际后果完全一样:都是 API 进程权限、都能拖环境变量里的 LLM Key、都能横向内网。这再次印证我在系列里反复说的:CVSS 分数是对攻击路径的建模,不是对业务损失的度量。 8.8 和 9.9 在防守优先级上没有差别,都应该当成"宿主 RCE"立即处置。另外这个洞暂无野外利用记录(CISA SSVC 评级 exploitation=none,EPSS 概率 0.06%-0.32%,未入 KEV),但利用链完全公开、门槛极低,"暂未被利用"不等于"可以慢慢修"。

4.4 无法立即升级时的缓解

官方与 RAXE 给出的临时措施:


五、运营方自救清单

如果你在自托管 Agenta,或任何带"用户自定义模板/自定义代码/自定义评测"功能的 LLMOps、Agent 平台,按优先级排查:

🔴 立即执行

🟡 配置加固

🟢 长期改进


六、总结思考

6.1 一个十五年的老洞,为什么 2026 年还能在 AI 平台上换走服务器

SSTI 绝不是什么新技术。James Kettle 2015 年那篇《Server-Side Template Injection: RCE for the modern webapp》把这个类别讲透之后,模板注入就是 Web 安全测试的常规检查项。主流框架的文档、各类安全开发规范里,"不要把用户输入拼进模板"是和"不要拼接 SQL"同级别的基础戒律。

它在 Agenta 身上复活,原因不是 Agenta 的工程师不懂安全,而是AI 平台重新设计了一套"用户输入"的形态

换句话说,AI 平台为了可定制性,主动把大量用户输入推进了解释器。传统安全模型里的例外("这块功能允许用户执行逻辑")在 AI 平台变成了主功能。洞还是那个洞,守门的人换了赛道,把老地图忘了。

这不只是 Agenta 一家的问题。整个 Agent/LLMOps 生态都在重新发明 2005-2015 年 Web 安全踩过的坑:SSTI、沙箱逃逸、路径遍历、SSRF、反序列化、越权——你翻这个系列的前 6 篇,攻击向量几乎全是传统 Web 漏洞在 AI 组件上的还魂。AI 安全有大量新课题(prompt 注入、模型滥用、训练数据投毒),但工程层面的第一课,是先把 OWASP A03 注入这十年的债还上。

6.2 评测器的三重门:代码、模板、提示词

Agenta 评测器的两个洞并排放在一起,能看出 AI 执行面的三层结构:

用户输入形态 到达的执行点 Agenta 对应漏洞
代码层 Python 评测代码 语言解释器(RestrictedPython 沙箱) CVE-2026-27952
模板层 Jinja2 模板 模板引擎表达式求值器(无沙箱) CVE-2026-27961
提示词层 prompt/配置 LLM(可被间接注入诱导调工具) 第 6.3 节及行业大量案例

前两层通向的是操作系统,第三层通向的是Agent 的工具调用权限。这三层共享同一个根因:产品需要用户提供"智能/逻辑",而智能在机器里的载体就是某种可执行语法。评估一个 AI 平台的安全性,我现在会先问产品经理一个问题:"用户能在你平台的哪些地方,让系统解析他写的语法?" 这个清单拉出来的长度,基本就是攻击面的宽度。

6.3 双洞合观给开发者的三条结论

  1. 模板渲染守住三条线:用户输入永远做数据不做模板(render(template, key=user_input));必须渲染用户模板时用 SandboxedEnvironment;敏感场景把渲染也丢进隔离容器。autoescape 只防 XSS,别拿它当 SSTI 的盾。
  2. 修洞按组件修,不按 CVE 修。Agenta 如果在修沙箱逃逸时把评测器的全部执行原语盘一遍(代码执行、模板渲染、表达式解析、动态 import、eval/exec、反序列化),SSTI 大概率不会多活 40 个小版本。问"这个组件还有哪些地方让外部输入变成执行",比问"这个 CVE 修了吗"重要得多。
  3. 对象图漫游是 Python 一切"语言级隔离"的共同天花板。RestrictedPython 没拦住(06 篇),裸 Jinja2 更拦不住(本篇)。凡是要在 Python 进程内执行不可信内容的场景,隔离边界最终都要落到进程/容器/微 VM——这条结论 06 篇说过,07 篇等于用第二个 CVE 又验证了一遍。

参考资料

  1. GitHub Security Advisory GHSA-cfr2-mp74-3763, Agenta SSTI in evaluator template rendering
  2. NVD, CVE-2026-27961 详情(CVSS 8.8, CWE-1336)
  3. RAXE Security, RAXE-2026-012: Agenta LLMOps Sandbox Escape and SSTI in Evaluator Pipeline (CVE-2026-27952, CVE-2026-27961), 2026-03-04
  4. Snyk Vulnerability DB, SNYK-PYTHON-AGENTA-15369732
  5. SentinelOne, "CVE-2026-27961: Agenta SSTI Vulnerability", 2026-02-27
  6. Jinja2 官方文档:SandboxedEnvironment
  7. PortSwigger Research, James Kettle, "Server-Side Template Injection"


↙↙↙阅读原文可查看相关链接,并与作者交流