1. 从一次内核崩溃说起:为什么条件竞争值得单独拎出来讲
第一次在userfaultfd上翻车,是在一个看起来人畜无害的驱动模块里。那个模块提供了一个ioctl接口,允许用户态传入一个指针,内核在持有某个全局锁的情况下把用户数据拷贝到一块内核缓冲区。单线程跑,稳如老狗;开两个线程同时打,十次里有三次直接panic,剩下七次是数据错乱。当时我盯着dmesg里那串BUG: unable to handle kernel paging request看了半天,才意识到问题不在拷贝逻辑本身,而在于拷贝过程中用户态内存页可能被换出,内核在缺页处理时释放了锁,另一个线程趁虚而入改了共享状态。
这就是条件竞争(Race Condition)在内核漏洞挖掘里的典型面貌:两个执行流对同一份共享资源的访问顺序不确定,导致中间状态被利用。而userfaultfd这个系统调用,恰恰是把这个"不确定"变成"可控"的关键工具。它让用户态能够接管缺页异常的处理,从而在内核访问用户内存的瞬间精确地暂停内核执行流,给另一个线程创造窗口。
这篇内容适合谁看?如果你已经能看懂基本的ioctl、copy_from_user、kmalloc这些内核接口,知道什么是cred结构体、什么是modprobe_path,但一遇到"竞争窗口怎么稳定触发""userfaultfd怎么用才能卡住内核"这类问题就发懵,那这篇就是写给你的。我会从设计思路讲到实操细节,把踩过的坑一个个摊开说。
需要先说明一点:userfaultfd在较新的内核版本里默认只对特权用户开放(vm.unprivileged_userfaultfd默认为0),这是内核社区为了收紧攻击面做的限制。本文讨论的场景默认在具备相应权限的测试环境中进行,所有实验都在隔离的虚拟机里完成,不涉及任何真实生产系统。
2. 条件竞争与userfaultfd的整体设计思路
2.1 条件竞争漏洞的本质:窗口、共享状态与调度
要理解userfaultfd为什么好用,得先把条件竞争的三个要素拆清楚。
第一个要素是共享状态。内核里常见的共享状态包括全局变量、堆对象的字段、引用计数、链表节点等。比如一个驱动用全局数组存了若干个设备上下文,每个上下文里有in_use标志位,这就是典型的共享状态。
第二个要素是访问窗口。两个执行流必须能在时间上重叠地访问同一份状态。如果访问是原子的、或者被锁严格串行化,就没有窗口。窗口往往出现在"检查"和"使用"之间——也就是经典的TOCTOU(Time-of-Check to Time-of-Use)问题。
第三个要素是调度可控性。光有窗口还不够,你得能让一个执行流在窗口内停下来,另一个执行流才能挤进去。单核系统靠时间片抢占,多核系统靠真正的并行,但这两者都不够"听话"——你没法精确控制停在哪条指令。
userfaultfd解决的正是第三个要素。它把"停下来"这件事从依赖运气变成了依赖设计。
2.2 userfaultfd到底做了什么:把缺页异常交给用户态
正常情况下,当内核访问一个尚未映射或者已被换出的用户态虚拟地址时,会触发缺页异常,内核的缺页处理程序负责分配物理页、建立映射,然后返回继续执行。整个过程对用户态透明。
userfaultfd改变了这个流程。用户态先通过userfaultfd()系统调用创建一个文件描述符,然后用ioctl注册一段虚拟内存区域(VMA)到该fd上。此后,任何对该区域的缺页访问都会产生一个事件,通过fd通知用户态线程。用户态线程处理完(比如填充数据、建立映射)后,再通过UFFDIO_COPY或UFFDIO_ZEROPAGE等命令告诉内核"可以继续了",内核才恢复被阻塞的执行流。
关键点在于:被阻塞的执行流可以是内核线程。当内核在copy_from_user里访问用户内存触发缺页时,如果这段内存注册了userfaultfd,内核就会阻塞在缺页处理里,等待用户态响应。这个阻塞是真正的睡眠,不是忙等,所以另一个线程有充足的时间去操作共享状态。
2.3 为什么选userfaultfd而不是其他手段
有人会问,用FUSE、用mmap的PROT_NONE、用信号处理不行吗?我试过几种,对比下来userfaultfd有几个不可替代的优势。
| 手段 | 能否阻塞内核 | 控制精度 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| userfaultfd | 能 | 页级,精确 | 中等 | 内核竞争窗口控制 |
| FUSE | 能 | 文件操作级 | 高 | 文件系统相关竞争 |
| PROT_NONE + 信号 | 部分 | 信号级,粗糙 | 低 | 用户态竞争 |
| 睡眠注入 | 能 | 需改内核 | 极高 | 调试场景 |
FUSE的问题在于它只能拦截文件操作,对于copy_from_user这种直接内存访问无能为力。PROT_NONE加SIGSEGV处理在用户态好用,但内核访问PROT_NONE页面会直接触发内核oops,不是可控阻塞。userfaultfd的页级精度和内核态阻塞能力,让它在内核竞争利用里成了标配。
2.4 整体利用框架的设计
一个完整的userfaultfd竞争利用,通常包含四个角色:
- 受害者线程:触发内核路径,在内核里访问注册了
userfaultfd的用户内存,从而被阻塞。 - 攻击者线程:在受害者被阻塞期间,执行对共享状态的修改操作。
- 缺页处理线程:监听
userfaultfd事件,在合适的时机释放受害者。 - 主控线程:协调上述三者的启动、同步和收尾。
设计上的核心考量是时序。缺页处理线程不能太早释放受害者,否则攻击者还没改完状态;也不能太晚,否则可能触发其他超时或检测。通常的做法是用一个pthread_barrier或者条件变量,让攻击者完成关键操作后再通知缺页处理线程。
3. 核心细节解析与实操要点
3.1 内存区域的注册与对齐要求
userfaultfd注册内存时,起始地址和长度都必须是页大小的整数倍。页大小通过sysconf(_SC_PAGESIZE)获取,x86_64上通常是4096字节。如果你mmap了一块区域,起始地址天然对齐,但长度要手动向上取整。
long page_size = sysconf(_SC_PAGESIZE); size_t len = (size + page_size - 1) & ~(page_size - 1); void *addr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);注册用UFFDIO_REGISTER:
struct uffdio_register reg; reg.range.start = (unsigned long)addr; reg.range.len = len; reg.mode = UFFDIO_REGISTER_MODE_MISSING; if (ioctl(uffd, UFFDIO_REGISTER, ®) == -1) { perror("UFFDIO_REGISTER"); }mode有三种:MISSING只处理未映射页,WP处理写保护页,MINOR处理次缺页。竞争利用里最常用的是MISSING,因为我们希望内核访问一个"还没准备好"的页。
注意:注册区域不能和已有的映射重叠,否则
ioctl会返回EINVAL。我踩过一次坑,mmap返回的地址和之前某次分配的区域挨得太近,注册时没检查,结果一直失败,排查了半天才发现是重叠。
3.2 缺页事件的处理循环
缺页处理线程的核心是一个poll加read的循环:
struct pollfd pfd = { .fd = uffd, .events = POLLIN }; while (1) { int nready = poll(&pfd, 1, -1); if (nready <= 0) continue; struct uffd_msg msg; read(uffd, &msg, sizeof(msg)); if (msg.event != UFFD_EVENT_PAGEFAULT) continue; // 在这里做同步,等待攻击者完成操作 pthread_barrier_wait(&release_barrier); // 填充页面,释放内核 struct uffdio_copy copy; copy.src = (unsigned long)fill_page; copy.dst = msg.arg.pagefault.address & ~(page_size - 1); copy.len = page_size; copy.mode = 0; ioctl(uffd, UFFDIO_COPY, ©); }这里的关键是pthread_barrier_wait。它让缺页处理线程在填充页面前等待,直到攻击者线程也到达同一个屏障。这样就能保证攻击者的操作发生在受害者被释放之前。
3.3 攻击者线程的同步策略
攻击者线程要做的事情很直接:等受害者被阻塞后,修改共享状态。但"等受害者被阻塞"这个条件怎么判断?不能靠sleep,因为时间不确定。
我的做法是用两个屏障。第一个屏障在受害者触发内核路径之前,确保攻击者已经就绪;第二个屏障在缺页处理线程里,确保攻击者完成操作后才释放受害者。
// 攻击者线程 pthread_barrier_wait(&start_barrier); // 和受害者同步启动 // 此时受害者正在进入内核,即将触发缺页 // 等待缺页处理线程的信号 pthread_barrier_wait(&release_barrier); // 执行关键操作:修改共享状态 modify_shared_state(); // 通知缺页处理线程可以释放了 pthread_barrier_wait(&done_barrier);实际写的时候,屏障的数量和顺序要根据具体漏洞调整。有些场景需要攻击者在受害者阻塞前就改好状态,有些需要阻塞后改。这个得对着反汇编和源码一点点抠。
3.4 触发路径的选择与验证
不是所有内核路径都能被userfaultfd卡住。能被卡住的前提是:内核在该路径里访问了用户态内存,且访问方式会触发缺页。常见的可卡点包括:
copy_from_user/copy_to_userget_user/put_user- 直接解引用用户态指针(较少见,但存在)
验证某个路径是否可卡,最简单的办法是写个小程序,注册userfaultfd后调用该路径,看缺页处理线程是否收到事件。如果收到了,说明可卡;如果内核直接返回错误或者崩溃,说明该路径要么没访问用户内存,要么访问方式不触发缺页。
实操心得:有些驱动会用
access_ok先检查地址范围,再copy_from_user。access_ok只检查范围不访问内存,不会触发缺页,所以不影响卡点。但如果驱动用了copy_from_user_nofault,那就卡不住了,因为nofault版本在缺页时直接返回错误而不睡眠。
4. 实操过程与核心环节实现
4.1 环境准备与内核配置检查
先确认内核支持userfaultfd。查/boot/config-$(uname -r)里有没有CONFIG_USERFAULTFD=y。如果没有,得重新编译内核。另外检查vm.unprivileged_userfaultfd:
sysctl vm.unprivileged_userfaultfd如果是0且你不是root,需要提权或者改这个值。测试环境里我一般直接sysctl -w vm.unprivileged_userfaultfd=1。
编译测试程序时链接pthread:
gcc -o race_test race_test.c -lpthread -Wall4.2 一个完整的竞争触发示例
假设有个模拟驱动,提供ioctl接口,内核在持有全局锁的情况下从用户态拷贝数据到一个全局缓冲区,拷贝后释放锁并更新一个计数器。漏洞在于:拷贝过程中如果缺页睡眠,锁会被释放(因为copy_from_user可能睡眠),另一个线程可以趁虚而入修改缓冲区。
受害者线程:
void *victim_thread(void *arg) { pthread_barrier_wait(&start_barrier); // 触发ioctl,内核会copy_from_user我们注册的地址 ioctl(dev_fd, CMD_WRITE, registered_addr); return NULL; }攻击者线程:
void *attacker_thread(void *arg) { pthread_barrier_wait(&start_barrier); pthread_barrier_wait(&release_barrier); // 受害者已被阻塞,修改共享缓冲区 ioctl(dev_fd, CMD_OVERWRITE, evil_data); pthread_barrier_wait(&done_barrier); return NULL; }缺页处理线程:
void *handler_thread(void *arg) { struct pollfd pfd = { .fd = uffd, .events = POLLIN }; poll(&pfd, 1, -1); struct uffd_msg msg; read(uffd, &msg, sizeof(msg)); // 通知攻击者可以动手了 pthread_barrier_wait(&release_barrier); // 等攻击者完成 pthread_barrier_wait(&done_barrier); // 填充页面,释放受害者 struct uffdio_copy copy = { .src = (unsigned long)fill_page, .dst = msg.arg.pagefault.address & ~(page_size - 1), .len = page_size, .mode = 0 }; ioctl(uffd, UFFDIO_COPY, ©); return NULL; }主线程负责创建userfaultfd、注册区域、初始化屏障、启动三个线程,最后join。
4.3 参数计算与时机调优
屏障的等待时间不需要计算,但填充页面的内容需要设计。如果受害者拷贝的是结构体,填充页面里就得放一个合法的结构体,否则内核后续解析可能崩溃。我一般先用正常数据跑一遍,把内核实际读到的内容dump出来,再照着构造。
另一个需要调优的是攻击者操作的粒度。操作太粗,可能覆盖了不该覆盖的字段导致崩溃;操作太细,可能改不到关键字段。我的经验是先用gdb连内核(kgdb或者qemu -s -S)单步跟一遍,看清楚共享状态的内存布局,再决定改哪几个字节。
4.4 稳定性验证与重复触发
单次成功不算成功,得能稳定复现。我的验证标准是:连续跑100次,成功率超过90%才算稳定。如果成功率低,通常是两个原因:一是屏障同步有问题,攻击者有时在受害者阻塞前就动手了;二是缺页事件的处理有延迟,poll返回后到ioctl填充之间有时间差。
提高稳定性的技巧:
- 用
CPU_SET把三个线程绑到不同核心,减少调度干扰。 - 在攻击者操作前后加
mfence内存屏障,防止编译器重排。 - 缺页处理线程用实时调度策略(
SCHED_FIFO),减少被抢占的概率。
struct sched_param param = { .sched_priority = 99 }; pthread_setschedparam(pthread_self(), SCHED_FIFO, ¶m);注意:实时调度需要root权限,且优先级设太高可能导致系统无响应,测试完记得改回来。
5. 常见问题与排查技巧实录
5.1 缺页事件收不到
最常见的原因是注册区域不对。检查三点:地址是否页对齐、长度是否页对齐、区域是否已经映射。如果mmap时用了MAP_POPULATE,页面已经被填充,就不会有缺页事件。另外,如果内核访问的是注册区域之外的地址,也不会有事件。
还有一种情况是内核路径用了copy_from_user的变体,比如__copy_from_user,在某些架构上可能不触发缺页。这个得看具体实现。
5.2 内核直接崩溃而不是阻塞
如果内核访问注册页面时直接oops,说明该路径不支持缺页睡眠。典型的是在原子上下文(持有自旋锁、中断禁用)里访问用户内存。这种情况下userfaultfd帮不上忙,得换思路,比如用FUSE或者找其他竞争点。
排查方法:看dmesg里的调用栈,如果栈里有spin_lock或者irq_save相关的函数,基本可以确定是原子上下文。
5.3 竞争窗口太短抓不住
有些漏洞的窗口只有几条指令,userfaultfd的阻塞虽然精确,但从缺页事件产生到用户态处理完,中间有上下文切换的开销。如果窗口比这个开销还短,就抓不住。
解决办法是放大窗口。比如让内核在阻塞前多做一些耗时操作,或者用多个线程反复触发,靠概率撞。我遇到过一个窗口只有mov加cmp两条指令的,最后是靠开16个线程疯狂打才偶尔成功。
5.4 成功率忽高忽低
这通常是同步问题。检查屏障的使用是否正确,特别是屏障的参与者数量是否和实际线程数一致。pthread_barrier_init的count参数如果设错,会导致永久阻塞或者提前释放。
另一个原因是CPU频率调节。测试时把cpufreq设成performance模式,避免降频导致的时序抖动:
cpupower frequency-set -g performance5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 收不到缺页事件 | 区域未注册/已映射 | 检查mmap标志和注册参数 | 去掉MAP_POPULATE,重新注册 |
| 内核oops而非阻塞 | 原子上下文访问 | 看dmesg调用栈 | 换竞争点或换手段 |
| 窗口抓不住 | 窗口过短 | 估算指令数 | 放大窗口或多线程撞 |
| 成功率低 | 同步错误 | 检查屏障count | 修正屏障参与者数量 |
| 偶发崩溃 | 填充数据非法 | dump内核读取内容 | 构造合法结构体 |
| 系统卡死 | 实时优先级过高 | 检查调度策略 | 降低优先级或改回普通调度 |
5.6 几个容易忽略的细节
文件描述符泄漏:每次测试都创建userfaultfd,如果不关闭,跑几百次后fd耗尽。记得在清理阶段close(uffd)。
内存泄漏:mmap的区域要munmap,否则地址空间越用越少。虽然测试程序退出后系统会回收,但长时间跑的压力测试里会出问题。
信号干扰:如果程序里用了SIGALRM做超时,可能和userfaultfd的阻塞冲突。我一般用poll的超时参数代替信号。
编译器优化:-O2可能把屏障调用优化掉,导致同步失效。编译时加-O0或者用volatile修饰共享变量。我习惯在调试阶段用-O0 -g,稳定后再开优化。
6. 从利用到防御:这个技术点的另一面
聊了这么多利用细节,也得说说防御视角。userfaultfd被滥用为攻击工具后,内核社区做了不少收紧。除了前面提到的vm.unprivileged_userfaultfd,还有几个方向:
一是限制注册区域,某些内核版本对可注册的VMA类型做了限制,比如不允许注册VM_IO区域。二是审计与监控,通过audit记录userfaultfd的创建和注册行为,异常频繁的调用可以作为告警信号。三是代码层面的加固,在copy_from_user前后避免释放锁,或者用copy_from_user的原子版本(如果数据量小)。
从开发者的角度,写内核代码时如果必须在持锁状态下访问用户内存,要意识到这个操作可能睡眠。正确的做法是先把用户数据拷到内核缓冲区,释放锁,再处理。如果非要持锁拷贝,那就得保证锁在睡眠期间不被释放——但这又可能引发死锁。这是个经典的权衡,没有银弹。
我在实际审计驱动代码时,会特别关注copy_from_user和锁的交互。一个简单的检查方法:看copy_from_user的调用点是否在mutex_lock和mutex_unlock之间。如果是,且mutex在睡眠期间会被其他路径获取,那就有竞争风险。
7. 一些收尾的实操体会
userfaultfd这个工具,刚上手时觉得复杂,用熟了之后会发现它的设计其实很优雅——把缺页这个底层机制暴露给用户态,让原本不可控的时序变得可控。但它也不是万能的,原子上下文卡不住、窗口太短抓不住、新内核权限收紧,这些都是实际会遇到的天花板。
我个人在用的过程中,最大的体会是同步比利用本身更难。漏洞逻辑往往几行代码就写完了,但让三个线程在正确的时间点做正确的事,调试花的时间是写逻辑的好几倍。后来我养成了一个习惯:先用printf把每个线程的关键时间点打出来,画成时间线,确认同步没问题了,再上真正的利用代码。这个笨办法帮我省了很多瞎猜的时间。
另外,测试环境一定要隔离。userfaultfd竞争利用很容易把内核搞崩,在物理机上跑就是给自己找麻烦。我一般用QEMU起一个快照虚拟机,崩了就回滚,几秒钟的事。快照这个习惯,建议每个碰内核的人都养成。