BPF Linux监控手册

图片你好居住者! BPF虚拟机是Linux内核中最重要的组件之一。它的智能应用程序将使系统工程师能够发现故障,甚至解决最复杂的问题。您将学习如何编写程序来监视和修改内核的行为,如何安全地注入代码以观察内核中的事件,等等。 David Calavera和Lorenzo Fontana将帮助您释放BPF的强大功能。扩展您对性能优化,网络和安全性的了解。 -使用BPF跟踪和修改Linux内核行为。 -注入代码以安全地监视内核中的事件-无需重新编译内核或重新启动系统。 -在C,Go或Python中使用方便的代码示例。 -通过拥有BPF程序生命周期来管理情况。





Linux内核安全性,功能和Seccomp



BPF提供了一种在不影响稳定性,安全性或速度的情况下扩展内核的强大方法。因此,内核开发人员认为,通过实现BPF程序支持的Seccomp过滤器(也称为Seccomp BPF)来利用其多功能性来改善Seccomp中的进程隔离是一个好主意。在本章中,我们将解释什么是Seccomp及其应用方式。然后,您将学习如何使用BPF程序编写Seccomp过滤器。之后,让我们看一下内核为Linux安全模块提供的内置BPF挂钩。



Linux安全模块(LSM)是一个提供一组功能的平台,可用于标准化各种安全模型的实现。LSM可以直接在诸如Apparmor,SELinux和Tomoyo之类的内核源代码树中使用。



让我们从讨论Linux功能开始。



能力



Linux功能的本质在于,您需要授予非特权进程权限以执行特定任务,而无需为此目的使用suid,否则,应使该进程具有特权,从而减少攻击的可能性并允许该进程执行某些任务。例如,如果您的应用程序需要打开一个特权端口(例如80),而不是以root身份运行该进程,则只需为其赋予CAP_NET_BIND_SERVICE功能。



考虑一个名为main.go的Go程序:



package main
import (
            "net/http"
            "log"
)
func main() {
     log.Fatalf("%v", http.ListenAndServe(":80", nil))
}


该程序在端口80(这是特权端口)上为HTTP服务器提供服务。我们通常在编译后立即运行它:



$ go build -o capabilities main.go
$ ./capabilities


但是,由于我们没有授予root特权,因此此代码在绑定端口时将引发错误:



2019/04/25 23:17:06 listen tcp :80: bind: permission denied
exit status 1


capsh(shell控制工具)是一种可启动具有特定功能集的shell的工具。


如前所述,在这种情况下,您可以通过启用cap_net_bind_service以及程序中已经存在的所有其他功能来启用特权端口绑定,而不是授予完全的root用户特权。为此,我们可以将程序包装在capsh中:



# capsh --caps='cap_net_bind_service+eip cap_setpcap,cap_setuid,cap_setgid+ep' \
   --keep=1 --user="nobody" \
   --addamb=cap_net_bind_service -- -c "./capabilities"


让我们对这个命令有所了解。



  • capsh-使用capsh作为外壳。
  • --caps ='cap_net_bind_service + eip cap_setpcap,cap_setuid,cap_setgid + ep'-由于我们需要更改用户(我们不想以root用户身份运行),因此我们将指定cap_net_bind_service以及将用户ID从root实际更改为任何人的能力,即cap_setuid和cap_setgid ...
  • --keep=1 — , root.
  • --user=«nobody» — , , nobody.
  • --addamb=cap_net_bind_service — root.
  • — -c "./capabilities" — .


— , , execve(). , , , , .


在--caps选项中指定功能后,您可能想知道+ eip是什么意思。这些标志用于指定功能:-



必须被激活(p);



-可用于申请(e);



-可以由子进程(i)继承。



由于我们要使用cap_net_bind_service,因此需要使用e标志。然后,在命令中启动Shell。这将启动功能二进制文件,我们需要用i标志对其进行标记。最后,我们希望使用p激活功能(我们在不更改UID的情况下完成了此操作)。看起来像cap_net_bind_service + eip。



您可以使用ss检查结果。将输出缩小一点以适合页面,但是它将显示关联的端口和用户ID而不是0,在这种情况下为65534:



# ss -tulpn -e -H | cut -d' ' -f17-
128 *:80 *:*
users:(("capabilities",pid=30040,fd=3)) uid:65534 ino:11311579 sk:2c v6only:0


在此示例中,我们使用了capsh,但是您可以使用libcap编写shell。有关更多信息,请参见man 3 libcap。



在编写程序时,开发人员通常并不事先知道程序在运行时所需的所有功能;此外,这些功能可能会在新版本中更改。



为了更好地了解我们程序的功能,我们可以使用支持BCC的工具,该工具为cap_capable内核函数设置了kprobe:



/usr/share/bcc/tools/capable
TIME      UID  PID   TID   COMM               CAP    NAME           AUDIT
10:12:53 0 424     424     systemd-udevd 12 CAP_NET_ADMIN         1
10:12:57 0 1103   1101   timesync        25 CAP_SYS_TIME         1
10:12:57 0 19545 19545 capabilities       10 CAP_NET_BIND_SERVICE 1


我们可以通过在cap_capable内核函数中将bpftrace与单行kprobe结合使用来实现相同的目的:



bpftrace -e \
   'kprobe:cap_capable {
      time("%H:%M:%S ");
      printf("%-6d %-6d %-16s %-4d %d\n", uid, pid, comm, arg2, arg3);
    }' \
    | grep -i capabilities


如果在kprobe之后激活了我们程序的功能,则将输出类似以下内容:



12:01:56 1000 13524 capabilities 21 0
12:01:56 1000 13524 capabilities 21 0
12:01:56 1000 13524 capabilities 21 0
12:01:56 1000 13524 capabilities 12 0
12:01:56 1000 13524 capabilities 12 0
12:01:56 1000 13524 capabilities 12 0
12:01:56 1000 13524 capabilities 12 0
12:01:56 1000 13524 capabilities 10 1


第五列是流程所需的功能,并且由于此输出包括非审核事件,因此我们会看到所有非审核检查,最后是将审核标志(输出中的最后一个)设置为1所需的功能。我们感兴趣的是CAP_NET_BIND_SERVICE,它在ID为10的include / uapi / linux / capability.h文件的内核源代码中定义为常量:



/* Allows binding to TCP/UDP sockets below 1024 */
/* Allows binding to ATM VCIs below 32 */
#define CAP_NET_BIND_SERVICE 10<source lang="go">


通常会在运行时利用功能,以使容器(例如runC或Docker)以非特权模式运行,但只允许运行大多数应用程序所需的那些功能。当应用程序需要特定功能时,Docker可以为它们提供--cap-add:



docker run -it --rm --cap-add=NET_ADMIN ubuntu ip link add dummy0 type dummy


此命令将为容器提供CAP_NET_ADMIN功能,这将使其能够配置网络链接以添加dummy0接口。



下一节将演示诸如过滤之类的功能的用法,但是使用另一种方法,使我们能够以编程方式实现自己的过滤器。



赛康



Seccomp代表安全计算,它是Linux内核中实现的安全性层,它使开发人员可以过滤掉某些系统调用。尽管Seccomp可以与Linux的功能相提并论,但它处理特定系统调用的能力使其比以前更加灵活。



Seccomp和Linux的功能不是互斥的,而是经常一起使用以从这两种方法中受益。例如,您可能想给进程提供CAP_NET_ADMIN功能,但不允许它通过阻止accept和accept4系统调用来接受套接字连接。



Seccomp筛选方法基于在SECCOMP_MODE_FILTER模式下运行的BPF筛选器,并且系统调用筛选的执行方式与数据包相同。



通过pr_SET_SECCOMP操作使用prctl加载Seccomp筛选器。这些过滤器采用BPF程序的形式运行,该程序针对由seccomp_data结构表示的每个Seccomp程序包运行。此结构包含参考体系结构,在系统调用期间指向处理器指令的指针以及最多六个系统调用参数,表示为uint64。



这是seccomp_data结构从linux / seccomp.h文件中的内核源看起来的样子:



struct seccomp_data {
int nr;
      __u32 arch;
      __u64 instruction_pointer;
      __u64 args[6];
};


从该结构中可以看到,我们可以按系统调用,其参数或两者的组合进行过滤。



接收到每个Seccomp数据包后,筛选器必须执行处理以做出最终决定,并告诉内核下一步该做什么。最终决定以返回值之一(状态码)表示。



-SECCOMP_RET_KILL_PROCESS-过滤因此未执行的系统调用后立即终止整个过程。



-SECCOMP_RET_KILL_THREAD-过滤系统调用后立即终止当前线程,因此不会执行该调用。



-SECCOMP_RET_KILL-SECCOMP_RET_KILL_THREAD的别名,向后兼容。



-SECCOMP_RET_TRAP-禁用系统调用,并且SIGSYS(错误系统调用)信号发送到调用任务。



-SECCOMP_RET_ERRNO-不执行系统调用,并且SECCOMP_RET_DATA过滤器的部分返回值作为errno传递到用户空间。根据错误原因返回不同的errno值。错误编号在下一部分中列出。



-SECCOMP_RET_TRACE-用于通过-PTRACE_O_TRACESECCOMP通知ptrace,以便在进行系统调用以查看和控制此过程时进行拦截。如果未连接跟踪器,则返回错误,将errno设置为-ENOSYS,并且不执行系统调用。



-SECCOMP_RET_LOG-允许并记录系统调用。



-SECCOMP_RET_ALLOW-完全允许系统调用。



ptrace是一个系统调用,用于在称为tracee的流程中实现跟踪机制,并具有监视和控制流程执行的能力。跟踪程序可以有效地影响执行并更改跟踪存储寄存器。在Seccomp的上下文中,当由SECCOMP_RET_TRACE状态代码触发时,将使用ptrace,因此跟踪程序可以防止执行系统调用并实现其自己的逻辑。


Seccomp错误



在使用Seccomp时,有时会遇到各种错误,这些错误由SECCOMP_RET_ERRNO类型的返回值标识。要报告错误,seccomp系统调用将返回-1而不是0。



可能出现以下错误:



-EACCESS-不允许调用者进行系统调用。通常会发生这种情况,因为它没有CAP_SYS_ADMIN特权,或者没有使用prctl设置no_new_privs(稍后会详细介绍)。



-EFAULT-传递的参数(seccomp_data结构中的参数)没有有效地址;



-EINVAL-这里有四个原因:-



请求的操作未知或当前配置中的内核不支持;



-指定的标志对于请求的操作无效;



-operation包含BPF_ABS,但是指定的偏移量存在问题,该偏移量可能会超过seccomp_data结构的大小;



-传递给过滤器的指令数量超过了最大值;



-ENOMEM-内存不足,无法运行该程序;



-EOPNOTSUPP-该操作表明SECCOMP_GET_ACTION_AVAIL可以使用某个操作,但是内核不支持参数返回;



-ESRCH-同步另一个流时出现问题;



-ENOSYS-SECCOMP_RET_TRACE操作没有附加跟踪器。



prctl是一个系统调用,它允许用户空间程序操纵(设置和获取)进程的特定方面,例如字节序列,线程名称,安全计算模式(Seccomp),特权,Perf事件等。


Seccomp在您看来似乎是沙盒技术,但事实并非如此。Seccomp是一个实用程序,允许用户开发沙箱机制。现在,让我们看一下如何使用Seccomp系统调用直接调用的过滤器来创建自定义交互程序。



样本BPF Seccomp过滤器



在这里,我们将展示如何结合前面讨论的两个动作,即:



-编写Seccomp BPF程序,该程序将用作过滤器,并根据所做出的决定使用不同的返回码;



-使用prctl加载过滤器。



首先,您需要来自标准库和Linux内核的标头:



#include <errno.h>
#include <linux/audit.h>
#include <linux/bpf.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/unistd.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/prctl.h>
#include <unistd.h>


在尝试该示例之前,我们需要确保编译内核时将CONFIG_SECCOMP和CONFIG_SECCOMP_FILTER设置为y。在生产机器上,您可以像这样进行测试:



cat /proc/config.gz| zcat | grep -i CONFIG_SECCOMP



其余代码是由两部分组成的install_filter函数。第一部分包含我们的BPF过滤指令列表:



static int install_filter(int nr, int arch, int error) {
  struct sock_filter filter[] = {
    BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (offsetof(struct seccomp_data, arch))),
    BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, arch, 0, 3),
    BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (offsetof(struct seccomp_data, nr))),
    BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, nr, 0, 1),
    BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (error & SECCOMP_RET_DATA)),
    BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW),
  };


使用linux / filter.h文件中定义的BPF_STMT和BPF_JUMP宏来设置指令。

让我们仔细阅读说明。



-BPF_STMT(BPF_LD + BPF_W + BPF_ABS(offsetof(struct seccomp_data,arch)))-系统以BPF_W字的形式加载和累积BPF_LD,数据包数据位于固定偏移量BPF_ABS处。



-BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K,arch,0,3)-使用BPF_JEQ检查BPF_K累加器常数中的体系结构值是否等于arch。如果是这样,它将在偏移量0处跳转到下一条指令,否则将在偏移量3处(在这种情况下)跳转以引发错误,因为arch不匹配。



-BPF_STMT(BPF_LD + BPF_W + BPF_ABS(offsetof(struct seccomp_data,nr)))-以单词BPF_W的形式下载并累积BPF_LD,BPF_W是固定偏移量BPF_ABS中包含的系统调用号。



-BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K,nr,0,1)-将系统调用号与nr变量的值进行比较。如果它们相等,则继续执行下一条语句,并不允许系统调用;否则,使用SECCOMP_RET_ALLOW启用系统调用。



-BPF_STMT(BPF_RET + BPF_K,SECCOMP_RET_ERRNO |(错误和SECCOMP_RET_DATA))-以BPF_RET终止程序,结果,发出SECCOMP_RET_ERRNO错误,并带有来自err变量的数字。



-BPF_STMT(BPF_RET + BPF_K,SECCOMP_RET_ALLOW)-使用BPF_RET终止程序,并允许使用SECCOMP_RET_ALLOW执行系统调用。



SECCOMP IS CBPF您

可能想知道为什么使用指令列表代替编译的ELF对象或JIT编译的C程序。



有两个原因。



•首先,Seccomp使用cBPF(经典BPF),而不使用eBPF,这意味着它没有寄存器,而只有一个累加器来存储最后的计算结果,如示例中所示。



•其次,Seccomp直接获取指向BPF指令数组的指针,仅此而已。我们仅使用宏来帮助以方便程序员的形式指定这些指令。


如果需要更多帮助来了解此程序集,请考虑执行相同操作的伪代码:



if (arch != AUDIT_ARCH_X86_64) {
    return SECCOMP_RET_ALLOW;
}
if (nr == __NR_write) {
    return SECCOMP_RET_ERRNO;
}
return SECCOMP_RET_ALLOW;


在socket_filter结构中定义过滤器代码之后,您需要定义一个sock_fprog,其中包含代码和计算出的过滤器长度。需要此数据结构作为自变量,以声明将来的流程工作:



struct sock_fprog prog = {
   .len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
   .filter = filter,
};


在install_filter函数中只剩下一件事情要做-下载程序本身!为此,我们使用prctl,将PR_SET_SECCOMP作为进入安全计算模式的选项。然后,我们告诉模式使用SECCOMP_MODE_FILTER加载过滤器,该模式包含在sock_fprog类型的prog变量中:



  if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) {
    perror("prctl(PR_SET_SECCOMP)");
    return 1;
  }
  return 0;
}


最后,我们可以使用install_filter函数,但是在此之前,我们需要使用prctl为当前执行设置PR_SET_NO_NEW_PRIVS,从而避免子进程获得比其父进程更多的特权的情况。这样,我们可以在没有root权限的情况下在install_filter函数中对prctl进行以下调用。



现在我们可以调用install_filter函数。让我们阻止所有与X86-64体系结构相关的写入系统调用,只授予权限,这将阻止所有尝试。安装过滤器后,请使用第一个参数继续执行:



int main(int argc, char const *argv[]) {
  if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {
   perror("prctl(NO_NEW_PRIVS)");
   return 1;
  }
   install_filter(__NR_write, AUDIT_ARCH_X86_64, EPERM);
  return system(argv[1]);
 }


让我们开始吧。我们可以使用clang或gcc来编译程序,无论哪种方式,它只是在编译main.c文件时都没有特殊选项:



clang main.c -o filter-write


如前所述,我们已经阻止了程序中的所有条目。要对此进行测试,您需要一个输出某些内容的程序-ls似乎是一个不错的选择。她通常的行为是这样的:



ls -la
total 36
drwxr-xr-x 2 fntlnz users 4096 Apr 28 21:09 .
drwxr-xr-x 4 fntlnz users 4096 Apr 26 13:01 ..
-rwxr-xr-x 1 fntlnz users 16800 Apr 28 21:09 filter-write
-rw-r--r-- 1 fntlnz users 19 Apr 28 21:09 .gitignore
-rw-r--r-- 1 fntlnz users 1282 Apr 28 21:08 main.c


完美!这是我们的shell程序的样子:我们只是将要测试的程序作为第一个参数传递:



./filter-write "ls -la"


执行后,该程序将产生完全空的输出。但是,我们可以使用strace查看发生了什么:



strace -f ./filter-write "ls -la"


工作的结果大大缩短了,但是它的相应部分表明,记录被EPERM错误阻止-与我们配置的相同。这意味着该程序不会输出任何内容,因为它无法访问写入系统调用:



[pid 25099] write(2, "ls: ", 4) = -1 EPERM (Operation not permitted)
[pid 25099] write(2, "write error", 11) = -1 EPERM (Operation not permitted)
[pid 25099] write(2, "\n", 1) = -1 EPERM (Operation not permitted)


现在您了解了Seccomp BPF的工作原理,并对如何使用它有了一个好主意。但是,您是否不希望对eBPF而不是cBPF进行相同操作以充分利用其功能?



在考虑eBPF程序时,大多数人认为他们只是使用管理员权限来编写和加载它们。尽管此声明通常是正确的,但内核实现了一系列机制来保护各个级别的eBPF对象。这些机制称为BPF LSM陷阱。



陷阱BPF LSM



为了提供与体系结构无关的系统事件监视,LSM实施了陷阱的概念。挂接调用在技术上类似于系统调用,但是与系统无关,并且与基础结构集成在一起。 LSM提供了一个新概念,其中抽象层可以帮助避免在处理不同体系结构上的系统调用时出现的问题。



在撰写本文时,内核有七个与BPF程序相关的钩子,而SELinux是唯一实现它们的内置LSM。



挂钩的源代码位于include / linux / security.h文件中的内核树中:



extern int security_bpf(int cmd, union bpf_attr *attr, unsigned int size);
extern int security_bpf_map(struct bpf_map *map, fmode_t fmode);
extern int security_bpf_prog(struct bpf_prog *prog);
extern int security_bpf_map_alloc(struct bpf_map *map);
extern void security_bpf_map_free(struct bpf_map *map);
extern int security_bpf_prog_alloc(struct bpf_prog_aux *aux);
extern void security_bpf_prog_free(struct bpf_prog_aux *aux);


它们中的每一个都将在不同的执行阶段被调用:



-security_bpf-对已执行的BPF系统调用执行初始检查;



-security_bpf_map-检查内核何时返回映射的文件描述符;



-security_bpf_prog-检查内核何时返回eBPF程序的文件描述符;



-security_bpf_map_alloc-检查BPF映射内的安全字段是否已初始化;



-security_bpf_map_free-检查BPF映射内的安全字段是否已清除;



-security_bpf_prog_alloc-检查安全字段是否在BPF程序中初始化;



-security_bpf_prog_free-检查BPF程序中是否清除了安全字段。



现在,看到所有这些,我们了解到LSM BPF拦截器背后的思想是它们可以为每个eBPF对象提供保护,从而确保只有那些具有适当特权的对象才能对映射和程序执行操作。



概要



对于要保护的任何事物,您都无法以一种千篇一律的方式来强制执行安全性。能够以不同级别和不同方式保护系统非常重要。信不信由你,保护系统安全的最佳方法是在不同位置组织不同级别的保护,从而使一个级别的安全性下降可防止访问整个系统。内核开发人员做了出色的工作,为我们提供了一组不同的层和接触点。我们希望我们已经使您对什么是层以及如何使用BPF程序进行合作有了很好的了解。



关于作者



David Calavera是Netlify的CTO。他曾在Docker支持部门工作,并为Runc,Go和BCC工具以及其他开源项目的开发做出了贡献。以他在Docker项目和Docker插件生态系统开发方面的工作而闻名。 David非常喜欢火焰图,并且始终致力于优化性能。



Lorenzo Fontana是Sysdig开源开发团队的成员,在那里他主要参与Falco,这是一个Cloud Native Computing Foundation项目,可通过内核模块和eBPF提供容器运行时安全性和异常检测。他对分布式系统,软件定义的网络,Linux内核和性能分析充满热情。



»有关这本书的更多详细信息,请参见出版社网站

»目录

»摘录:



为居住者提供25%的优惠券折扣-Linux



在为该书的纸质版本付款后,会通过电子邮件发送一本电子书。



All Articles