【CVE.0x0A】CVE-2021-3490 漏洞复现及简要分析
本文最后更新于:2023年8月13日 晚上
那一天,a3 终于回想起了被 VM Pwn 支配的恐怖
0x00. 一切开始之前
CVE-2021-3490 是一个发生在 eBPF verifier 中的漏洞,由于 eBPF verifier 在校验位运算操作( 与、或、异或 )时没有正确地更新寄存器的 32 位边界,从而导致攻击者可以构造出非法的运行时寄存器值以进行提权;该漏洞在 这个 commit 中被引入,在 这个 commit 中被修复
本文我们选择内核版本 5.11.16
进行分析
注:eBPF 相关基础知识可以看这↑里↓
0x01. 漏洞分析
eBPF 指令的合法性校验通过 eBPF verifier 完成,eBPF verifier 的核心函数便是 do_check()
,该函数会遍历每一条指令并根据指令的不同类型进行不同操作,对于算术指令(BPF_ALU
/ BPF_ALU64
)而言有如下调用链:
1 |
|
在 adjust_scalar_min_max_vals()
函数当中会对 32 位与 64 位都进行边界校验(因为实际参与运算的可能是 32 也可能是 64),计算边界值的逻辑主要是先调用 scalar32_min_max_xor()
计算 32 位边界值再调用 scalar_min_max_xor()
计算 64 位边界值:
1 |
|
在更新 32 位边界值时开发者认为如果两个寄存器的低 32 位都为 known
那就可以直接跳过,因为 64 位时还会进行更新:
tnum_subreg_is_const()
会看寄存器的var_off
的 mask 的低 32 位是否为 0(即全部已知)
1 |
|
在更新 64 位边界值时若两个寄存器都为 known
就直接调用 __mark_reg_known()
将寄存器标为 known
并直接返回:
1 |
|
__mark_reg_known()
其实就是简单的调用tnum_const()
设置寄存器var_off
为known
,并给对应边界赋值:
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
/* This helper doesn't clear reg->id */
static void ___mark_reg_known(struct bpf_reg_state *reg, u64 imm)
{
reg->var_off = tnum_const(imm);
reg->smin_value = (s64)imm;
reg->smax_value = (s64)imm;
reg->umin_value = imm;
reg->umax_value = imm;
reg->s32_min_value = (s32)imm;
reg->s32_max_value = (s32)imm;
reg->u32_min_value = (u32)imm;
reg->u32_max_value = (u32)imm;
}
/* 标记一个寄存器的未知部分 (变量偏移或标量值)
* 为已知的值 @imm.
*/
static void __mark_reg_known(struct bpf_reg_state *reg, u64 imm)
{
/* Clear id, off, and union(map_ptr, range) */
memset(((u8 *)reg) + sizeof(reg->type), 0,
offsetof(struct bpf_reg_state, var_off) - sizeof(reg->type));
___mark_reg_known(reg, imm);
}
但这样存在一个问题,若存在一个高 32 位 unknown 的寄存器,则不会调用 __mark_reg_known()
更新 32 位的边界值,而只会更新 64 位边界值:
1 |
|
这里笔者举一个非常简单的并且已经在其他各大师傅的漏洞分析的文章里用烂了的例子:
R2 = { .value = 0x1, .mask = 0xffffffff00000000 };
:该寄存器低 32 位值已知为 1,高 32 位不确定R3 = { .value = 0x100000002, .mask = 0x0 };
:该寄存器 64 位值全部已知,为0x100000002
假如我们将 R2 与 R3 做与运算,在刚进入 switch 时会先调用 tnum_and()
进行计算并将结构保存到 R2->var_off
,由于 R3 全部确定而 R2 的高 32 位不确定,因此运算结果为 { .value = 0x0, .mask = 0x100000000 }
,即仅有第 32 位是不确定的
接下来继续回到 scalar_min_max_and()
中,该函数最后会调用 __update_reg_bounds()
对比寄存器的 var_off
并更新边界值:
1 |
|
计算方法如下:
- 最小边界值 = 【
min_value
、var_off
已知值】中的最大者 - 最大边界值 =【
max_value
、var_off
已知值】中的最小者
由于 R2 的 32 位初始边界值未经过更新,仍为其原值 1
,因此经过该轮计算之后 R2 的最小值为 1,最大值为 0,而这显然是不合理的
回到 adjust_scalar_min_max_vals()
中,其最后也会调用 __update_reg_bounds()
对比寄存器的 var_off
并更新边界值:
1 |
|
__reg_deduce_bounds()
主要再做一次边界调整校验的工作,这里 32 位与 64 位都用的同一套逻辑:
- 若有符号最小值边界大于等于 0 或 有符号最大值边界小于 0 ,则更新有符号最小值边界为有符号与无符号最小值边界中的最大值,并更新有符号最大值边界为有符号与无符号最大值边界中的最小值,之后直接返回
- 若无符号最大值边界没有超过有符号范围(最高位不为1),则将有符号最小值设为无符号最小值,有符号最大值设为有符号与无符号最大值中的最小值
- 否则,若无符号最小值边界超过有符号范围(最高位为1),则将有符号最小值设为有符号与无符号最小值中的最大值,将有符号最大值设为无符号最大值
1 |
|
__reg_bound_offset()
则是基于边界值范围重新计算 var_off
的值:
tnum_range()
:取 min 中 min、max 的低位相同位部分,从第一个不同位开始设为未知tnum_intersect()
:取 a、b 的共有已知为 1 的位
1 |
|
这两个操作在这里都不会影响 R2 的值
poc
现在我们来构造能够触发该漏洞的两个寄存器 R2 = { .value = 1, mask = 0xffffffff00000000 }
与 R3 = { .value = 0x100000002, mask = 0 }
,其中 R3
可以直接通过赋值构造一个 known 的寄存器, R2
需要一半已知一半未知,可以通过 从 map 中取出一个值进行赋值 的方式先构造出一个 unknown 的寄存器,再与 0xffffffff00000000
做 AND 操作使其低 32 位变为 known:
1 |
|
把这个程序载入内核过一遍 verifier,简单打印下日志,可以看到我们确乎构造出了一个最小边界值为 1、最大边界值为 0 的寄存器:
0x02. 漏洞利用
接下来我们考虑如何利用这个漏洞完成提权,现在我们有了一个 32 位边界值为 [1,0]
、32位推测值与32位运行时值都为 0 的寄存器,接下来我们考虑如何构造一个verifier 推测值与运行时值不同的寄存器,从而继续完成后续利用
一、构造边界值为 [1, 0] 的寄存器
第一步还是先利用漏洞构造一个最小边界值为 1、最大边界值为 0 的寄存器,因为 R1~R5 有的时候要用来作为函数参数,所以这里我们改为在 R6
上继续构造
因为读取 map 的操作代码行数太长了(),所以笔者现在给他封装到一个 BPF_READ_ARRAY_MAP_IDX()
宏里:
1 |
|
二、构造运行时为 1、verifier 确信为 0 的寄存器
我们还是考虑继续在 32 位上做文章,假如我们构造出另一个 32 位边界值为 [0, 1]
、32位运行时值为 0
寄存器 R7
,将这个寄存器与我们的 R6
相加,其边界值计算其实就是检查是否有溢出然后简单的把两个寄存器边界相加:
1 |
|
此时我们的寄存器 R6
32位边界值为 [1, 1]
,之后 verifier 会调用 __reg_bound_offset()
反向赋值给 var_off
,此时我们的 var_off
的 32 位值便为 1
,但实际上的 32 位值为 0,我们便获得了一个运行时为 0 、verifier 认为是 1 的寄存器
这样一个寄存器好像对我们来说没有太多作用,但如果我们再给 R6
加上 1
,从而使得 32 位 var_off
变为 2
,但实际上的 32 位值为 1,我们再将 R6
与 1
做 &
运算,verifier 便会认为该寄存器的值变为 0,但其实际上的运行时值为 1
有了这样一个寄存器,后面我们就可以开始为所欲为了:)
对于 R7
的构造,我们可以先从 map 中取值获取一个 verifier 全不可知的寄存器,之后利用 32 位判断跳转指令 BPF_JMP32_IMM(BPF_JLE, BPF_REG_7, 1, 2)
使其变为 { .var_off = 0, .mask = 0xffffffff00000001}
即可,map 中的值是我们可控的所以我们可以使其运行时值为 0 :
注:你也可以先给 R6 += 1 再 R6 &= R7,效果是一样的
1 |
|
可能大家会想到对于条件跳转指令而言 verifier 主要根据边界值进行判断,或许我们能够构造一个运行时为真但 verifier 认为假的条件跳转语句(例如
BPF_JMP32_IMM(BPF_JGE, BPF_REG_6, 1, 1)
)并在 verifier 认为恒为假但运行时为真的分支中隐藏恶意指令:
1
2
3
4
5
6
7
8
9
10
11
12
static int is_branch32_taken(struct bpf_reg_state *reg, u32 val, u8 opcode)
{
struct tnum subreg = tnum_subreg(reg->var_off);
s32 sval = (s32)val;
switch (opcode) {
//...
case BPF_JGE:
if (reg->u32_min_value >= val)
return 1;
else if (reg->u32_max_value < val)
return 0;但这并不是一个可行的方案,因为对于不可达指令(dead code),verifier会将其 patch 为跳转回条件分支指令,从而导致我们无法在此处藏入恶意代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void sanitize_dead_code(struct bpf_verifier_env *env)
{
struct bpf_insn_aux_data *aux_data = env->insn_aux_data;
struct bpf_insn trap = BPF_JMP_IMM(BPF_JA, 0, 0, -1);
struct bpf_insn *insn = env->prog->insnsi;
const int insn_cnt = env->prog->len;
int i;
for (i = 0; i < insn_cnt; i++) {
if (aux_data[i].seen)
continue;
memcpy(insn + i, &trap, sizeof(trap));
}
}
三、内核地址泄露
接下来我们考虑如何泄露内核地址,比较容易想到的是我们或许可以通过这个运行时为 1 而 verifier 认为是 0 的寄存器构造一些越界读取,而 map 是我们能够直接接触到的指针之一,因此我们可以尝试从此处下手
我们是否可以直接向 BPF_FUNC_map_lookup_elem()
传入一个 verifier 确信为 0 但实际上是负数的寄存器呢?答案是否定的,因为对于 BPF_MAP_TYPE_ARRAY
类型的 map 而言在查找元素时实际上会调用到 array_map_lookup_elem()
,其 index 为无符号类型,因此我们无法前向读取:
1 |
|
但当我们在 eBPF 程序中调用 BPF_FUNC_map_lookup_elem()
时,其返回值为指向 value
的指针,而这个指针是允许与常量做运算的(类型为 PTR_TO_MAP_VALUE
),由于我们有一个 verifier 认为是 0 的寄存器,我们可以轻松绕过对指针范围的检查并完成越界读取……吗?
ALU Sanitation bypass
ALU Sanitation
是一个用于运行时动态检测的功能,通过对程序正在处理的实际值进行运行时检查以弥补 verifier 静态分析的不足,这项技术通过调用 fixup_bpf_calls()
为 eBPF 程序中的每一条指令的前面都添加上额外的辅助指令来实现
对于 BPF_ADD
及 BPF_SUB
这样的指令而言,会添加如下辅助指令:
1 |
|
其中 aux->alu_limit
为当前指针运算范围,初始时为 0,与指针所做的常量运算同步,对于减法而言可读范围为 (ptr - alu_limit, ptr]
(以指针最初指向的地址为 0
),因此我们还需要绕过这个检查
由于我们有运行时为 1、verifier 认为是 0 的寄存器,我们可以这样调整范围:
- 构造另外一个同样是运行时值为 1、verifier 认为是 0 的寄存器
R8
- 将
R8
乘上一个不大于 value size 的值(例如 value size 为0x1000
,R8
便设为0x1000
) - 将指向 map 第一个元素第一个字节
value[0]
的寄存器(假设为R7
)先加上0x1000
,此时alu_limit
变为0x1000
,R7
指向value[0x1000]
R7 -= R8
,由于 verifier 认为 R8 为 0,因此alu_limit
保持不变,但 R7 实际上已经指回了value[0]
由此我们便能继续愉快地进行前向的越界读了
注:在内核版本 5.11.8 之前 ALU Sanitation 存在一个漏洞,即
aux_alu_limit
被初始化为 0 从而导致0-1
造成整型溢出变为一个巨大的值,在这个 commit 中才被修复,因此对于 5.11.8 之前版本的内核而言是不需要绕过该检查的
OOB-read on bpf_array
现在让我们来看看这个存放数据的位置附近有没有什么有趣的数据,对于 BPF_MAP_TYPE_ARRAY
类型 的 map 而言,其 wrapper 为 bpf_array
类型(即 bpf_map
内嵌于该结构体中),数据则直接存放在其内部的 value
数组成员当中,因此在查找元素时我们获得的其实是一个指向 bpf_array
内部的指针:
1 |
|
因此我们只需要前向读取便能读取到 bpf_map
,之后通过 bpf_map
的函数表(bpf_map->ops
)便能泄露出内核地址,这里我们将 bpf_array_ops
的值读取到 map[1]
中:
1 |
|
成功泄露出内核地址:
笔者本来想直接写一个循环直接往前盲读
page_offset_base + 0x9d000
(通过物理地址 0 处数据定位),但是 verifier 要求不能有回向边 ,所以这里还是老老实实地看bpf_array
周围的数据:)
Leak map address
当我们在调用辅助函数 BPF_FUNC_map_lookup_elem()
时,该函数会返回一个指向 value
的指针,我们是否能够直接将这个值存放到 map 当中从而泄露出 map 地址?通常情况下答案是否定的,verifier 会检查寄存器的类型并阻止指针泄露的情况发生
现在让我们思考如何利用我们的漏洞寄存器绕过这个限制,注意到 verifier 在跟踪指针寄存器与常量寄存器间运算时会调用到 adjust_ptr_min_max_vals()
:
1 |
|
而在 adjust_ptr_min_max_vals()
当中有这样一个逻辑:如果源寄存器的边界存在 smin_val > smax_val || umin_val > umax_val
的情况,则直接将目的寄存器设为 unknown:
1 |
|
而 __mark_reg_unknown()
则会直接将寄存器设为标量值类型,这样的值可以直接存入 map 而不会被 verifier 限制:
1 |
|
由此我们便可以通过将指针寄存器与一个漏洞寄存器进行算术运算来绕过这个限制,从而泄露出 map 的地址,需要注意的是我们的漏洞寄存器的第 33 位是 unknown 的,我们需要将其进行截断以消去:
我们应当尽量减少截断时 verifier 对寄存器的跟踪,因此这里直接用
mov
,如果使用and 0xffffffff
这样的操作则没法消除掉 unknown 位,少 and 几位则会导致寄存器边界值和 var_off 重新更新
1 |
|
这里需要注意我们获得的地址是指向 bpf_array.value
的,需要自行计算偏移:
这里我们可以注意到
bpf_map
并不在 direct mapping area 上,应该是调用了 vmalloc,笔者推测可能是因为我们分配的 map 太大的缘故:)
四、任意地址读,泄露进程地址
接下来我们考虑如何完成任意地址读,由于我们能够读写 bpf_map
中的数据,故考虑从此处下手:)
BPF Type Format(BTF)是一种元数据格式,用于给 eBPF 提供一些额外的信息,在内核中使用 btf
结构体表示一条 btf 信息:
1 |
|
注意到在 bpf_map
当中刚好有一个指向 struct btf
的指针:
1 |
|
bpf_map->btf
在什么时候会被访问到?注意到 bpf
系统调用给我们提供的选项中有一个为 BPF_OBJ_GET_INFO_BY_FD
:
1 |
|
对于 map 类型而言最终会调用到 bpf_map_get_info_by_fd()
,在该函数中会把 bpf_map->btf.id 拷贝给用户空间:
1 |
|
我们不难想到的是我们可以通过控制 btf 指针的方式完成任意地址读,代码如下:
1 |
|
不过由于我们目前暂时不知道 page_offset_base
,因此暂时无法完成对所有物理内存搜索的工作,而只能读取内核镜像范围的内存
但是 init
进程的 PCB init_task
位于内核数据段上,init_task 的地址对我们来说是可知的,而所有进程在内核中的 PCB 构成一个双向链表,因此我们可以直接沿着这个双向链表搜索我们的进程控制块,判断是否搜索到的方法有很多,比如说对比 pid 一类的,这里笔者选择用 prctl(PR_SET_NAME, "arttnba3")
来设置 task_struct->comm
:
1 |
|
成功获得当前进程的 task_struct
地址:
五、任意地址写
我们同时有 map 的地址和内核基址,同时还能直接改写 map 内部的内容,不难想到的是我们可以直接在 map 上构造 fake map ops 后劫持 map 函数表从而劫持内核控制流
比较传统的方式就是直接栈迁移然后 ROP 执行 commit_cred(&init_cred)
,但笔者看到一个非常有意思的构造任意写的思路,所以这里也用这种解法(笑)
注意到 array map 的 map_get_next_key()
定义如下,当 key
小于 map.max_entries
时 key
会被写入到 next_key
当中:
1 |
|
当然对于常规的调用 map_get_next_key()
的流程而言虽然 key
的内容是可控的但是 next_key
指针不是我们所能控制的:
1 |
|
但是在 map ops 当中有一些函数可以让我们控制这两个参数,我们可以将这样的函数指针替换为 map_get_next_key()
从而完成任意地址写,例如 map_push_elem()
:
1 |
|
当我们更新 eBPF map 时,若 map 类型为 BPF_MAP_TYPE_QUEUE
或 BPF_MAP_TYPE_STACK
,则这个函数会被调用:
1 |
|
不过在我们调用 bpf_map_update_value()
时还有一个检查,若 flags 设置了 BPF_F_LOCK
标志位,则会检查 map->spin_lock_off
是否大于等于 0,若非则会直接报错返回,因此这里我们还要将该字段改为一个正整数:
1 |
|
最后我们的任意写方案如下:我们可以在 bpf_array.value
上构造一个 fake ops 将 ops->map_push_elem
替换为 array_map_get_next_key()
,之后替换掉 map 的函数表,并更改 map.max_entries
为 0xffffffff
、更改 map 类型为 BPF_MAP_TYPE_STACK
、更改 map.spin_lock_off
为正数来实现任意地址写,需要注意的是单次只能写 4 字节:
1 |
|
Final Exploit
最后的 exp 如下,因为在 array_map_get_next_key()
中会检查 index != max_entries - 1
,而 init_cred
的高 32 位必定是 0xFFFFFFFF
,因此这里笔者选择直接改写当前进程的 task_struct.cred
的 uid 与 gid 相关字段:
注:这里笔者将常用函数 & 指令封装在了 bpf_tools.h 中
1 |
|
运行即可完成提权:
第一次真正从头开始做 eBPF 相关的利用,说实话还是挺有意思的,不过虚拟机的各种实现细节确实比想象中要庞大得多(
那一天,a3 终于回忆起本科阶段做用户态 vm pwn 逆向半天逆到头大的痛苦)
Extra. New ALU Sanitation bypass
在 这个 commit 中 ALU Sanitation 又得到了进一步的加强:
alu_limit
的计算方式发生了改变,不是使用指针寄存器的当前位置,而是使用一个offset
寄存器- 被认为是常数的寄存器赋值会被直接更改为常量赋值
这两个新特性的引入使得本文所用的攻击方法近乎完全失效,不过这并不代表我们不能完成利用,在 D^3CTF2022-d3bpf-v2 中来自 vidar-team 的 chuj 师傅展示了一个新的技巧——由于 bpf_skb_load_bytes()
会将一个 sk_buff
的数据读到栈上,因此我们可以利用运行时为 1、verifier 确信为 0 的寄存器构造一个较长的 len
参数,从而使得数据拷贝时发生栈溢出
我们或许还需要额外的办法泄露内核地址,一个可行的方式是直接造成 kernel oops 后通过 dmesg 泄露出内核信息,这个技巧对于总会设置 oops=panic
的 CTF 题并不可用,但是大部分的真实世界环境其实都不会在 soft panic 发生时直接 panic (/proc/sys/kernel/panic_on_oops == 0
),因此这个方法的可行性其实还是挺高的
0x03. 漏洞修复
在 这个 commit 中完成了对漏洞的修补操作,漏洞的修复方式也比较简单,只需要将缺失的设置 32 位边界的操作补充上就行:
1 |
|
笔者认为这个修补方式还是比较成功的