mykvm 复现
1 | service ctf |
1 | mykvm: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=1993c72c363459deb6e3280880959d1f83620724, stripped |
- 64位,dynamically,开了 NX,Canary,FORTIFY
运行时出现以下报错:
1 | ➜ bin ./mykvm |
- 证明系统已经升级 libreadline.so.6 到 libreadline.so.7 或者 libreadline.so.8
使用以下命令就可以解决:
1 | ➜ x86_64-linux-gnu sudo ln -s libreadline.so.8.0 libreadline.so.6 |
- 我的电脑上是 libreadline.so.8,如果是 libreadline.so.7 修改命令即可
拖入 IDA 分析,发现如下代码:
1 | fd = open("/dev/kvm", 524290); |
- 在我的 ubuntu 上没有
/dev/kvm
- 学习 kvm:/dev/kvm简单理解
学习了一下陌生的函数:
1 | char *readline(const char *prompt); |
- prompt:指向提示字符串
- readline 的返回值就是该行文本的指针:
- 如果是一个空行,那么将返回一个空的字符串
- 如果在读某一行的过程中遇到了EOF错误,并且是空行的话,便会返回NULL
- 如果不是空行的话,便会将其当做新的一行
- 返回值由 malloc() 分配的空间存储,故调用结束后应通过 free() 显式地释放内存
1 | void add_history(const char *string); |
- 我们希望输入过的命令行,还可以通过
C-p
或者C-s
来搜索到,那么就需要将命令行加入到历史列表中,可以调用add_history()
函数来完成
1 | void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offsize); |
- start:指向欲对应的内存起始地址,通常设为NULL,代表让系统自动选定地址,对应成功后该地址会返回
- length:代表将要把文件映射到内存的字节大小
- prot:代表映射区域的保护方式
- flags:会影响映射区域的各种特性
- fd:文件描述词,代表欲映射到内存的文件
- offset:文件映射的偏移量,通常设置为“0”,代表从文件最前方开始对应,offset 必须是分页大小的整数倍
参考:
KVM (kernel-based virtual machine) 简述
/dev/kvm
设备是 kvm(kernel-based virtual machine) 虚拟机的一个设备文件
一,qemu 是一个模拟软件,运行于 linux 的用户空间,qemu 可以模拟我们能见到的所有操作系统,由于是通过模拟的方法来实现系统虚拟化,它产生的所有 CPU 指令都翻译转换一次,因此其性能非常低
二,kvm 是一个运行于内核空间的程序,为了提供一个整体的解决方案(包括用户空间工具集[由qemu提供],管理各种设备[由kvm内核模块提供]),kvm 开发团队借用了 qemu 代码,并作了一些修改,形成了一套工具,也就是 qemu-kvm(不是linux中的命令)
三,/dev/kvm
是一个字符设备,其核心作用就是让 qemu 与 kvm 内核模块结合起来,当 qemu 打开这个设备后,通过 ioctl 这个系统调用就可以获得 kvm 模块提供的三个抽象对象:
- kvm:代表 kvm 模块本身,用来管理 kvm 版本信息,创建一个 vm(通过)
- vm:代表一个虚拟机,通过 vm 的 io_ctl 接口,可以为虚拟机创建 vcpu,设置内存区间,创建中断控制芯片,分配中断等等
- vcpu:代表一个 vcpu,通过 vcpu 的 io_ctl 接口,可以启动或者暂停 vcpu,设置 vcpu 的寄存器,为 vcpu 注入中断等等
Qemu 的使用方式:
- 打开 /dev/kvm 设备
- 通过 KVM_CREATE_VM 创建一个虚拟机对象
- 通过 KVM_CREATE_VCPU 为虚拟机创建 vcpu 对象
- 通过 KVM_RUN 设置 vcpu 运行起来
因此,/dev/kvm
只是 kvm 内核模块提供给用户空间的一个接口,这个接口被 qemu-kvm 调用,通过 ioctl 系统调用就可以给用户提供一个工具用以创建,删除,管理虚拟机
Docker 搭建环境
题目给了 Dockerfile:
1 | FROM ubuntu:16.04 |
在目录下执行以下命令:
1 | $ docker build . |
第一个 images 就是目标了:
1 | ywx813@DESKTOP-I5DPK9O MINGW64 ~/Desktop/2022暑假复现/actf2022_mykvm/docker |
尝试在 docker 中运行题目文件:
1 | # ls |
- docker 没有
/dev/kvm
,只能想其他办法 - PS:启动 docker 时需要添加 -privilage 参数,来允许在 docker 使用 kvm
VMware Workstation 16 搭建环境
输入下面的grep
命令来看看是否支持硬件虚拟化:
1 | ➜ bin grep -Eoc '(vmx|svm)' /proc/cpuinfo |
- 如果 CPU 支持硬件虚拟化,这个命令将会打印出大于“0”的数字,这代表 CPU 核心数目
- 否则,如果输出为“0”,它意味着这个 CPU 不支持硬件虚拟化
在一些机器上,虚拟化技术可能被厂商在 BIOS 中禁用了,运行下面的命令可以进行查看:
1 | sudo /usr/sbin/kvm-ok /* kvm-ok需要安装 */ |
也可以通过任务管理器查看:
- 已经在BIOS中开启了VT功能
关闭虚拟机,在虚拟机设置中勾选 虚拟化引擎
中的前两个:
得到报错:
参考以下博客:
最后问题终于解决了(但是 win docker 的环境挂了),要完全关闭 Hyper-V,WSL,同时还要关闭内核隔离(虚拟机访问物理资源时需要通过 VMM 去建立一个虚拟的Ring0权限,内核隔离开启后,默认会启动 Hyper-V)
1 | ➜ bin grep -Eoc '(vmx|svm)' /proc/cpuinfo |
调试方法
为了调试环境与题目环境一样,只能使用 docker,但是 win 中的 docker 环境挂了…
最后选择在 ubuntu 中下载了一个 docker,然后利用 Dockerfile 直接在 ubuntu 上搭环境:
1 | docker build -t mykvm -f Dockerfile . |
然后即可启动,一定要后台启动才能跟远程堆环境保持一致,另外还要加 --privileged
参数以便在 docker 内访问 kvm 设备
1 | ➜ docker docker container run --privileged -p 1234:1234 -p 8000:8888 -d mykvm |
接下来就可以进入 docker 使用 gdbserver 挂调试器,然后外部连入调试即可
1 | root@9622bccfc102:/home/ctf |
- docker IP addr:172.17.0.2
1 | root@9622bccfc102:/home/ctf# gdbserver 127.0.0.1:7777 ./mykvm |
- gdbserver port:7777
然后再外部使用以下命令进行连接:
1 | pwndbg> target remote 172.17.0.2:7777 |
KVM api 使用案例
首先,我们需要打开 /dev/kvm
:
1 | kvm = open("/dev/kvm", O_RDWR | O_CLOEXEC); |
接下来,我们需要创建一个虚拟机(VM),它表示与一个模拟系统关联的所有内容,包括内存和一个或多个 CPU,KVM 以文件描述符的形式为我们提供此 VM 的句柄:
1 | vmfd = ioctl(kvm, KVM_CREATE_VM, (unsigned long)0); |
- KVM 系统提供文件描述符来控制虚拟机
虚拟机需要我们提供内存,对应虚拟机中的物理地址空间:
1 | mem = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0); |
- 申请一页内存来保存测试代码,直接使用 mmap 获得页对齐的、初始值为 0 的内存
将机器码复制到这块内存:
1 | memcpy(mem, code, sizeof(code)); |
使用 KVM_SET_USER_MEMORY_REGION 通知 KVM 虚拟机有4096字节的内存:
1 | struct kvm_userspace_memory_region region= { |
- slot 字段表示 KVM 中内存空间的索引:
- 当我们使用相同的 slot 调用 KVM_SET_USER_MEMORY_REGION 时会替换掉这个 mapping,当使用新的 slot 调用 KVM_SET_USER_MEMORY_REGION 时会创建新的 mapping
- guest_phys_addr 虚机中的基础物理地址
- userspace_addr 指向我们通过 mmap 申请的内存,注意这是一个 64-bit 的值
- memory_size 表示要映射的内存的大小:0x1000字节
现在我们的 VM 中有内存,内存中有要运行的代码,现在我们创建一个 VCPU 去运行这些代码,KVM 提供给我们控制这个 VCPU 的文件描述符,0 表示虚拟 CPU 的索引,索引的最大值可通过 KVM_CAP_MAX_VCPUS 获得:
1 | vcpufd= ioctl(vmfd, KVM_CREATE_VCPU, (unsigned long)0); |
每个虚拟CPU都关联一个 kvm_run 结构体,这个结构体用来在内核和用户空间传递 CPU 的信息,尤其是当硬件虚拟化停止时(vmexit),kvm_run 结构体包含停止原因
我们将这个结构体通过 mmap 映射到用户空间,首先我们通过 KVM_GET_VCPU_MMAP_SIZE 得知有多少内存需要映射:
1 | mmap_size = ioctl(kvm, KVM_GET_VCPU_MMAP_SIZE, NULL); |
一般 mmap_size 大于 kvm_run 结构体的大小,因为内核会使用这片区域去存其它的瞬态信息,使用 mmap 映射 kvm_run 结构体:
1 | run = mmap(NULL, mmap_size, PROT_READ|PROT_WRITE, MAP_SHARED, vcpufd, 0); |
VCPU 同样包括寄存器,KVM 将寄存器分为两类:标准寄存器和特殊寄存器,分别使用 kvm_regs 和 kvm_sregs 结构体表示,在运行我们的代码前要先初始化这个寄存器
- 初始化特殊寄存器:我们只需设置 cs 寄存器,将 cs 的 base 和 selector 设为“0”
1 | ret = ioctl(vcpufd, KVM_GET_SREGS, &sregs); |
- 初始化标准寄存器:我们大部分设为“0”,instruction pointer 设为“0x1000”,al 和 bl 设为“2”,flags 寄存器设置为“2”
1 | struct kvm_regs regs = { |
创建 VM 和 VCPU、映射和初始化内存、并设置初始寄存器状态后,我们现在可以使用 KVM_RUN 开始使用 VCPU 运行指令
每次虚拟化停止时,它都会返回,因此我们将在循环中运行它:(根据停止原因进行相应的处理)
1 | while(1) { |
参考:
1 | int ioctl(int fd, unsigned long request, ...); |
- fd:文件描述符
- request:命令码,应用程序通过下发命令码来控制驱动程序完成对应操作
- “…”:是可变参数 arg,一些情况下应用程序需要向驱动程序传参,参数就通过 ag 来传递
- 返回值:驱动程序中 ioctl 接口给的返回值,驱动程序可以通过返回值向用户程序传参,ioctl 运行失败时一定会返回“-1”并设置全局变量 errorno
关于汇编的知识
MOVZX 指令(进行全零扩展并传送)将源操作数复制到目的操作数,并把目的操作数0扩展到16位或32位(这条指令只用于无符号整数)
- 下图展示了如何将源操作数进行全零扩展,并送入16位目的操作数:
MOVSX 指令(进行符号扩展并传送)将源操作数内容复制到目的操作数,并把目的操作数符号扩展到16位或32位(这条指令只用于有符号整数)
- 下图展示了如何将源操作数进行符号扩展(符号位为“1”),并送入16位目的操作数:
全零扩展:零扩展就是全补零,不论其符号位是多少,高位全都补 “0”
符号扩展:当用更多的内存存储某一个有符号数时,由于符号位位于该数的第一位,扩展之后,符号位仍然需要位于第一位:
- 当扩展一个负数时,需要将扩展的高位全赋为 “1”
- 当扩展一个正数时(包括无符号数),符号扩展和零扩展是一样的,因为符号位就是 “0”
案例:
- 用8位二进制表示 “-1”,则是 11111111
- 用16位二进制表示时,则为 11111111 11111111 高位全都是 “1”,这个叫做符号扩展,主要用于对齐操作数
汇编语言中,CPU 对外设的操作通过专门的端口读写指令来完成:读端口用 IN 指令,写端口用 OUT 指令
漏洞分析
漏洞点一:负数溢出
1 | puts("your code size: "); |
- 令 kvm.size = -1,下一个 read 就会有栈溢出
- 不过有 canary 的限制,不能直接利用
漏洞点二:没有置空
1 | Struct kvm; // [rsp+4h] [rbp-101Ch] BYREF |
- Struct kvm 在栈上占用的范围很大,其中包含了大量存留指针
1 | memcpy( |
- memcpy 中的 size 我们可以控制
- 只要 size 足够大,就可以把栈上的指针 copy 到 bss 段(方便后续利用)
漏洞点三:越界
1 | region.slot = 0; |
- memory_size 表示要映射的内存的大小:0x40000000 字节
- 映射内存范围过大,导致 guest 代码能访问到宿主机的 bss 段中的其他变量
1 | .bss:0000000000602100 ?? code_bss db ? ; ; DATA XREF: pwn+8C↑o |
- 很明显可以通过 code_bss 访问到 dest
入侵思路
首先肯定要先 leak libc_base,然后通过 code_bss 的溢出来覆盖 dest(可以是 got 或者 hook)
然后在以下代码中输入 one_gadget 就可以了:
1 | kvm.name = input_line("host name: "); |
问题的关键就是:写一段由 VCPU 运行的 code 来进行泄露
先使用以下一段 code 进行测试,看看到底泄露了什么东西
1 | code=asm(""" |
- 利用汇编指令
out
把 al 寄存器中的值输出到 [标准输出]
结果:
1 | bbbbbbbb |
- 发现泄露的数据是从 0x603000 开始的
1 | pwndbg> x/20xg 0x603000 |
- 而距离它偏移为 0x358 的地方就有前面 copy 进去的存留指针
1 | pwndbg> distance 0x603000 0x603358 |
那么进行 leak 的 code 就可以这样写:
1 | code=asm(""" |
接下来用同样的思路覆盖 dest:
1 | pwndbg> distance 0x603000 0x60A100 |
1 | code=asm(""" |
- 把 dest 覆盖为 got 表地址附近,方便修改 put_got
最后还有一个问题,readline() 函数会将相当一部分不可见字符转义:
1 | pwndbg> telescope 0x602000 |
- 解决的办法就是:检查 one_gadget 的每一字节,如果是不可见字符则重启程序
完整 exp:
1 | from pwn import* |
小结:
这个题目搭环境花了好长时间,本地打通以后打不通远程,用 gdbserver 调试了一下才发现 heap 环境不一样
学到了不少东西:
- gdbserver 调试 docker 中的环境
- KVM api
- 一些汇编的知识