其实这应该是kernelpwn基础中的一个,但是一直没有很关心,在最近做一道题目时又提到了这一利用方式,索性就把这玩意给先写了。
简单理解
userfaultfd 并不是一种攻击的名字,它是 Linux 提供的一种让用户自己处理缺页异常的机制,初衷是为了提升开发灵活性,在 kernel pwn 中常被用于提高条件竞争的成功率。比如在如下的操作时
1 | copy_from_user(kptr, user_buf, size); |
如果在进入函数后,实际拷贝开始前线程被中断换下 CPU,别的线程执行,修改了 kptr 指向的内存块的所有权(比如 kfree 掉了这个内存块),然后再执行拷贝时就可以实现 UAF。这种可能性当然是比较小的,但是如果 user_buf 是一个 mmap 的内存块,并且我们为它注册了 userfaultfd,那么在拷贝时出现缺页异常后此线程会先执行我们注册的处理函数,在处理函数结束前线程一直被暂停,结束后才会执行后面的操作,大大增加了竞争的成功率。
相关知识
页调度与延迟加载
有的内存既不在RAM也不在交换区,例如mmap创建的内存映射页。mmap页在read/write访问之前,实际上还没有创建(还没有映射到实际的物理页),例如:mmap(0x1337000, 0x1000, PROT_READ|PROT_WRITE, MAP_FIXED|MAP_PRIVATE, fd, 0);
内核并未将fd内容拷贝到0x1337000,只是将地址0x1337000映射到文件fd。
当有如下代码访问时:
1 | char *a = (char *)0x1337000 |
若发生对该页的引用,则(1)为0x1337000创建物理帧,(2)从fd读内容到0x1337000,(3)并在页表标记合适的入口,以便识别0x1337000虚地址。如果是堆空间映射,仅第2步不同,只需将对应物理帧清0。
总之,若首次访问mmap创建的页,会耗时很长,会导致上下文切换和当前线程的睡眠。
userfaultfd
我对于他的理解就是,userfaultfd机制是用来处理页缺陷的,并且处理的handle函数我们也是可以控制的。正常的流程一般为下面几步。
Step 1: 创建一个描述符uffd
所有的注册内存区间、配置和最终的缺页处理等就都需要用ioctl来对这个uffd操作。ioctl-userfaultfd支持UFFDIO_API、UFFDIO_REGISTER、UFFDIO_UNREGISTER、UFFDIO_COPY、UFFDIO_ZEROPAGE、UFFDIO_WAKE等选项。比如UFFDIO_REGISTER用来向userfaultfd机制注册一个监视区域,这个区域发生缺页时,需要用UFFDIO_COPY来向缺页的地址拷贝自定义数据。
1 | # 2 个用于注册、注销的ioctl选项: |
STEP 2. 用ioctl的UFFDIO_REGISTER选项注册监视区域
1 | // 注册时要用一个struct uffdio_register结构传递注册信息: |
STEP 3. 创建一个处理专用的线程轮询和处理”user-fault”事件
要使用userfaultfd,需要创建一个处理专用的线程轮询和处理”user-fault”事件。主进程中就要调用pthread_create创建这个自定义的handler线程:
1 | // 主进程中调用pthread_create创建一个fault handler线程 |
一个自定义的线程函数举例如下,这里处理的是一个普通的匿名页用户态缺页,我们要做的是把我们一个已有的一个page大小的buffer内容拷贝到缺页的内存地址处。用到了poll函数轮询uffd,并对轮询到的UFFD_EVENT_PAGEFAULT事件(event)用拷贝(ioctl的UFFDIO_COPY选项)进行处理。
1 | static void * fault_handler_thread(void *arg) |
例题:QWB2021-notebook
题目分析
1 | qemu-system-x86_64 \ |
保护开启了smep,smap,kaslr进入系统可以看到还开启了kpti。
题目就是一个菜单堆题:
1 | __int64 __fastcall mynote_ioctl(file *file, unsigned int cmd, unsigned __int64 arg) |
1 | __int64 __fastcall noteadd(size_t idx, size_t size, void *buf) |
在create函数里,是首先将对应位置的size放到栈上,随后直接把输入的size放到了储存size的地址,并且接着就是一个copy_from_user。
1 | __int64 __fastcall noteedit(size_t idx, size_t newsize, void *buf) |
可以看到虽然在create函数存在size的验证,但是在edit函数不存在任何验证,并且一样是在krealloc之后就有一个copy_from_user。
1 | __int64 __fastcall notegift(void *buf) |
这里的gift函数就是把所有堆地址给泄露出来。
利用分析
那么就上面分析出来的结果可以得出目前的利用思路就是,首先利用userfaultfd机制形成一个UAF的堆块,然后利用结构中含有指针的结构体进行堆喷,那么我们就可以进一步的泄漏出地址出来。下一步就是我们可以修改指针进行栈迁移,我们可以把ROP链写在另外一个堆上面,因为可以泄露堆地址的缘故所以我们可以直接栈迁移到写了ROP链的堆上面。这里使用的结构体是tty_struct,其中有tty_operations是一个类似于vtable的函数表,所以我们利用三个堆块即可完成利用。
上面是常规思路,这里主要写一下新的思路。
内核中存在这样一个函数:
1 | struct work_for_cpu { |
上面函数在编译过后表达的形式其实是:
1 | static void work_for_cpu_fn(size_t * args) |
该函数位于 workqueue 机制的实现中,只要是开启了多核支持的内核 (CONFIG_SMP)都会包含这个函数的代码。不难注意到,这个函数非常好用,只要能控制第一个参数指向的内存,即可实现带一个任意参数调用任意函数,并把返回值存回第一个参数指向的内存的功能,且该 “gadget” 能干净的返回,执行的过程中完全不用管 SMAP、SMEP 的事情。由于内核中大量的 read / write / ioctl 之类的实现的第一个参数也都恰好是对应的对象本身,可谓是非常的适合这种场景了。考虑到我们提权需要做的事情只是 commit_creds(prepare_kernel_cred(0)),完全可以用两次上述的函数调用原语实现。
所以这里只需要用到两个堆块,第一个堆块我们要形成一个size为0x2e0的UAF堆块,第二个堆块没有要求。使用堆喷让tty_struct喷到我们的UAF堆块,但是我们需要确认他是否成功了,在tty_struct的第一个成员是一个魔数,我们可以利用他进行判断。随后修改tty_operations指针指向另外一个堆块,紧接着根据上面的函数中的偏移修改tty_struct的内容即可
综上,得出exp
1 |
|
参考链接:https://ctf-wiki.org/pwn/linux/kernel-mode/exploitation/userfaultfd/#_1