Racing_against_the_clock--hitting_a_tiny_kernel_race_window
196082 慢慢好起来

前言

说好的月更,晚了两天,这个月连发两篇等于二月发了😋

CVE-2021-4083

该漏洞的利用方式主要是向大家展示了在没有开启CONFIG_PREEMPT的内核上如何扩大条件竞争窗口的技术,这里使用的方式主要是以下三种:

  1. 使用缓存未命中来稍微扩大竞争窗口
  2. 在竞争窗口期使一个timerfd过期触发硬件中断用于打断当前执行上下文
  3. 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);

/* Alas, it calls VFS */
/* So fscking what? fput() had been SMP-safe since the last Summer */
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系统调用来说,其内部实现主要分为两步:

  1. 通过__fget函数拿到所传入文件描述符对应的struct file结构体。
  2. 通过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) {
/* File object ref couldn't be taken.
* dup2() atomicity guarantee is the reason
* we loop to catch the new file (or NULL pointer)
*/
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()唤醒这足够多的等待项,这一效果会导致内核不得不花费大量时间来处理,从而达到大大延长竞争窗口的效果。

具体的使用流程如下:

  1. 创建timbered
  2. 通过多次dup产生大量指向该定时器的文件描述符
  3. 利用500个epoll实例,在每一个实例中监视100个重复的FD
  4. 最终效果是内核的等待队列中挂载着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 // 使用 500 个 epoll 实例
#define DUPS_PER_EPOLL 100 // 每个 epoll 监听 100 个重复的 FD
// 总计产生 50,000 个等待队列项

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++) {
// 通过 dup() 复制 timerfd,增加在同一个文件上的等待者数量
int dup_fd = dup(tfd);
struct epoll_event ev = { .events = EPOLLIN };
// 将重复的 FD 加入不同的 epoll 实例
epoll_ctl(epoll_fds[i], EPOLL_CTL_ADD, dup_fd, &ev);
}
}
// 此时,内核中 tfd 的等待队列(wait_queue)已经挂载了 50,000 个项目
}

// 第二步:设置高分辨率定时器触发中断
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;

// 定时器到期时,硬件会引发中断,内核开始遍历庞大的 epoll 列表
timerfd_settime(tfd, TFD_TIMER_ABSTIME, &its, NULL);
}

// 攻击模拟线程
void *race_thread(void *arg) {
// 这里执行受害系统调用,例如 dup()
// 目标是在执行到 _fget_files 关键指令时,被上述定时器中断打断
// 导致原本 12 条指令的窗口被拉长到毫秒级
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
diff --git a/fs/file.c b/fs/file.c
index 8627dacfc4246f..ad4a8bf3cf109f 100644
--- a/fs/file.c
+++ b/fs/file.c
@@ -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尝试重新获取该文件。

 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
总字数 347.8k 访客数 访问量