Gentle_knife's Studio.

bottle框架相关题目(待更)

Word count: 2kReading time: 10 min
2025/08/20
loading

介绍 Bottle 框架

Bottle 是一个用 Python 写的 **微型 Web 框架,**整个框架只有一个 Python 文件。

SimpleTemplate 模板引擎 — Bottle 0.13-dev 文档

Bottle 框架常见的漏洞是 SSTI,pickle 反序列化。

bottle模板框架注入 - Echair - 博客园VNCTF 2025 web wp - LamentXU - 博客园LilCTF做题笔记

https://ctf-files.bili33.top/VNCTF2025/VNCTF2025%20Official%20Writeup.pdf

实战

[VNCTF 2025]学生姓名登记系统

Infernity 师傅用某个单文件框架给他的老师写了一个 “学生姓名登记系统”,并且对用户的输入做了严格的限制,他自认为他的系统无懈可击,但是真的无懈可击吗?

看 wp 都说题目描述说:”某个单文件框架“,搜索或者问 ai 都能知道 python 本体的单文件框架是 bottle 框架。而我现在实践,发现确实搞不出来……搜索能力有待改进。

[XYCTF 2025]Signin

复现环境:Review::CTF

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
# -*- encoding: utf-8 -*-
'''
@File : main.py
@Time : 2025/03/28 22:20:49
@Author : LamentXU
'''
'''
flag in /flag_{uuid4}
'''
from bottle import Bottle, request, response, redirect, static_file, run, route
with open('../../secret.txt', 'r') as f:
secret = f.read()

app = Bottle()
@route('/')
def index():
return '''HI'''
@route('/download')
def download():
name = request.query.filename
if '../../' in name or name.startswith('/') or name.startswith('../') or '\\' in name:
response.status = 403
return 'Forbidden'
with open(name, 'rb') as f:
data = f.read()
return data

@route('/secret')
def secret_page():
try:
session = request.get_cookie("name", secret=secret)
if not session or session["name"] == "guest":
session = {"name": "guest"}
response.set_cookie("name", session, secret=secret)
return 'Forbidden!'
if session["name"] == "admin":
return 'The secret has been deleted!'
except:
return "Error!"
run(host='0.0.0.0', port=8080, debug=False)

路径穿越

1
2
3
4
5
6
7
8
9
@route('/download')
def download():
name = request.query.filename
if '../../' in name or name.startswith('/') or name.startswith('../') or '\\' in name:
response.status = 403
return 'Forbidden'
with open(name, 'rb') as f:
data = f.read()
return data

下载文件的接口,过滤了../../ \\,..//开头,这里考的的路径穿越 bypass,只过滤两个../并排,那么./../=../,适当绕过一下就好了。

结合<font style="color:#080808;">../../secret.txt</font>,给<font style="color:#080808;">/download</font>传参<font style="color:#080808;">?filename=./.././../secret.txt</font>,得到:

我们得到了 secret.txt 的内容:Hell0_H@cker_Y0u_A3r_Sm@r7

pickle反序列化

[LilCTF 2025]ez_bottle

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
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')
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)

1
2
3
4
UPLOAD_DIR = .../uploads  # 上传的文件存放目录
STATIC_DIR = .../static # 静态文件目录
MAX_FILE_SIZE = 1 * 1024 * 1024 # 1MB 限制
BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals", "get", "open"]

黑名单基本杜绝了 SSTI 的可能。

检查软链接 & 路径穿越

1
2
def is_symlink(zipinfo):
return (zipinfo.external_attr >> 16) & 0o170000 == 0o120000
  • 防止 zip 文件里有软链接。
1
2
def is_safe_path(base_dir, target_path):
return os.path.realpath(target_path).startswith(os.path.realpath(base_dir))
  • 防止解压 zip 的时候用 ../ 跳出目录(路径穿越攻击)。

路由功能

/upload 上传页面(GET)

  • 返回 upload.html(上传表单)

/upload 上传处理(POST)

  1. 必须是 .zip 文件
  2. 文件大小不能超过 1MB
  3. zipfile.ZipFile 解压时:
    • 禁止软链接
    • 禁止路径穿越
  4. 解压到一个用 md5(文件名 + 时间) 生成的唯一目录里
  5. 返回文件列表,并提示访问路径 /view/<md5>/<filename>

/view/<md5>/<filename>

  1. 读取文件内容(纯文本)
  2. 如果命中黑名单 → 返回 “you are hacker!!!”
  3. 否则调用
1
return template(content)
- **这里的 **`**template()**`** 是 Bottle 框架的模板渲染函数**
- 如果文件内容里是模板语法(类似 `{{...}}`),它会直接执行 **SSTI**

打的过程

% x = “hello world”

x

输出 x, 忽略行尾纯变量,无法利用行尾表达式

过滤花括号和下划线,无法获取对象

一些尝试

1
2
3
4
5
6
7
8
9
10
11
underscore = chr(95) 
builtins = underscore*2 + "builtins" + underscore*2
dict = underscore*2 + "builtins" + underscore*2
fn = getattr(import_name, len)
print(fn([1,2,3,4])) # 应该输出 4

builtins = underscore*2 + "builtins" + underscore*2
dict = underscore*2 + "builtins" + underscore*2
fn = builtins.dict["len"]
print(fn([1,2,3]))

正确思路

一个页面可以上传压缩包,会把他解压放在另一个目录里,并且这个目录可知。

禁止了软链接,路径穿越和 SSTI。

如果总结成这样看,确实是做不出来。但是题目里禁止做的事情,我们一般有两个思路,一是换别的方法做,但是更多情况我们要绕过 bypass 继续用这个事情。当我们上传压缩包的时候,只要是软链接,路径穿越直接上传不上去,那就没办法了。但是我们重新看查看文件的路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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)}"

这里包含了黑名单,查看到的是"you are hacker!!!nonono!!!",但是实际上他还是渲染了,只是不给你看。所以我们通过别的文件看就可以了。

bottle 框架里面%可以执行 python 代码。

也就是我们先上传一个名为 1.tpl 的文件,内容是{{! open('/flag').read()}},来获得 flag。

/upload 处理函数里:

1
2
3
4
5
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)
  • UPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads')
    也就是说,所有上传文件都放在当前脚本目录下的 uploads/ 目录里。

再上传一个内容为:

的文件用来读取 flag 就可以了。

bottle.zip

Bottle 斜体字 SSTI

聊聊bottle框架中由斜体字引发的模板注入(SSTI)waf bypass - LamentXU - 博客园

CATALOG
  1. 1. 介绍 Bottle 框架
  2. 2. 实战
    1. 2.1. [VNCTF 2025]学生姓名登记系统
    2. 2.2. [XYCTF 2025]Signin
      1. 2.2.1. 路径穿越
      2. 2.2.2. pickle反序列化
    3. 2.3.
    4. 2.4. [LilCTF 2025]ez_bottle
      1. 2.4.0.1. 检查软链接 & 路径穿越
    5. 2.4.1. 路由功能
      1. 2.4.1.1. /upload 上传页面(GET)
      2. 2.4.1.2. /upload 上传处理(POST)
      3. 2.4.1.3. /view/<md5>/<filename>
    6. 2.4.2. 打的过程
    7. 2.4.3. 正确思路
  3. 2.5. Bottle 斜体字 SSTI