CVE-2022-23222 漏洞分析
author: moxingyuan from iceswordlab
一、漏洞背景
CVE-2022-23222 是一个 Linux 内核漏洞,其成因为 eBPF verifier 未阻止某些 *OR_NULL 类型指针的算数加减运算。利用该漏洞可导致权限提升。
受该漏洞影响的内核版本范围为 5.8 - 5.16 。
该漏洞分别在内核版本 5.10.92、5.15.15、5.16.1 中被修复,其中,5.10.92 版本修复该漏洞的 commit 为 [35ab8c9085b0af847df7fac9571ccd26d9f0f513](kernel/git/stable/linux.git - Linux kernel stable tree) 。
二、漏洞成因
漏洞形成于 kernel/bpf/verifier.c 的 adjust_ptr_min_max_vals 函数:
1 | static int adjust_ptr_min_max_vals(struct bpf_verifier_env *env, |
在禁止特定指针类型的算数加减运算时,没有列举完所有的 *OR_NULL 类型指针,导致部分 *OR_NULL 类型指针可以进行非法运算。
所有的 *OR_NULL 类型指针可以在枚举类型 bpf_reg_type 中找到。
1 | enum bpf_reg_type { |
可发现漏掉的指针类型包括:
- PTR_TO_BTF_ID_OR_NULL
- PTR_TO_MEM_OR_NULL
- PTR_TO_RDONLY_BUF_OR_NULL
- PTR_TO_RDWR_BUF_OR_NULL
三、漏洞相关知识
eBPF (Extended Berkeley Packet Filter) 由 cBPF (Classic Berkeley Packet Filter) 衍生而来,是一项可在内核虚拟机中运行程序的技术。使用eBPF无需修改内核源码,或者插入驱动,对系统的入侵性相对没那么强,可以安全并有效地扩展内核的功能。
3.1 eBPF指令
eBPF 使用类似 x86 的虚拟机指令,基础指令为 8 字节,其编码格式为:
32 bits (MSB) | 16 bits | 4 bits | 4 bits | 8 bits (LSB) |
---|---|---|---|---|
immediate | offset | source register | destination register | opcode |
扩展指令在基础指令基础上增加 8 个字节的立即数,总长度为 16 字节。
伪指令是内核代码中定义的方便理解记忆的助记符,通常是对真实指令的包装。
下文中出现的指令/伪指令及其功能如下:
指令/伪指令 | 功能 |
---|---|
BPF_MOV64_REG(DST, SRC) | dst = src |
BPF_MOV64_IMM(DST, IMM) | dst_reg = imm32 |
BPF_ST_MEM(SIZE, DST, OFF, IMM) | *(uint *) (dst_reg + off16) = imm32 |
BPF_STX_MEM(SIZE, DST, SRC, OFF) | *(uint *) (dst_reg + off16) = src_reg |
BPF_LDX_MEM(SIZE, DST, SRC, OFF) | dst_reg = *(uint *) (src_reg + off16) |
BPF_ALU64_IMM(OP, DST, IMM) | dst_reg = dst_reg ‘op’ imm32 |
BPF_JMP_IMM(OP, DST, IMM, OFF) | if (dst_reg ‘op’ imm32) goto pc + off16 |
BPF_LD_MAP_FD(DST, MAP_FD) | dst = map_fd |
BPF_EXIT_INSN() | exit |
3.2 eBPF寄存器
eBPF 共有 11 个寄存器,其中 R10 是只读的帧指针,剩余 10 个是通用寄存器。
- R0: 保存函数返回值,及 eBPF 程序退出值
- R1 - R5: 传递函数参数,调用函数保存
- R6 - R9: 被调用函数保存
- R10: 只读的帧指针
3.3 eBPF程序类型
所有 eBPF 程序类型定义在以下枚举类型:
1 | enum bpf_prog_type { |
下文涉及到的类型只有 BPF_PROG_TYPE_SOCKET_FILTER 。该类型 eBPF 程序通过 setsockopt 附加到指定 socket 上面,对 socket 的流量进行追踪、过滤,可附加的 socket 类型包括 UNIX socket 。
该类型程序的传入参数为结构体 __sk_buff 指针,可通过调用 bpf_skb_load_bytes_relative 辅助函数经由该结构体获取 socket 流量。
3.4 eBPF map
eBPF map 是 eBPF 程序和用户态进行数据交换的媒介。其类型包括:
1 | enum bpf_map_type { |
下文使用到的类型包括 BPF_MAP_TYPE_ARRAY 和 BPF_MAP_TYPE_RINGBUF 。
顾名思义,BPF_MAP_TYPE_ARRAY 类似数组,索引为整形,值可为任意长度的内存对象。
BPF_MAP_TYPE_RINGBUF 是环形缓冲区,如果写入的数据来不及读取,导致积累的数据超过缓冲区长度,新数据则会覆盖掉旧数据。
3.5 eBPF辅助函数
eBPF 辅助函数(eBPF helper)是可在 eBPF 程序中使用的辅助函数。
内核规定了不同类型的eBPF程序可使用哪些辅助函数,比如,bpf_skb_load_bytes_relative 只有 socket 相关的 eBPF 程序可使用。
各 eBPF 辅助函数的函数原型由内核定义,下文使用到的一些辅助函数的原型如下:
1 | const struct bpf_func_proto bpf_map_lookup_elem_proto = { |
可见 bpf_map_lookup_elem 的返回值类型是 RET_PTR_TO_MAP_VALUE_OR_NULL ,bpf_ringbuf_reserve 的返回值类型是RET_PTR_TO_ALLOC_MEM_OR_NULL 。
各 eBPF 辅助函数的功能可通过 man bpf-helpers 命令查看。
3.6 eBPF verifier
eBPF 程序在加载进内核之前,必须通过 eBPF verifier 的检查。只有符合要求的 eBPF 程序才允许被加载进内核,这是为了防止 eBPF 程序对内核进行破坏。
eBPF verifier 对 eBPF 程序的限制包括:
- 不能调用任意的内核函数,只限于内核模块中列出的 eBPF helper 函数
- 不允许包含无法到达的指令,防止加载无效代码,延迟程序的终止。
- 限制循环次数,必须在有限次内结束。
- 栈大小被限制为 MAX_BPF_STACK,截止到内核 5.10.83 版本,被设置为 512。
- 限制 eBPF 程序的复杂度,verifier 处理的指令数不得超过 BPF_COMPLEXITY_LIMIT_INSNS,截止到内核 5.10.83 版本,被设置为100万。
- 限制 eBPF 程序对内存的访问,比如不得访问未初始化的栈,不得越界访问 eBPF map 。
四、POC分析
POC 地址为:https://github.com/tr3ee/CVE-2022-23222
漏洞整体利用思路是通过欺骗 eBPF verifier 泄露内核地址,并实现内核任意地址读、写原语,通过任意读原语搜索进程 cred 所在地址,通过任意写原语修改进程 cred 以实现提权。
4.1 前置准备
创建 2 个 eBPF map ,类型分别为 BPF_MAP_TYPE_ARRAY 及 BPF_MAP_TYPE_RINGBUF。
1 | ret = bpf_create_map(BPF_MAP_TYPE_ARRAY, sizeof(u32), PAGE_SIZE, 1); |
前者在 POC 中的作用为:
- 和内核交换数据。
- 泄露其元素的地址。
后者的作用则为:
- 和内核交换数据。
- 通过 bpf_ringbuf_reserve 辅助函数获取 PTR_TO_MEM_OR_NULL 类型指针 。
4.2 泄露内核地址
泄露内核地址的方法为构造特定的 eBFP 程序以利用前述漏洞。
先将 r1 保存到 r9 。r1 在进入 eBPF 程序之前被内核初始化为指向 skb 的指针。
1 | // r9 = r1 |
获取 array 指针,保存在 r0 。调试发现,array 指针都是 0xFFFF…10 这种格式。
1 | // r0 = bpf_lookup_elem(ctx->comm_fd, 0) |
上一步获取的 r0 类型为 PTR_TO_MAP_VALUE_OR_NULL 。进行以下判断后,在 false 分支 r0 类型就变成 PTR_TO_MAP_VALUE。
1 | // if (r0 == NULL) exit(1) |
将 array 指针保存进 r8。
1 | // r8 = r0 |
调用 bpf_ringbuf_reserve 函数,请求 PAGE_SIZE 的 ringbuf 内存,返回值为 PTR_TO_MEM_OR_NULL 类型指针,属于漏洞中没有过滤的指针类型。
1 | // r0 = bpf_ringbuf_reserve(ctx->ringbuf_fd, PAGE_SIZE, 0) |
复制 r0 到 r1 ,r1 的类型变为 PTR_TO_MEM_OR_NULL ,id 也变成 r0 的 id 。这里提一下,verifier 会维护 eBPF 寄存器的 id 属性,用于追踪指针类型的来源。
1 | // r0 = r1 |
之后,r1 自身加 1。
1 | // r1 = r1 + 1 |
参考 adjust_ptr_min_max_vals 函数的代码,在指针加减操作中,目标寄存器的 id 和类型会变成指针寄存器的 id 和类型。由于在上一步中 r1 既是目标寄存器也是指针寄存器,其 id 和类型保持不变。
1 | static int adjust_ptr_min_max_vals(struct bpf_verifier_env *env, |
检查 r0 是否为 NULL 。事实上,r0 不为 NULL 的情况不可能发生。ringbuf 的大小虽然为 PAGE_SIZE ,但其中一部分用于存储关于 ringbuf 的结构体,剩下的才用于存储数据。因此,请求保留 PAGE_SIZE 的内存不可能实现。经过此步骤后,r0 的类型变为 SCALAR_VALUE ,其值为 0 。那么,与 r0 具有相同 id 的 r1 的类型和值又会如何变化呢?
1 | // if (r0 != NULL) { ringbuf_discard(r0, 1); exit(2); } |
check_cond_jmp_op 是 verifier 中检查 JMP 指令的函数,当 JMP 指令的条件是 *OR_NULL 类型指针和 0 比较时,会通过 mark_ptr_or_null_regs 函数改变不同分支中寄存器的类型。
1 | static int check_cond_jmp_op(struct bpf_verifier_env *env, |
mark_ptr_or_null_regs 函数又调用了 __mark_ptr_or_null_regs 函数,在后者中,所有相同 id 的寄存器都会被 mark_ptr_or_null_reg 函数进行相同的处理。因此,后续 r1 也会变成 SCALAR_VALUE 类型,且 verifier 认为其值为 0 。然而,事实上 r1 的值为 1 。这就是漏洞所在,PTR_TO_MEM_OR_NULL 类型的指针无论经过加减运算变成何值,只要经过是否为 NULL 的判断,在其中一个分支 verifier 都会认为其值为 0 。
1 | static void __mark_ptr_or_null_regs(struct bpf_func_state *state, u32 id, |
接着,将 r1+8 保存到 r7 。verifier 认为 r7 值为 8 ,实际上 r7 值为 9 。再将 array 指针 r8 加上 0xE0 的值保存到 r10-8 处,之所以加上 0xE0 是为了泄露更多数据,后面会补充说明。
通过 bpf_skb_load_bytes_relative 向 r10-16 写入 r7 个字节,即 9 个字节,溢出了 1 个字节。所写入的数据是可控的,可在用户态通过写入 socket 传递进内核态。在这里将控制写入数据为全零数据,即 r10-8 处的字节会被 0x00 覆盖。
1 | // r7 = r1 + 8 |
将栈上的 array 指针取出,并减去 0xE0 ,与前面对应,结果保存进 r6 。一加一减,verifier会认为 r6 仍为 array 指针,即等于 0xFFFF…10 。而实际上,r6 等于 0xFFFF…10 - 0xE0 。这里可以选择加减 0x10 ~ 0xE0 ,选择 0xE0 泄露的数据较多。接着,将 r6 所指向的 PAGE_SIZE 字节数据复制到 array 指针处,实现信息泄露。调试发现,泄露的数据中就包含 array 指针,在 0xFFFF…10 - 0x50 处。
1 | // r6 = *(u64 *)(r10 - 8) - 0xE0 |
构造好程序后,就可将其加载进内核,attach 到 socket 上,向 socket 写入全零数据以覆盖栈上的 array 指针,再从 array map 中获取泄露的数据,从中找出 array 指针。
1 | int prog = bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, sizeof(insn) / sizeof(insn[0]), ""); |
4.3 构造任意读、写原语
接下来构造的 eBPF 程序和上一程序及其类似,因此通过添加注释的方式进行说明。
实现任意读原语的 eBPF 程序:
1 | struct bpf_insn arbitrary_read[] = { |
实现任意写原语的 eBPF 程序:
1 | struct bpf_insn arbitrary_write[] = { |
4.4 定位进程cred
调试发现,进程的 cred 有一定概率在泄露的 array 指针之后。因此需要多创建几个进程,避免利用失败。
所有进程通过 prctl(PR_SET_NAME, __ID__, 0, 0, 0) 将进程名称设置为固定字符串,在此使用 SCSLSCSL 。
1 | int spawn_processes(context_t *ctx) |
之后,各进程依次尝试通过任意读原语,在 array 指针之后 PAGE_SIZE * PAGE_SIZE 大小的内核空间搜索 SCSLSCSL 字符串,来定位进程的 cred 。
1 | int find_cred(context_t *ctx) |
4.5 实现提权
定位到进程 cred 后,即可通过任意写原语修改 cred ,实现提权。
1 | int overwrite_cred(context_t *ctx) |
参考
cve-2022-23222-linux-kernel-ebpf-lpe.txt
The Good, Bad and Compromisable Aspects of Linux eBPF - Pentera
eBPF - Introduction, Tutorials & Community Resources
eBPF Instruction Set — The Linux Kernel documentation
BPF 进阶笔记(一):BPF 程序(BPF Prog)类型详解:使用场景、函数签名、执行位置及程序示例
bpf-helpers(7) - Linux manual page