$ cat /posts/cve-2021-4204-linux-kernel-ebpf-lpe.txt |=------=[ CVE-2021-4204 / tr3e ]=------=| --[ 1 - TL;DR 近期在对Linux eBPF进行代码审计的过程中,发现了一枚权限提升漏洞CVE-2021-4204。 此漏洞影响Linux Kernel 5.8 - 5.16。 完整利用代码详见:https://github.com/tr3ee/CVE-2021-4204 ----[ 1.1 - eBPF verifier 为了保证内核的安全性,所有加载进内核的eBPF程序都会经过eBPF verifier的合法性检查。 自然,这些检查中包含对辅助函数的合法性检查,毕竟eBPF区别于BPF的一大特性就是辅助函数,这为eBPF提供 了丰富的内核函数调用能力。 eBPF verifier要求辅助函数提供`struct bpf_func_proto`结构的调用约定,并根据约定对函数调用者进行合 法性检查。 * C * ------------------------------------------------------------------------------------------------ const struct bpf_func_proto bpf_map_lookup_elem_proto = { .func = bpf_map_lookup_elem, .gpl_only = false, .pkt_access = true, .ret_type = RET_PTR_TO_MAP_VALUE_OR_NULL, .arg1_type = ARG_CONST_MAP_PTR, .arg2_type = ARG_PTR_TO_MAP_KEY, }; ------------------------------------------------------------------------------------------------ 比如上面这个例子中,要求所有调用bpf_map_lookup_elem的程序遵循: - 参数1: 一个map指针 - 参数2: 一个指向key的指针 - 返回值: 有可能是一个指向value的指针,也可能是NULL 所以当你尝试这样调用`bpf_map_lookup_elem(NULL, NULL)`的时候,eBPF verifer会失败并且告诉你期望的参 数是一个map_ptr而不是scalar。 当然这个例子是最简单的一个辅助函数,毕竟它要求我们提供指向key的指针,却不需要提供key的长度,因为 这个长度在我们定义map的时候就已经给定了。 * C * ------------------------------------------------------------------------------------------------ const struct bpf_func_proto bpf_strtol_proto = { .func = bpf_strtol, .gpl_only = false, .ret_type = RET_INTEGER, .arg1_type = ARG_PTR_TO_MEM, .arg2_type = ARG_CONST_SIZE, .arg3_type = ARG_ANYTHING, .arg4_type = ARG_PTR_TO_LONG, }; ------------------------------------------------------------------------------------------------ 在这个例子中,bpf_strtol用于把字符串转换为long类型,它要求调用bpf_strtol的程序遵守: - 参数1: 一个指向字符串的指针 - 参数2: 这个字符串的大小 - 参数3: 非指针的任意值 - 参数4: 一个指向long类型的指针 - 返回值: 整数 这就比前面的复杂一些了,因为它的输入是一个不定长度的字符串,所以必须要求调用者提供字符串的长度。 eBPF verifier则会基于这个约定,在检查阶段对这个输入校验,避免调用者提供无效的参数来造成内存的越界 读写。 比如`bpf_strtol("hello", 5, 0, &res)`这样的调用,eBPF verifier是认为合法的。但如果我们的调用是`b pf_strtol("hello", 100, 0, &res)`,很明显"hello"的长度并没有100,因此程序会被拒绝加载。 通常,辅助函数的参数中包含动态大小的内存块时,调用约定都会后跟一个整数用来表示内存块的大小,就像 上面的bpf_strtol_proto一样。 * C * ------------------------------------------------------------------------------------------------ static int check_func_arg(struct bpf_verifier_env *env, u32 arg, struct bpf_call_arg_meta *meta, const struct bpf_func_proto *fn) { ... } else if (arg_type_is_mem_ptr(arg_type)) { /* The access to this pointer is only checked when we hit the * next is_mem_size argument below. */ meta->raw_mode = (arg_type == ARG_PTR_TO_UNINIT_MEM); } else if (arg_type_is_mem_size(arg_type)) { ... err = check_helper_mem_access(env, regno - 1, reg->umax_value, zero_size_allowed, meta); ... } ... return err; } ------------------------------------------------------------------------------------------------ 上面这段代码中check_func_arg()就是eBPF verifier对调用约定进行检查的具体实现。 可以看到如果参数X是mem_ptr,并且参数X+1是mem_size时,就会调用check_helper_mem_access对参数X的内存 进行校验。 但是,是否存在某个调用约定没有遵守这个规则呢? ----[ 1.2 - 漏洞分析 是的,它存在!BPF ringbuf没有遵守这一规则,这也就是CVE-2021-4204。 BPF ringbuf是一个内置的高性能环形缓冲区,用于弥补perf buffer不能满足的场景,相关的文档可以看这个 https://www.kernel.org/doc/html/latest/bpf/ringbuf.html * C * ------------------------------------------------------------------------------------------------ BPF_CALL_2(bpf_ringbuf_submit, void *, sample, u64, flags) { bpf_ringbuf_commit(sample, flags, false /* discard */); return 0; } const struct bpf_func_proto bpf_ringbuf_submit_proto = { .func = bpf_ringbuf_submit, .ret_type = RET_VOID, .arg1_type = ARG_PTR_TO_ALLOC_MEM, .arg2_type = ARG_ANYTHING, }; BPF_CALL_2(bpf_ringbuf_discard, void *, sample, u64, flags) { bpf_ringbuf_commit(sample, flags, true /* discard */); return 0; } const struct bpf_func_proto bpf_ringbuf_discard_proto = { .func = bpf_ringbuf_discard, .ret_type = RET_VOID, .arg1_type = ARG_PTR_TO_ALLOC_MEM, .arg2_type = ARG_ANYTHING, }; ------------------------------------------------------------------------------------------------ 上面这段ringbuf.c的代码中,提供了两个辅助函数bpf_ringbuf_submit()和bpf_ringbuf_discard(),他们的 首个参数都是mem_ptr并且后续却没有跟着mem_size类型的参数。 BPF ringbuf的设计初衷是实现高吞吐的环形缓冲区,正常情况下它在内核里的调用流程是: [1] 调用bpf_ringbuf_reserve()获得一块缓冲区内存P [2] 对缓冲区内存P进行写操作 [3] 调用bpf_ringbuf_submit()提交数据 或者 调用bpf_ringbuf_discard()丢弃数据 这个过程称为生产数据,而通常用户态会存在一个程序不断读取它生产的数据,这个过程称为消费数据。 bpf_ringbuf_{submit, discard}本意是用于提交/丢弃我们生产的数据,但却没有对传入的指针参数做校验, 这允许我们调用bpf_ringbuf_{submit, discard}时传入越界的指针,利用其内部逻辑来实现污染,从而在内核 中执行代码、提升权限。 ----[ 1.3 - 漏洞利用 函数bpf_ringbuf_{submit, discard}里最终都是调用的bpf_ringbuf_commit(),它的实现如下: * C * ------------------------------------------------------------------------------------------------ static void bpf_ringbuf_commit(void *sample, u64 flags, bool discard) { unsigned long rec_pos, cons_pos; struct bpf_ringbuf_hdr *hdr; struct bpf_ringbuf *rb; u32 new_len; hdr = sample - BPF_RINGBUF_HDR_SZ; rb = bpf_ringbuf_restore_from_rec(hdr); new_len = hdr->len ^ BPF_RINGBUF_BUSY_BIT; if (discard) new_len |= BPF_RINGBUF_DISCARD_BIT; /* update record header with correct final size prefix */ xchg(&hdr->len, new_len); /* if consumer caught up and is waiting for our record, notify about * new data availability */ rec_pos = (void *)hdr - (void *)rb->data; cons_pos = smp_load_acquire(&rb->consumer_pos) & rb->mask; if (flags & BPF_RB_FORCE_WAKEUP) irq_work_queue(&rb->work); else if (cons_pos == rec_pos && !(flags & BPF_RB_NO_WAKEUP)) irq_work_queue(&rb->work); } ------------------------------------------------------------------------------------------------ bpf_ringbuf_commit()会做如下操作: [1] *(sample - BPF_RINGBUF_HDR_SZ) ^= BPF_RINGBUF_BUSY_BIT [2] 如果discard为真,*(sample - BPF_RINGBUF_HDR_SZ) |= BPF_RINGBUF_DISCARD_BIT [3] 满足条件时调用irq_work_queue(&rb->work) 那么在ringbuf内部是怎么实现的呢?这里解释一下,bpf_ringbuf的内部实现可以看下面这段结构体定义: * C * ------------------------------------------------------------------------------------------------ struct bpf_ringbuf { wait_queue_head_t waitq; struct irq_work work; u64 mask; struct page **pages; int nr_pages; spinlock_t spinlock ____cacheline_aligned_in_smp; /* Consumer and producer counters are put into separate pages to allow * mapping consumer page as r/w, but restrict producer page to r/o. * This protects producer position from being modified by user-space * application and ruining in-kernel position tracking. */ unsigned long consumer_pos __aligned(PAGE_SIZE); unsigned long producer_pos __aligned(PAGE_SIZE); char data[] __aligned(PAGE_SIZE); }; ------------------------------------------------------------------------------------------------ bpf_ringbuf中的data字段就是我们的环形缓冲区,而mask字段则是用来标记环形缓冲区的掩码。 另外,consumer_pos用于跟踪消费者的消费进度,producer_pos用于跟踪生产者的生产进度。 然而由于bpf_ringbuf_{submit, discard}并没有对输入的指针做校验,我们可以把原本应该指向data的指针经 过算术运算指向mask字段,通过改变mask字段变相的给我们提供了越界读写的能力。 但是,由于我们依旧只能通过bpf_ringbuf_reserve()来获得data中的内存指针,所以还没法直接向前控制bpf _ringbuf的字段。不过,好在我们可以通过创建大量的bpf_ringbuf利用堆喷射的方式,来向后越界读写下一个 bpf_ringbuf,这使得我们可以完全控制后头的bpf_ringbuf结构体。 在我们完全控制了bpf_ringbuf之后,让我们来看看bpf_ringbuf_reserve是怎么工作的? * C * ------------------------------------------------------------------------------------------------ static void *__bpf_ringbuf_reserve(struct bpf_ringbuf *rb, u64 size) { unsigned long cons_pos, prod_pos, new_prod_pos, flags; u32 len, pg_off; struct bpf_ringbuf_hdr *hdr; if (unlikely(size > RINGBUF_MAX_RECORD_SZ)) return NULL; len = round_up(size + BPF_RINGBUF_HDR_SZ, 8); if (len > rb->mask + 1) return NULL; cons_pos = smp_load_acquire(&rb->consumer_pos); if (in_nmi()) { if (!spin_trylock_irqsave(&rb->spinlock, flags)) return NULL; } else { spin_lock_irqsave(&rb->spinlock, flags); } prod_pos = rb->producer_pos; new_prod_pos = prod_pos + len; /* check for out of ringbuf space by ensuring producer position * doesn't advance more than (ringbuf_size - 1) ahead */ if (new_prod_pos - cons_pos > rb->mask) { spin_unlock_irqrestore(&rb->spinlock, flags); return NULL; } hdr = (void *)rb->data + (prod_pos & rb->mask); pg_off = bpf_ringbuf_rec_pg_off(rb, hdr); hdr->len = size | BPF_RINGBUF_BUSY_BIT; hdr->pg_off = pg_off; /* pairs with consumer's smp_load_acquire() */ smp_store_release(&rb->producer_pos, new_prod_pos); spin_unlock_irqrestore(&rb->spinlock, flags); return (void *)hdr + BPF_RINGBUF_HDR_SZ; } BPF_CALL_3(bpf_ringbuf_reserve, struct bpf_map *, map, u64, size, u64, flags) { struct bpf_ringbuf_map *rb_map; if (unlikely(flags)) return 0; rb_map = container_of(map, struct bpf_ringbuf_map, map); return (unsigned long)__bpf_ringbuf_reserve(rb_map->rb, size); } ------------------------------------------------------------------------------------------------ 可以看到,这里获取内存块的主要操作是`rb->data + (prod_pos & rb->mask)`,而prod_pos和mask都被我们 所控制。当mask=0xffffffffffffffff时,这个操作等价于通过prod_pos实现任意地址的分配,从而获得任意地 址读写。 可惜的是,在完成分配后它还向分配后的地址做了两次写操作`hdr->len = size | BPF_RINGBUF_BUSY_BIT`、 `hdr->pg_off = pg_off`,这使得我们的任意地址分配能力大大缩水,并且存在数据污染问题,这只能算是受 限的地址读写。 同时,尽管我们已经获得了内核地址读写,却没有相关的地址泄漏,还需要必要的内核地址才能完成接下来的 步骤。 因此我们转向bpf_ringbuf结构,看看能不能获得一些有用的地址信息。 * GDB * ------------------------------------------------------------------------------------------------ gef> x/32gx $rdi 0xffffc90000517000: 0x0000000000000000 0xffffc90000517008 0xffffc90000517010: 0xffffc90000517008 0x0000000000000000 0xffffc90000517020: 0x0000000000000000 0xffffffff811361e0 0xffffc90000517030: 0x0000000080000fff 0xffff888003cd8680 0xffffc90000517040: 0x0000000000000004 0x0000000000000000 0xffffc90000517050: 0x0000000000000000 0x0000000000000000 0xffffc90000517060: 0x0000000000000000 0x0000000000000000 0xffffc90000517070: 0x0000000000000000 0x0000000000000000 0xffffc90000517080: 0x0000000000000000 0x0000000000000000 0xffffc90000517090: 0x0000000000000000 0x0000000000000000 0xffffc900005170a0: 0x0000000000000000 0x0000000000000000 0xffffc900005170b0: 0x0000000000000000 0x0000000000000000 0xffffc900005170c0: 0x0000000000000000 0x0000000000000000 0xffffc900005170d0: 0x0000000000000000 0x0000000000000000 0xffffc900005170e0: 0x0000000000000000 0x0000000000000000 0xffffc900005170f0: 0x0000000000000000 0x0000000000000000 gef> p *(struct bpf_ringbuf*) $rdi $4 = { waitq = { lock = { { rlock = { raw_lock = { { val = { counter = 0x0 }, { locked = 0x0, pending = 0x0 }, { locked_pending = 0x0, tail = 0x0 } } } } } }, head = { next = 0xffffc90000517008, prev = 0xffffc90000517008 } }, work = { { node = { llist = { next = 0x0 }, { u_flags = 0x0, a_flags = { counter = 0x0 } }, src = 0x0, dst = 0x0 }, { llnode = { next = 0x0 }, flags = { counter = 0x0 } } }, func = 0xffffffff811361e0 }, mask = 0x80000fff, pages = 0xffff888003cd8680, nr_pages = 0x4, spinlock = { { rlock = { raw_lock = { { val = { counter = 0x0 }, { locked = 0x0, pending = 0x0 }, { locked_pending = 0x0, tail = 0x0 } } } } } }, consumer_pos = 0x0, producer_pos = 0xff8, data = 0xffffc9000051a000 "\360\017" } ------------------------------------------------------------------------------------------------ 上面是一个典型的bpf_ringbuf在内存中的结构。因为`bpf_ringbuf->data`是通过vmap将一组碎片内存映射为 连续的虚拟内存,因此data位于`vmalloc space`里。而不论是进程的task_struct还是cred都分配在了`direc t mapping space`,这意味着即使拿到了bpf_ringbuf的地址,也不能够提权,因为我们没有`direct mapping space`的地址。 但是bpf_ringbuf里有一个字段很特殊——`bpf_ringbuf->pages`,它保留着一组碎片内存数组,也就是data的真 实地址,且这个数组的地址就位于`direct mapping space`,于是我们所需要的泄漏有了,但任意地址分配中 的数据污染问题还是没能解决。不过刚刚不是说过`bpf_ringbuf->pages`是个数组吗?我们来看看它在内存里 的布局。 * GDB * ------------------------------------------------------------------------------------------------ gef> x/6gx ((struct bpf_ringbuf*)$rdi)->pages 0xffff888003cd8680: 0xffffea00000f3ac0 0xffffea00000f3b00 0xffff888003cd8690: 0xffffea00000f3b40 0xffffea00000f3b80 0xffff888003cd86a0: 0xffffea00000f3b80 0x0000000000000000 0xffff888003cd86b0: 0x0000000000000000 0x0000000000000000 ------------------------------------------------------------------------------------------------ 果然,数组的第六个元素是0x0000000000000000,那么我们可以通过上述的任意地址分配,把环形缓冲区分配 在`pages+0x28`的位置,那么我们就可以读写`pages+0x30`之后的内存。那么再通过堆喷射task_struct,我们 就能成功改写进程的cred。 漏洞利用这块写的比较随便,之后有机会再补充吧。 完整利用代码详见:https://github.com/tr3ee/CVE-2021-4204 ----[ 1.4 - 参考资料 [1] Advisory: https://www.openwall.com/lists/oss-security/2022/01/11/4 [2] Exploit Overview: https://www.openwall.com/lists/oss-security/2022/01/18/1 [3] Patch: [bpf: Fix out of bounds access for ringbuf helpers](https://git.kernel.org/pub/scm/li nux/kernel/git/torvalds/linux.git/commit/?id=64620e0a1e712a778095bd35cbb277dc2259281f) [4] Source: https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/kernel/bpf/ver ifier.c?h=v5.10.83 [5] CVE-2020-8835: LINUX KERNEL PRIVILEGE ESCALATION VIA IMPROPER EBPF PROGRAM VERIFICATION: htt ps://www.zerodayinitiative.com/blog/2020/4/8/cve-2020-8835-linux-kernel-privilege-escalation-via -improper-ebpf-program-verification