影响范围
- 引入commit:fdb9c405e35bdc6e305b9b4e20ebc141ed14fc81
- 修复commit:7e6bc1f6cabcd30aba0b11219d8e01b952eacbb6
- 时间跨度:2020/04/28 ~ 2022/07/03
- 版本跨度:v5.8 ~ v5.19
简介
在netfilter模块的nft_setelem_parse_data() 中存在一处类型混淆(type confusion),从而在nft_set_elem_init() 中产生了堆越界问题。
修复方案
更新内核或使用下面命令禁止普通用户改变user namspace。
1 | sysctl kernel.unprivileged_userns_clone=0 |
漏洞分析
漏洞的patch如链接:https://github.com/torvalds/linux/commit/7e6bc1f6cab.diff
1 | diff --git a/net/netfilter/nf_tables_api.c b/net/netfilter/nf_tables_api.c |
变化不是很大。仔细看的话,原代码其实强制把set->dtype当做NFT_DATA_VERDICT,但实际上set->dtype有其他类型的可能性(类型混淆)。
这个函数其实是在5.8加入的。在5.8之前,调用逻辑是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 // >>> net/netfilter/nf_tables_api.c:4488
/* 4488 */ static int nft_add_set_elem(struct nft_ctx *ctx, struct nft_set *set,
/* 4489 */ const struct nlattr *attr, u32 nlmsg_flags)
/* 4490 */ {
------
/* 4500 */ struct nft_data data;
------
/* 4595 */ if (nla[NFTA_SET_ELEM_DATA] != NULL) {
/* 4596 */ err = nft_data_init(ctx, &data, sizeof(data), &d2,
/* 4597 */ nla[NFTA_SET_ELEM_DATA]);
/* 4598 */ if (err < 0)
/* 4599 */ goto err2;
/* 4600 */
/* 4601 */ err = -EINVAL;
/* 4602 */ if (set->dtype != NFT_DATA_VERDICT && d2.len != set->dlen)
/* 4603 */ goto err3;
------
/* 4629 */
/* 4630 */ nft_set_ext_add_length(&tmpl, NFT_SET_EXT_DATA, d2.len);
/* 4631 */ }在5.8之后添加了
nft_setelem_parse_data(),逻辑变成如下:
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 // >>> net/netfilter/nf_tables_api.c:5697
/* 5697 */ static int nft_add_set_elem(struct nft_ctx *ctx, struct nft_set *set,
/* 5698 */ const struct nlattr *attr, u32 nlmsg_flags)
/* 5699 */ {
------
/* 5706 */ struct nft_set_elem elem;
------
/* 5884 */ if (nla[NFTA_SET_ELEM_DATA] != NULL) {
/* 5885 */ err = nft_setelem_parse_data(ctx, set, &desc, &elem.data.val,
/* 5886 */ nla[NFTA_SET_ELEM_DATA]);
/* 5887 */ if (err < 0)
/* 5888 */ goto err_parse_key_end;
------
/* 5914 */
/* 5915 */ nft_set_ext_add_length(&tmpl, NFT_SET_EXT_DATA, desc.len);
/* 5916 */ }
// >>> net/netfilter/nf_tables_api.c:5116
/* 5116 */ static int nft_setelem_parse_data(struct nft_ctx *ctx, struct nft_set *set,
/* 5117 */ struct nft_data_desc *desc,
/* 5118 */ struct nft_data *data,
/* 5119 */ struct nlattr *attr)
/* 5120 */ {
/* 5121 */ int err;
/* 5122 */
/* 5123 */ err = nft_data_init(ctx, data, NFT_DATA_VALUE_MAXLEN, desc, attr);
/* 5124 */ if (err < 0)
/* 5125 */ return err;
/* 5126 */
/* 5127 */ if (desc->type != NFT_DATA_VERDICT && desc->len != set->dlen) {
/* 5128 */ nft_data_release(data, desc->type);
/* 5129 */ return -EINVAL;
/* 5130 */ }
/* 5131 */
/* 5132 */ return 0;
/* 5133 */ }仔细看!5.8之前调用
nft_data_init()时第3个参数是值为sizeof(data),即16bytes。但到了5.8之后第3个参数莫名变成了NFT_DATA_VALUE_MAXLEN,即64bytes。其实patch修复的那一行在5.8之前已经存在,但5.8前却不受影响,因为nft_data_init()的第三个参数保证了datalen不会大于16bytes,从而不会导致溢出。
先看nft_setelem_parse_data()函数的参数部分,attr是用户可控的数据,将attr传入nft_data_init()中对data和desc进行初始化。因此data和desc也是用户可控的。
1 | // >>> net/netfilter/nf_tables_api.c:5116 |
下面是初始化的过程。
先进入nla_parse_nested_deprecated()将nla(即上层的attr)中的数据解析到tb中。然后根据tb中的值在设置data和desc(Line 9543 和 Line 9546)
1 | // >>> net/netfilter/nf_tables_api.c:9530 |
1 | // >>> net/netfilter/nf_tables_api.c:9486 |
初始化完data和desc后就是下面这段判断了。
1 | // >>> net/netfilter/nf_tables_api.c:5116 |
由于上面说了,desc和data是通过解析用户提供的attr得到的,而set->dlen又是用户在创建netfilter的set时可以控制的(但上层存在一个限制,即set->dlen < 64)。
重点来了,假设此时set->dtype == NFT_DATA_VALUE且desc->type == NFT_DATA_VERDICT,则desc->len == 16,set->dlen和desc->len可以不同,但由于这两个判断中间是“与”逻辑,因此并不会走到错误分支。
nft_setelem_parse_data()外层由nft_add_set_elem()调用,在其下还调用nft_set_elem_init()。
1 | // >>> net/netfilter/nf_tables_api.c:5697 |
在nft_set_elem_init()中存在一处memcpy,来源为data,长度为set->dlen,但ext长度和分配和参数中的tmpl有关,tmpl在上层函数中根据desc进行修改。又因为在nft_setelem_parse_data()中我们提到,set->dlen和desc->len可以不同,前者最大可到64,而后者只为16定值,因此此处memcpy会造成堆越界写。
1 | // >>> net/netfilter/nf_tables_api.c:5364 |
根据数据的不同,分配的elem结构体可以位于kmalloc-64、kmalloc-128、kmalloc-192中。
漏洞利用
先观察一下这个发生堆越界写的对象:elem。
如下设置结构体可以在kmalloc-64中发生堆溢出:

如下设置结构体可以在kmalloc-128中发生堆溢出:

如下设置结构体可以在kmalloc-192中发生堆溢出:

这里可以选择kmalloc-64的排布,当然其他的也可以利用成功。
之后我们堆喷结构体user_key_payload,做linux内核利用的朋友应该很熟悉这个结构体了。
1 | struct user_key_payload { |
结构体中的datalen字段描述了后面data的长度。通过堆越界写修改它就能通过keyctlsyscall 来越界读取data中的数据。
我们先堆喷一些user_key_payload来简单的做一下堆风水。然后再喷一些user_key_payload,并隔空释放几个。之后触发nft_add_set_elem()中的kzalloc时大概率会得到如下的堆布局:

之后越界写就可以修改user_key_payload中的 datalen字段。我们可以使用keyctl遍历读取所有的key,并检查读取的长度。如果长度变成我们修改的长度,则说明修改成功,否则需要重复这一步。
成功后我们把其他所有的user_key_payload通过keyctl的KEYCTL_REVOKE删掉。需要注意的是,这里的删掉并不是直接调用kfree将堆块释放,而是通过向user_key_payload中的rcu写入func值,并让这个key对用户不可见,之后在垃圾回收中将其释放。man中是这样描述的:
1 | KEYCTL_REVOKE (since Linux 2.6.10) |
此时我们通过corrupted的user_key_payload做越界读,就能读到rcu.func中的user_free_payload_rcu这个函数指针,从而泄露出内核代码段地址。
稍微提一嘴,原作者博客中使用了另一个结构体作为泄露来源,即通过
io_uring来分配percpu_ref_data结构体。但我看了眼这个结构体是在Linux 5.10中加入的,这意味着受影响的5.8~5.9并不能使用这个方法来leak。
有了leak就是想办法通过越界写来提权了。这边我打算试试360在今年blackhat上提到的新利用思路USMA。
在raw_packet中存在这样一条路径:
1 | // >>> linux-5.13/net/packet/af_packet.c:3695 |
平时我们只是拿它来做方便的page level 风水,即4292行的功能。而USMA使用的是4287行分配的数组。
首先我们设置block_nr为5~8,这样pg_vec就能分配到kmalloc-64中。之后我们触发nft_add_set_elem()中的堆溢出就能覆盖到pg_vec中的虚拟地址。
之后通过packet_mmap就能将这些page映射到用户态进行读写。
其中在mmap时还存在如下的校验:
1 | // >>> mm/memory.c:1752 |
即检查page是否为匿名页,是否为Slab子系统分配的页,以及page是否含有type,而内存页的type总共有以下四种。
1 |
PG_buddy为伙伴系统中的页,PG_offline为内存交换出去的页,PG_table为用作页表的页,PG_guard为用作内存屏障的页。可以看到如果传入的page为内核代码段的页,以上的检查全都可以绕过。
例如我们可以将pg_vec中的页修改为__sys_setresuid()所在的页,从而直接patch它的代码让任意用户都可以直接提权到root。
1 | // >>> kernel/sys.c:652 |
稍微提一嘴,原作者博客中使用这篇文章中所描述的手法来实现任意地址写。简单来说是借助了
simple_xattr结构体中对list_head的unlink操作,从而修改modprobe_path。

https://github.com/veritas501/CVE-2022-34918