seccomp 中的 bpf

Posted on Jul 24, 2021

学习这个问题的原因是想做 pwnable.tw 的 seccomp-tool 一题,此题的 elf 可以读取、模拟、加载用户输入的 bpf 代码,其中加载使用的是 prctl 系统调用,功能号为 PR_GET_SECCOMP。

暂时还不知道怎么做,原来觉得应该是通过 bpf 来完成利用,总不会是 elf 某处写渣了的溢出。由于我对 bpf 和 seccomp 不甚了解,所以先花了点时间了解了一下。了解了之后我感觉还真可能是 elf 本身的洞,在 emulate 功能中,也实现了一个类似于 Linux cbpf 解码器和模拟器的虚拟机,这里的代码还是有一点的,还没来的及看,说不定就是这里存在问题。

首先,根据上下文,seccomp 可能指代三种东西

  • Linux 内核 seccomp 沙箱机制,运行在该沙箱中的程序只能使用 exit,sigreturn,read 和 write 四种系统调用。
  • seccomp-bpf,seccomp 机制的扩展,通过 bpf 的支持可以使用户自定义需要过滤的系统调用。
  • seccomp lib,提供了一系列函数,使用该库可以实现类似 seccomp-bpf 的过滤效果,并且使用者不需要了解 bpf 即可使用。

seccomp-tools 此题中使用的就是 seccomp-bpf,所以这里主要说一下 seccomp-bpf。其实看雪的 [原创]seccomp沙箱机制 & 2019ByteCTF VIP 这篇文章写的非常详细,看这篇就差不多了。但是我这里按自己的习惯还是半写半抄在这里再弄一遍。

/* part of seccomp-tools install function */
  if ( bpf_bytes_len )
  {
    if ( (unsigned __int8)check_filter() == 1 )
    {
      prctl(38, 1LL, 0LL, 0LL, 0LL);            // PR_SET_NO_NEW_PRIVS
      bpf_filter_prog.filter = (struct sock_filter *)&bpf_code_arr;
      *(_QWORD *)&bpf_filter_prog.len = (int)(unsigned __int16)bpf_bytes_len >> 3;
      if ( prctl(22, 2LL, &bpf_filter_prog) )   // PR_SET_SECCOMP
        perror("prctl");
      else
        puts("Installed!");
    }
  }

BPF

BPF(伯克利包过滤器,Berkeley Packet Filter)最初的设计目标是更快地捕获和过滤网络数据包。引用 wiki 的介绍

伯克利包过滤器 (Berkeley Packet Filter,缩写 BPF),是类Unix系统上数据链路层的一种原始接口,提供原始链路层封包的收发。除此之外,如果网卡驱动支持混杂模式,那么它可以让网卡处于此种模式,这样可以收到网络上的所有包,不管他们的目的地是不是所在主机

另外,BPF支持过滤数据包——用户态的进程可以提供一个过滤程序来声明它想收到哪些数据包。通过这种过滤可以避免从操作系统内核向用户态复制其他对用户态程序无用的数据包,从而极大地提高性能。

BPF有时也只表示过滤机制,而不是整个接口。一些系统,比如Linux和Tru64 Unix,提供了数据链路层的原始接口,而不是BPF的接口,但使用了BPF的过滤机制。

BSD 内核实现例程如 bpf_mtap()bpf_tap(),以BPF_MTAP()BPF_TAP()宏定义的形式进行包裹由网卡驱动(以及伪驱动pseudo-drivers) 向BPF机制发送进出的封包。

我的理解就是在内核建立一个以寄存器为基础的虚拟机,用户向内核注入虚拟机字节码,通过内核中的 JIT 编译器就可以高效地在内核态执行用户添加的额外代码。是一个相对高效且安全地在用户态执行代码的机制

  • 高效:不需要重启、不需要修改内核,JIT 编译直接在 CPU 上执行。
  • 安全:在内核态运行之前会先通过一个 verifier 检测字节码是否安全,比 module 直接插入用户代码会更加安全。

BPF 刚被实现时其虚拟机只有两个寄存器,后来又有技术人员对它进行了扩展,增加到了 12 个寄存器和一些新的机制,扩展后的 BPF 就被称为 eBPF(extended BPF),原来的 BPF 就被称为 cBPF(classic BPF)。

seccomp 中的使用

seccomp-bpf 使用的是 cBPF(当然一些情况下这句话不怎么准确,因为如果内核支持 eBPF 的话,cBPF 会被翻译成 eBPF 执行)。cBPF 有一个 32 位累加寄存器 BPF_A,一个 32 位索引寄存器和 16 * 32 位的内存(也有人把这个当作 32 位寄存器,用 M[] 访问,这样说也是很有道理的,也就是有 16 个映射到内存的 32 位寄存器),当然还有一个不可编程的 PC。每条指令被抽象为了一个结构体

/*
 *	Try and keep these values and structures similar to BSD, especially
 *	the BPF code definitions which need to match so you can share filters
 */
 
struct sock_filter {	/* Filter block */
	__u16	code;   /* Actual filter code */
	__u8	jt;	/* Jump true */
	__u8	jf;	/* Jump false */
	__u32	k;      /* Generic multiuse field */
};

整条指令链就被抽象为了

struct sock_fprog {	/* Required for SO_ATTACH_FILTER. */
	unsigned short		len;	/* Number of filter blocks */
	struct sock_filter *filter;
};

为了便于书写,在 filter.h 中封装了 cBPF 结构体宏,宏展开后实际上就是指令结构体。

/*
 * Macros for filter block array initializers.
 */
#define BPF_STMT(code, k) { (unsigned short)(code), 0, 0, k }
#define BPF_JUMP(code, k, jt, jf) { (unsigned short)(code), jt, jf, k }

可用的指令被定义在了 bpf_common.h 中

/* Instruction classes */
#define BPF_CLASS(code) ((code) & 0x07)
#define		BPF_LD		0x00 // load, 赋值到寄存器中
#define		BPF_LDX		0x01
#define		BPF_ST		0x02 // store, 赋值到内存中
#define		BPF_STX		0x03
#define		BPF_ALU		0x04
#define		BPF_JMP		0x05
#define		BPF_RET		0x06
#define		BPF_MISC        0x07

/* ld/ldx fields */
#define BPF_SIZE(code)  ((code) & 0x18)
#define		BPF_W		0x00 /* 32-bit */
#define		BPF_H		0x08 /* 16-bit */
#define		BPF_B		0x10 /*  8-bit */
/* eBPF		BPF_DW		0x18    64-bit */
#define BPF_MODE(code)  ((code) & 0xe0)
#define		BPF_IMM		0x00
#define		BPF_ABS		0x20
#define		BPF_IND		0x40
#define		BPF_MEM		0x60
#define		BPF_LEN		0x80
#define		BPF_MSH		0xa0

/* alu/jmp fields */
#define BPF_OP(code)    ((code) & 0xf0)
#define		BPF_ADD		0x00
#define		BPF_SUB		0x10
#define		BPF_MUL		0x20
#define		BPF_DIV		0x30
#define		BPF_OR		0x40
#define		BPF_AND		0x50
#define		BPF_LSH		0x60
#define		BPF_RSH		0x70
#define		BPF_NEG		0x80
#define		BPF_MOD		0x90
#define		BPF_XOR		0xa0

#define		BPF_JA		0x00
#define		BPF_JEQ		0x10
#define		BPF_JGT		0x20
#define		BPF_JGE		0x30
#define		BPF_JSET        0x40
#define BPF_SRC(code)   ((code) & 0x08)
#define		BPF_K		0x00
#define		BPF_X		0x08

以禁用 execve 为例,使用 bpf 禁用就是这样

#include <unistd.h>
#include <stdio.h>
#include <stdint.h>
#include <sys/syscall.h>
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <sys/prctl.h>

int main()
{
    struct sock_filter filter[] = 
    {
        BPF_STMT(BPF_LD + BPF_W + BPF_ABS, 0),
        BPF_JUMP(BPF_JMP + BPF_JEQ, SYS_execve, 0, 1),  /* sys_number == execve */
        BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_KILL),    /* true */
        BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW)    /* false */
    };
    struct sock_fprog bpf_prog;
    bpf_prog.filter = filter;
    bpf_prog.len = sizeof(filter) / sizeof(struct sock_filter);

    prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
    prctl(PR_SET_SECCOMP, 2, &bpf_prog);
    while(1)
    {
        /* code */
    }
    return 0;
}

这里在设置 seccomp 之前先执行了 prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);,这条语句禁止了此进程提权。这么做的原因是使用 prctl 的 PR_SET_SECCOMP 需要 CAP_SYS_ADMIN 这个 capabilities,如果没有这个 capabilities 就需要设置 PR_SET_NO_NEW_PRIVS 为 1,否则对于非 root 用户 seccomp 就会失效。

bpf 的每一条指令中的 code 都由 指令类型 + 指令 的方法组成。filter.h 提供的宏 BPF_STMT 是普通语句,做运算和内存操作等,BPF_JUMP 就是跳转语句,jf 和 jt 的值就是相应情况下跳过的指令数。

编译后用 seccomp-tools dump 一下

$ seccomp-tools dump ./seccomp_bpf
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000000  A = sys_number
 0001: 0x15 0x00 0x01 0x0000003b  if (A != execve) goto 0003
 0002: 0x06 0x00 0x00 0x00000000  return KILL
 0003: 0x06 0x00 0x00 0x7fff0000  return ALLOW

可以发现成功过滤了。

到这里就搞清楚了如何用 bpf 来进行系统调用过滤了。

宏观地来看实现流程

然后再宏观地看一下 prctl 对 bpf 的处理流程,以 linux-5.12.9 版本的代码为例。

首先是 prctl 系统调用

SYSCALL_DEFINE5(prctl, int, option, unsigned long, arg2, unsigned long, arg3,
		unsigned long, arg4, unsigned long, arg5)

这个系统调用使用了一个巨大的 switch 来处理各种功能选项,我们关心的是这个 case

case PR_SET_SECCOMP:
		error = prctl_set_seccomp(arg2, (char __user *)arg3);

此功能由 prctl_set_seccomp 函数实现,函数比较简短,这里全部放一下

/**
 * prctl_set_seccomp: configures current->seccomp.mode
 * @seccomp_mode: requested mode to use
 * @filter: optional struct sock_fprog for use with SECCOMP_MODE_FILTER
 *
 * Returns 0 on success or -EINVAL on failure.
 */
long prctl_set_seccomp(unsigned long seccomp_mode, void __user *filter)
{
	unsigned int op;
	void __user *uargs;

	switch (seccomp_mode) {
	case SECCOMP_MODE_STRICT:
		op = SECCOMP_SET_MODE_STRICT;
		/*
		 * Setting strict mode through prctl always ignored filter,
		 * so make sure it is always NULL here to pass the internal
		 * check in do_seccomp().
		 */
		uargs = NULL;
		break;
	case SECCOMP_MODE_FILTER:
		op = SECCOMP_SET_MODE_FILTER;
		uargs = filter;
		break;
	default:
		return -EINVAL;
	}

	/* prctl interface doesn't have flags, so they are always zero. */
	return do_seccomp(op, 0, uargs);
}

#define SECCOMP_MODE_STRICT 1
#define SECCOMP_MODE_FILTER 2

seccomp_mode 就是指定设置的模式了,strict mode 就是只允许 exit,sigreturn,read 和 write,filter mode 就是通过 bpf 来自定义过滤了。然后调用 do_seccomp 函数,也比较短

/* Common entry point for both prctl and syscall. */
static long do_seccomp(unsigned int op, unsigned int flags,
		       void __user *uargs)
{
	switch (op) {
	case SECCOMP_SET_MODE_STRICT:
		if (flags != 0 || uargs != NULL)
			return -EINVAL;
		return seccomp_set_mode_strict();
	case SECCOMP_SET_MODE_FILTER:
		return seccomp_set_mode_filter(flags, uargs);
	case SECCOMP_GET_ACTION_AVAIL:
		if (flags != 0)
			return -EINVAL;

		return seccomp_get_action_avail(uargs);
	case SECCOMP_GET_NOTIF_SIZES:
		if (flags != 0)
			return -EINVAL;

		return seccomp_get_notif_sizes(uargs);
	default:
		return -EINVAL;
	}
}

我们关系的是 SECCOMP_SET_MODE_FILTER 这个 case,可见实际调用的是 seccomp_set_mode_filter 函数,这个函数比较长,这里就不放了,我们主要关心的是流程对用户 bpf 代码的处理,也就是编译的过程,最后进行编译的函数为 bpf_prepare_filter,调用链为

seccomp_prepare_user_filter
seccomp_prepare_filter
bpf_prog_create_from_user
bpf_prepare_filter
static struct bpf_prog *bpf_prepare_filter(struct bpf_prog *fp,
					   bpf_aux_classic_check_t trans)
{
	int err;

	fp->bpf_func = NULL;
	fp->jited = 0;

	err = bpf_check_classic(fp->insns, fp->len);
	if (err) {
		__bpf_prog_release(fp);
		return ERR_PTR(err);
	}

	/* There might be additional checks and transformations
	 * needed on classic filters, f.e. in case of seccomp.
	 */
	if (trans) {
		err = trans(fp->insns, fp->len);
		if (err) {
			__bpf_prog_release(fp);
			return ERR_PTR(err);
		}
	}

	/* Probe if we can JIT compile the filter and if so, do
	 * the compilation of the filter.
	 */
	bpf_jit_compile(fp);

	/* JIT compiler couldn't process this filter, so do the
	 * internal BPF translation for the optimized interpreter.
	 */
	if (!fp->jited)
		fp = bpf_migrate_filter(fp);

	return fp;
}

这里会尝试 jit 编译,如果失败就通过 bpf_migrate_filter 函数来进行转换,此函数会两次调用 bpf_convert_filter 函数,两次调用的注释为

/* 1st pass: calculate the new program length. */
err = bpf_convert_filter(old_prog, old_len, NULL, &new_len,
				 &seen_ld_abs);
..
/* 2nd pass: remap sock_filter insns into bpf_insn insns. */
err = bpf_convert_filter(old_prog, old_len, fp, &new_len,
				 &seen_ld_abs);

可见第一次是计算转换后的程序长度,第二次是正式的代码转换。bpf_convert_filter 的实现挺复杂的,我也没仔细看,这里就不多说了。

通过调试我发现 cBPF 代码是无法 jit 编译的,返回后 fp->jited 的值仍为 0

我的理解是,jit 是 eBPF 实现的,所以 cBPF 代码需要被 bpf_migrate_filter 转换。

eBPF 简单介绍

这玩意很复杂,不了解。这里为了解题,简单的了解了一下,相比于 cBPF 还是有不少变动的,首先是指令

struct bpf_insn {
	__u8	code;		/* opcode */
	__u8	dst_reg:4;	/* dest register */
	__u8	src_reg:4;	/* source register */
	__s16	off;		/* signed offset */
	__s32	imm;		/* signed immediate constant */
};

寄存器也被扩充到了 10 个

/* Register numbers */
enum {
	BPF_REG_0 = 0,
	BPF_REG_1,
	BPF_REG_2,
	BPF_REG_3,
	BPF_REG_4,
	BPF_REG_5,
	BPF_REG_6,
	BPF_REG_7,
	BPF_REG_8,
	BPF_REG_9,
	BPF_REG_10,
	__MAX_BPF_REG,
};

这些寄存器有约定功能

  • R0:内核函数返回值存储再改寄存器中,同时也是 eBPF 程序的返回值寄存器
  • R1 - R5:调用内核函数的参数寄存器
  • R6 - R9:被调函数负责备份的寄存器
  • R10:用于访问栈的只读寄存器

这十个寄存器在实际运行的时候是直接映射到 CPU 寄存器上的,以 x86_64 为例,R0 - R10 就依次映射到

  • rax, rdi, rsi, rdx, rcx, r8, rbx, r13, r14, r15, rbp

结合 cdecl 调用约定就很好理解了。

参考

Linux内核工程导论——网络:Filter(LSF、BPF、eBPF) [原创]seccomp沙箱机制 & 2019ByteCTF VIP