Racing_against_the_clock--hitting_a_tiny_kernel_race_window
前言
说好的月更,晚了两天,这个月连发两篇等于二月发了😋
CVE-2021-4083
该漏洞的利用方式主要是向大家展示了在没有开启CONFIG_PREEMPT的内核上如何扩大条件竞争窗口的技术,这里使用的方式主要是以下三种:
- 使用缓存未命中来稍微扩大竞争窗口
- 在竞争窗口期使一个timerfd过期触发硬件中断用于打断当前执行上下文
- timerfd触发的唤醒必须遍历epoll创建的50000个等待队列项
unix_gc中__skb_queue_purge函数
在unix_gc中最后一步会调用__skb_queue_purge函数来删除skb,而在其函数内部存在以下调用链:
__skb_queue_purge=>kfree_skb=>__kfree_skb=>skb_release_all=>skb_release_head_state=>skb->destructor(skb)
在af_unix中,这里的skb->destructor所指的函数为unix_destruct_scm函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| void unix_destruct_scm(struct sk_buff *skb) { struct scm_cookie scm;
memset(&scm, 0, sizeof(scm)); scm.pid = UNIXCB(skb).pid; if (UNIXCB(skb).fp) unix_detach_fds(&scm, skb);
scm_destroy(&scm); sock_wfree(skb); }
|
在函数内部会通过unix_detach_fds函数将skb中的文件描述符设置到scm中,然后在最后销毁scm。
1 2 3 4 5 6 7 8 9 10 11 12 13
| void __scm_destroy(struct scm_cookie *scm) { struct scm_fp_list *fpl = scm->fp; int i;
if (fpl) { scm->fp = NULL; for (i=fpl->count-1; i>=0; i--) fput(fpl->fp[i]); free_uid(fpl->user); kfree(fpl); } }
|
销毁scm的函数最终会进入如上函数中,会释放文件描述符的引用计数。
dup系统调用
对于dup系统调用来说,其内部实现主要分为两步:
- 通过
__fget函数拿到所传入文件描述符对应的struct file结构体。
- 通过
fd_install将其安装到新的文件描述符。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| static struct file *__fget(unsigned int fd, fmode_t mask) { struct files_struct *files = current->files; struct file *file;
rcu_read_lock(); loop: file = fcheck_files(files, fd); if (file) {
if (file->f_mode & mask) file = NULL; else if (!get_file_rcu(file)) goto loop; } rcu_read_unlock();
return file; }
|
仔细查看__fget函数,这里首先是通过fcheck_files来找到对应的struct file结构体,经过一步验证才会调用get_file_rcu这个宏来增加该文件的引用计数,而中间这段窗口期就是我们竞争的窗口。
漏洞触发思路
| 用户态线程1 |
用户态线程2 |
__fget线程 |
unix_gc线程 |
| 创建socket A,创建socket B |
|
|
|
| 使用socket B发送socket A到socket A |
|
|
|
| close掉socket A |
通过dup获取新的socket A |
|
|
|
|
通过check_files拿到file |
|
|
|
|
因为此时还未增加引用计数 所以Socket A放入gc候选列表 |
|
|
|
检验是否是不可破循环 |
|
|
|
恢复飞行计数 |
|
|
|
最终进行gc |
|
|
|
socket A引用计数减一并释放 |
|
|
增加拿到file的引用计数 |
|
|
|
返回指向socket A的新的文件描述符 |
|
|
获得struct file的UAF |
|
|
总的来说触发思路还是比较简单,不过较为恼火的是,在拿到file和增加引用计数之间其实从汇编层面来看只存在十几条汇编指令,但是需要unix_gc线程执行很多操作,所以正常来看这貌似无法完成条件竞争。
缓存未命中
在__fget的竞争窗口中还是存在对文件的内存访问,在另外一个核心上频繁调用close(dup(fd))会频繁修改文件的引用计数导致缓存的数据会被标记为脏数据从而不会直接从CPU缓存中取出地址。
(此处的实际操作还不知道怎么操作的,没找到该CVE的exp)
通过该方法可以将窗口时间提升至几十到一两百纳秒,这远远不够unix_gc中执行的代码。
时钟中断延长窗口
高分辨率计时器(HRT)是系统时钟提供的时间粒度比传统操作系统计时器(毫秒级)精细得多(纳秒级或更高精度)。在通过使用高分辨率定时器(hrtimer),通过timerfd_settime()设置一个绝对时间戳,让硬件在预定的那一刻产生中断。在非抢占式(CONFIG_PREEMPT未开启)的内核中,虽然无法直接抢占线程,但是硬件中断依然可以打断正在执行的内核代码。
仅仅是中断无法实现扩大竞争窗口到前文所需的大小,在中断后我们仍需要CPU在中断的上下文中停留足够久的时间。这里使用的方法是,通过创建许多的epoll实例,在每一个epoll实例中监视定时器的文件描述符,从而实现在内核中挂载了足够多的等待项。当定时器到期产生硬件中断时,内核会遍历通过wake_up()唤醒这足够多的等待项,这一效果会导致内核不得不花费大量时间来处理,从而达到大大延长竞争窗口的效果。
具体的使用流程如下:
- 创建timbered
- 通过多次dup产生大量指向该定时器的文件描述符
- 利用500个epoll实例,在每一个实例中监视100个重复的FD
- 最终效果是内核的等待队列中挂载着50000个等待队列
示例代码:
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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| #include <linux/unistd.h> #include <sys/epoll.h> #include <sys/timerfd.h> #include <sys/mman.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <time.h> #include <pthread.h>
#define NUM_EPOLLS 500 #define DUPS_PER_EPOLL 100
int epoll_fds[NUM_EPOLLS]; int tfd;
void prepare_waitqueue_storm() { tfd = timerfd_create(CLOCK_MONOTONIC, 0); if (tfd < 0) { perror("timerfd_create"); exit(1); }
for (int i = 0; i < NUM_EPOLLS; i++) { epoll_fds[i] = epoll_create1(0); for (int j = 0; j < DUPS_PER_EPOLL; j++) { int dup_fd = dup(tfd); struct epoll_event ev = { .events = EPOLLIN }; epoll_ctl(epoll_fds[i], EPOLL_CTL_ADD, dup_fd, &ev); } } }
void trigger_interrupt(unsigned long nanoseconds_from_now) { struct itimerspec its; struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); its.it_value.tv_sec = now.tv_sec; its.it_value.tv_nsec = now.tv_nsec + nanoseconds_from_now; if (its.it_value.tv_nsec >= 1000000000L) { its.it_value.tv_sec++; its.it_value.tv_nsec -= 1000000000L; } its.it_interval.tv_sec = 0; its.it_interval.tv_nsec = 0;
timerfd_settime(tfd, TFD_TIMER_ABSTIME, &its, NULL); }
void *race_thread(void *arg) { return NULL; }
int main() { printf("[+] Preparing 50,000 waitqueue items...\n"); prepare_waitqueue_storm();
printf("[+] Launching race with hardware timer interrupt...\n"); trigger_interrupt(1000);
return 0; }
|
patch分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
@@ -858,6 +858,10 @@ loop: file = NULL; else if (!get_file_rcu_many(file, refs)) goto loop; + else if (files_lookup_fd_raw(files, fd) != file) { + fput_many(file, refs); + goto loop; + } } rcu_read_unlock();
|
该patch最终修改的是在__fget增加文件的引用计数之后再一次尝试取出文件,如果文件不一致则跳转至loop尝试重新获取该文件。