在最近的 CTF Kernel Pwn 中,使用内核的 userfaultfd 机制来进行稳定 race 频率越来越高了,而我之前在做题时大都按照 man page 中的例子和一些 API 文档来猜测其用法。在 SECCON 2020 结束后,还是想从源码层面研究一下其内部机制是怎样的,这样可以在之后的应用中少踩一些坑。

引子

在 SECCON 2020 的 kstack 这道 kernel pwn 里,使用了 userfaultfd + setxattr 的所谓“堆喷射”技术,也就是这篇 blog 里介绍的技术,个人觉得使用 heap spray 的说法不是特别准确,在本题中这种技术就是用于在 double free 之后来完成对 UAF 对象的占位篡改,并不仅仅可以用于做堆喷。

说回正题 userfaultfd,这是 kernel 中提供的一种特殊的处理 page fault 的机制,能够让用户态程序自行处理自己的 page fault.
它的调用方式是通过一个 userfaultfd 的 syscall 新建一个 fd,然后用 ioctl 等 syscall 来调用相关的API. 该机制的初衷是为了方便虚拟机的 live migration,其功能还处在不断改进和发展中,文档和资料都不是很多。

这里我主要以 v5.9 内核源码为基础,结合2个 man page(参考文末的链接) 来进行说明。

UserfaultFD 的工作流程和用法

文末的引用中有一个讲得比较好的 slides,里面有张图可以很好地说明其工作流程

左侧的 Faulting threadmm coreuserfaultfd 是属于同一个(内核)线程,右边的 uffd monitor 是属于另一(内核)线程,它们在用户态应该表现为共享地址空间的2个线程。

在开始时,faulting 线程读取了一块未分配物理页的内存,触发了page fault,此时进到内核中进行处理,内核调用了 handle_userfault 交给 userfaultfd 相关的代码进行处理,此时该线程将被挂起进入阻塞状态。同时一个待处理的消息 uffd_msg 结构通过该 fd 发送到了另一个 monitor 线程,该线程可以调用相关 API 进行处理 ( UFFDIO_COPYUFFDIO_ZEROPAGE)并告知内核唤醒 faulting 线程。

从这个例子中我们能看出这里面涉及到2个线程之间的交互,我们也不能免俗地要介绍一下具体用法,阅读 userfaultfd man page 里给出的例子,里面大概分为几步:

1. 分配一个 userfault fd 并检查 API

由于 glibc 没有对应的 syscall wrapper,直接使用 syscall 函数分配。

1
2
3
4
5
6
7
8
9
10
11
 /* Create and enable userfaultfd object */

uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd == -1)
errExit("userfaultfd");

uffdio_api.api = UFFD_API;
uffdio_api.features = 0;
if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1)
errExit("ioctl-UFFDIO_API");

2. 注册需要进行 userfault 的内存区域

1
2
3
4
5
6
7
8
9
/* Register the memory range of the mapping we just created for
handling by the userfaultfd object. In mode, we request to track
missing pages (i.e., pages that have not yet been faulted in). */

uffdio_register.range.start = (unsigned long) addr;
uffdio_register.range.len = len;
uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1)
errExit("ioctl-UFFDIO_REGISTER");

3. 创建 monitor 线程,(子线程)监听 fd 的事件

在一个 for 循环中,不断使用 pool 来等待这个 fd ,然后读取一个 msg,这里读取的 msg 就是 uffd_msg 结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
for (;;) {

/* See what poll() tells us about the userfaultfd */

struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
nready = poll(&pollfd, 1, -1);
if (nready == -1)
errExit("poll");

printf("\nfault_handler_thread():\n");
printf(" poll() returns: nready = %d; "
"POLLIN = %d; POLLERR = %d\n", nready,
(pollfd.revents & POLLIN) != 0,
(pollfd.revents & POLLERR) != 0);

/* Read an event from the userfaultfd */

nread = read(uffd, &msg, sizeof(msg));
if (nread == 0) {
printf("EOF on userfaultfd!\n");
exit(EXIT_FAILURE);
}

if (nread == -1)
errExit("read");
...

4. 主线程触发指定区域的 page fault

读一下该区域的内存即可

5. (子线程)处理 fault

调用 UFFDIO_COPY 为新映射的页提供数据,并唤醒主线程,子线程自身会进入到下一轮循环中继续 poll 等待输入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* Copy the page pointed to by 'page' into the faulting
region. Vary the contents that are copied in, so that it
is more obvious that each fault is handled separately. */

memset(page, 'A' + fault_cnt % 20, page_size);
fault_cnt++;

uffdio_copy.src = (unsigned long) page;

/* We need to handle page faults in units of pages(!).
So, round faulting address down to page boundary */

uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address &
~(page_size - 1);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1)
errExit("ioctl-UFFDIO_COPY");

UserfaultFD 的相关源码

我最感兴趣的有几个地方,一是内核如何标记需要user fault处理的内存页的,二是当 page fault 发生时内核如何交给用户态处理,三是那个页映射是何时又是怎么恢复的。

涉及 userfaultfd 处理的主要有以下几个文件

  • fs/userfaultfd.c:主要逻辑都在该文件中
  • mm/userfaultfd.c:一些跟页表相关的底层函数
  • mm/memory.c:通用的处理 page fault 的代码
  • mm/mremap.c / mm/mmap.c:处理 mremap 和 mmap 的代码,本文暂时不研究

既然是一个 fd 那么就应该有实现文件操作的接口,理所当然的我们在 fs/userfaultfd.c 中找到了其实现

1
2
3
4
5
6
7
8
9
10
11
static const struct file_operations userfaultfd_fops = {
#ifdef CONFIG_PROC_FS
.show_fdinfo = userfaultfd_show_fdinfo,
#endif
.release = userfaultfd_release,
.poll = userfaultfd_poll,
.read = userfaultfd_read,
.unlocked_ioctl = userfaultfd_ioctl,
.compat_ioctl = compat_ptr_ioctl,
.llseek = noop_llseek,
};

其中主要的控制接口就是 ioctl,实现了若干个 cmd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
switch(cmd) {
case UFFDIO_API:
ret = userfaultfd_api(ctx, arg);
break;
case UFFDIO_REGISTER:
ret = userfaultfd_register(ctx, arg);
break;
case UFFDIO_UNREGISTER:
ret = userfaultfd_unregister(ctx, arg);
break;
case UFFDIO_WAKE:
ret = userfaultfd_wake(ctx, arg);
break;
case UFFDIO_COPY:
ret = userfaultfd_copy(ctx, arg);
break;
case UFFDIO_ZEROPAGE:
ret = userfaultfd_zeropage(ctx, arg);
break;
case UFFDIO_WRITEPROTECT:
ret = userfaultfd_writeprotect(ctx, arg);
break;
}

注册fault area

当我们调用 UFFDIO_REGISTER 时,内核进入到 userfaultfd_register 函数,首先检查对应 vma 的合法性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ret = validate_range(mm, &uffdio_register.range.start,
uffdio_register.range.len);
if (ret)
goto out;

start = uffdio_register.range.start;
end = start + uffdio_register.range.len;

ret = -ENOMEM;
if (!mmget_not_zero(mm))
goto out;

mmap_write_lock(mm);
if (!mmget_still_valid(mm))
goto out_unlock;
vma = find_vma_prev(mm, start, &prev);
if (!vma)
goto out_unlock;

/* check that there's at least one vma in the range */
ret = -EINVAL;
if (vma->vm_start >= end)
goto out_unlock;

这里要求地址得在用户态的范围内,而且要在 task 的 mm 结构中能找到,换言之,这应该是已经分配了虚拟地址的 page.
那么如果在这样的页上发生了 page fault,这个 page 应该是 已经mmap映射,但还未分配实际物理内存 的 page.
回想 man page 中的例子,在 mmap 成功后立即进行了 register 操作,没有对该区域进行过读写。
这也告诉我们,想对没有映射虚拟地址的 page 进行 register 是不行的。

在这之后,内核将对应的 vma 添加一个flag(VM_UFFD_MISSINGVM_UFFD_WP

1
2
3
4
5
6
7
8
9
10
11
12
vm_flags = 0;
if (uffdio_register.mode & UFFDIO_REGISTER_MODE_MISSING)
vm_flags |= VM_UFFD_MISSING;
if (uffdio_register.mode & UFFDIO_REGISTER_MODE_WP)
vm_flags |= VM_UFFD_WP;
...
new_flags = (vma->vm_flags &
~(VM_UFFD_MISSING|VM_UFFD_WP)) | vm_flags;
prev = vma_merge(mm, prev, start, vma_end, new_flags,
vma->anon_vma, vma->vm_file, vma->vm_pgoff,
vma_policy(vma),
((struct vm_userfaultfd_ctx){ ctx }));

代码中还包括其他对 vma 的检查处理,这里不再赘述。

发生 page fault 时的处理

从前面的图中我们知道内核处理 userfault 的核心函数是 handle_userfault,当发生 page fault 时,在内核中的调用链如下(x86架构)

  • exc_page_fault -> handle_page_fault (v5.9中 page fault 的入口点) / page_fault -> do_page_fault (低版本的入口点),它们位于 arch/x86/mm/fault.c
  • do_user_addr_fault
  • handle_mm_fault (mm/memory.c)
  • handle_pte_fault
  • do_anonymous_page (这里看到 userfault 仅能处理 anonymous page,4.11之后还能支持 hugetlbfs 和共享内存)
  • handle_userfault (fs/userfaultfd.c)

handle_userfault 中,默认的返回是 VM_FAULT_SIGBUS,如果该 fault 不是第二次发生 (有 FAULT_FLAG_ALLOW_RETRY 的标志位),则返回值被改为 VM_FAULT_RETRY

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
vm_fault_t ret = VM_FAULT_SIGBUS;
...
if (unlikely(!(vmf->flags & FAULT_FLAG_ALLOW_RETRY))) {
/*
* Validate the invariant that nowait must allow retry
* to be sure not to return SIGBUS erroneously on
* nowait invocations.
*/
BUG_ON(vmf->flags & FAULT_FLAG_RETRY_NOWAIT);
#ifdef CONFIG_DEBUG_VM
if (printk_ratelimit()) {
printk(KERN_WARNING
"FAULT_FLAG_ALLOW_RETRY missing %x\n",
vmf->flags);
dump_stack();
}
#endif
goto out;
}
...
ret = VM_FAULT_RETRY;

在这之后,内核将该 fault 线程挂起,将这个事件加入到 ctx->fault_pending_wqh 这个 wait queue 中,这会使得 userfaultfd_polluserfaultfd_read 返回给 monitor 线程对应的消息结构,并等待处理。在处理完成后,再将线程状态设为 TASK_RUNNING 并返回。

UFFDIO_COPY

该API用于向以分配的 page 中预先写入数据,这个写入操作位于原 fault 线程读写数据之前。

userfaultfd_copy 中,会调用 mcopy_atomic (mm/userfaultfd.c) 分配物理内存,并将用户提供的数据 copy 过去,最后调用 wake_userfault 唤醒 fault 线程。

UFFDIO_WAKE

直接调用 wake_userfault 唤醒 fault 线程。

需要注意的几点

这里主要讨论几种特殊情况,都是我在做题过程中进行的一些奇怪的尝试。

不处理直接 UFFDIO_WAKE 会怎样

第一次page fault会返回 VM_FAULT_RETRY, 之后就看具体内核的处理方式了。
有的版本内核 VM_FAULT_RETRY 之后会清除 FAULT_FLAG_ALLOW_RETRY 标志位,回到用户态之后再次触发 page fault,此时 handle_userfault 会返回 VM_FAULT_SIGBUS 导致进程收到 SIGBUS 信号终止。
而不巧的是我选择的目标 v5.9 版本的内核是直接在 do_user_addr_fault 中直接 goto 到之前的位置重新处理,但没有清除这一标志位,这就直接导致了一个死循环。

UFFDIO_UNREGISTER 再 UFFDIO_WAKE 会怎样

因为在 retry 时,该 vma 上用于 userfault 的标志位已经清除,所以将由 kernel 自行处理。

在 monitor 线程中访问 faulting 的 page 会怎样

Obviously, 会产生死循环。

小结

对于 kernel pwn 来说,userfaultfd 可以非常有效地控制 race 的顺序,通过与 setxattr syscall 的配合甚至可以完成对(几乎)任意大小的内核对象进行占位,确实是当前内核选手必须要掌握的一项技术。而由于其代码迭代比较频繁,有一些诸如 UFFDIO_REGISTER_MODE_WP (写保护)的模式并非所有版本的内核都支持(当前的 man page 还声明尚未支持),还有对 shared memory, mmap/mremap 的处理也没有进一步讨论了。有兴趣的同学可以参考源码了解一下。

Reference