适合小孩子的 BPF,第 XNUMX 部分:经典 BPF

Berkeley Packet Filters (BPF) 是一项 Linux 内核技术,多年来一直出现在英语技术出版物的头版上。 会议上充斥着关于BPF的使用和开发的报告。 Linux 网络子系统维护者 David Miller 在 Linux Plumbers 2018 上发表演讲 “这个谈话不是关于 XDP” (XDP 是 BPF 的用例之一)。 布伦丹·格雷格发表题为“ Linux BPF 的超能力。 托克·霍伊兰-约根森 内核现在是微内核。 托马斯·格拉夫(Thomas Graf)提倡这样的想法: BPF 是内核的 javascript.

Habré 上还没有对 BPF 进行系​​统的描述,因此在一系列文章中我将尝试谈论该技术的历史,描述架构和开发工具,并概述使用 BPF 的应用领域和实践。 系列文章零,讲述了经典BPF的历史和架构,也揭示了其运行原理的秘密。 tcpdump, seccomp, strace, 以及更多。

BPF的开发由Linux网络社区控制,BPF现有的主要应用程序与网络相关,因此,经过许可 @eucariot,我将这个系列称为“BPF for the Little Ones”,以纪念这个伟大的系列 “适合小孩子的网络”.

BPF 历史短期课程(c)

现代 BPF 技术是同名旧技术的改进和扩展版本,现在称为经典 BPF 以避免混淆。 基于经典 BPF 创建了一个著名的实用程序 tcpdump, 机制 seccomp,以及鲜为人知的模块 xt_bpfiptables 和分类器 cls_bpf。 在现代 Linux 中,经典 BPF 程序会自动转换为新形式,但是,从用户的角度来看,API 仍然存在,并且正如我们将在本文中看到的,经典 BPF 的新用途仍在被发现。 出于这个原因,也因为跟随经典 BPF 在 Linux 中的发展历史,会更清楚它是如何以及为何演变成现代形式的,所以我决定从一篇关于经典 BPF 的文章开始。

上世纪八十年代末,著名的劳伦斯伯克利实验室的工程师对如何在上世纪八十年代末现代化的硬件上正确过滤网络数据包的问题产生了兴趣。 过滤的基本思想,最初是在CSPF(CMU/Stanford Packet Filter)技术中实现的,是尽早过滤掉不必要的数据包,即在内核空间中,因为这可以避免将不必要的数据复制到用户空间中。 为了为在内核空间中运行的用户代码提供运行时安全性,使用了沙盒虚拟机。

然而,现有过滤器的虚拟机被设计为在基于堆栈的机器上运行,并且在较新的 RISC 机器上运行效率不高。 于是,经过伯克利实验室工程师的努力,开发出了一种新的BPF(伯克利包过滤器)技术,其虚拟机架构是基于摩托罗拉6502处理器设计的,摩托罗拉XNUMX处理器是诸如 Apple II или 未列明在其他编号。 与现有解决方案相比,新的虚拟机将过滤器性能提高了数十倍。

BPF机器架构

我们将以工作方式通过分析示例来熟悉架构。 然而,首先,假设机器有两个可供用户访问的 32 位寄存器,一个累加器 A 和索引寄存器 X、64 字节内存(16 个字),可用于写入和后续读取,以及用于处理这些对象的小型命令系统。 程序中也有实现条件表达式的跳转指令,但为了保证程序的及时完成,跳转只能向前进行,即特别禁止创建循环。

启动机器的总体方案如下。 用户为 BPF 架构创建一个程序,并使用 一些 内核机制(例如系统调用),加载并连接程序 对某些人 到内核​​中的事件生成器(例如,事件是网卡上下一个数据包的到达)。 当事件发生时,内核运行程序(例如在解释器中),机器内存对应 对某些人 内核内存区域(例如,传入数据包的数据)。

以上内容足以让我们开始查看示例:我们将根据需要熟悉系统和命令格式。 如果你想立即研究虚拟机的命令系统并了解其所有功能,那么你可以阅读原文 BSD 数据包过滤器 和/或文件的前半部分 文档/网络/filter.txt 来自内核文档。 此外,您还可以研究演示 libpcap:数据包捕获的架构和优化方法其中,BPF 的作者之一 McCanne 讲述了创作的历史 libpcap.

我们现在继续考虑在 Linux 上使用经典 BPF 的所有重要示例: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.

转储

BPF 的开发与包过滤前端的开发同时进行 - 一个众所周知的实用程序 tcpdump。 而且,由于这是使用经典 BPF 的最古老和最著名的示例,可在许多操作系统上使用,因此我们将从它开始研究该技术。

(我在 Linux 上运行了本文中的所有示例 5.6.0-rc6。 一些命令的输出已被编辑以提高可读性。)

示例:观察 IPv6 数据包

假设我们想要查看接口上的所有 IPv6 数据包 eth0。 为此,我们可以运行该程序 tcpdump 用一个简单的过滤器 ip6:

$ sudo tcpdump -i eth0 ip6

在这种情况下, tcpdump 编译过滤器 ip6 写入 BPF 架构字节码并将其发送到内核(详细信息请参阅 Tcpdump:正在加载)。 加载的过滤器将对通过接口的每个数据包运行 eth0。 如果过滤器返回非零值 n,然后直到 n 数据包的字节将被复制到用户空间,我们将在输出中看到它 tcpdump.

适合小孩子的 BPF,第 XNUMX 部分:经典 BPF

事实证明,我们可以很容易地找出哪些字节码发送到了内核 tcpdump 在的帮助下 tcpdump,如果我们使用选项运行它 -d:

$ sudo tcpdump -i eth0 -d ip6
(000) ldh      [12]
(001) jeq      #0x86dd          jt 2    jf 3
(002) ret      #262144
(003) ret      #0

在第 XNUMX 行我们运行命令 ldh [12],它代表“加载到寄存器 A 半个字(16 位)位于地址 12”,唯一的问题是我们正在寻址什么样的内存? 答案是,在 x 开始 (x+1)分析的网络数据包的第一个字节。 我们从以太网接口读取数据包 eth0而且这个 手段数据包如下所示(为简单起见,我们假设数据包中没有 VLAN 标记):

       6              6          2
|Destination MAC|Source MAC|Ether Type|...|

所以执行命令后 ldh [12] 在寄存器中 A 将会有一个字段 Ether Type — 此以太网帧中传输的数据包类型。 在第 1 行,我们比较寄存器的内容 A (封装类型)c 0x86dd而且这个 并且有 我们感兴趣的类型是 IPv6。 第 1 行,除了比较命令之外,还有两列 - jt 2 и jf 3 — 如果比较成功则需要转到的标记(A == 0x86dd)并且不成功。 因此,在成功的情况下 (IPv6),我们转到第 2 行,在不成功的情况下,转到第 3 行。在第 3 行,程序以代码 0 终止(不复制数据包),在第 2 行,程序以代码终止262144(给我复制一个最大256KB的包)。

一个更复杂的例子:我们按目标端口查看 TCP 数据包

让我们看看复制目标端口 666 的所有 TCP 数据包的过滤器是什么样的。我们将考虑 IPv4 情况,因为 IPv6 情况更简单。 研究完此示例后,您可以自己探索 IPv6 过滤器作为练习(ip6 and tcp dst port 666)和一般情况的过滤器(tcp dst port 666)。 因此,我们感兴趣的过滤器如下所示:

$ sudo tcpdump -i eth0 -d ip and tcp dst port 666
(000) ldh      [12]
(001) jeq      #0x800           jt 2    jf 10
(002) ldb      [23]
(003) jeq      #0x6             jt 4    jf 10
(004) ldh      [20]
(005) jset     #0x1fff          jt 10   jf 6
(006) ldxb     4*([14]&0xf)
(007) ldh      [x + 16]
(008) jeq      #0x29a           jt 9    jf 10
(009) ret      #262144
(010) ret      #0

我们已经知道第 0 行和第 1 行的作用。 在第 2 行,我们已经检查过这是一个 IPv4 数据包(以太网类型 = 0x800)并将其加载到寄存器中 A 数据包的第 24 个字节。 我们的包裹看起来像

       14            8      1     1
|ethernet header|ip fields|ttl|protocol|...|

这意味着我们加载到寄存器中 A IP 标头的协议字段,这是合乎逻辑的,因为我们只想复制 TCP 数据包。 我们将协议与 0x6 (IPPROTO_TCP)第 3 行。

在第 4 行和第 5 行,我们加载位于地址 20 的半字并使用命令 jset 检查是否设置了三者之一 标志 - 佩戴发放的口罩 jset 三个最高有效位被清除。 三个位中的两个告诉我们该数据包是否是分段 IP 数据包的一部分,如果是,则它是否是最后一个分段。 第三位是保留的并且必须为零。 我们不想检查整数或损坏的数据包,因此我们检查所有三位。

第 6 行是此列表中最有趣的。 表达 ldxb 4*([14]&0xf) 意味着我们加载到寄存器中 X 数据包第 4 个字节的最低有效 XNUMX 位乘以 XNUMX。第 XNUMX 个字节的最低有效 XNUMX 位是字段 互联网标头长度 IPv4 标头,以字为单位存储标头长度,因此需要乘以 4。有趣的是,表达式 4*([14]&0xf) 是一种特殊寻址方案的名称,只能以这种形式使用,并且只能用于寄存器 X, IE。 我们也不能说 ldb 4*([14]&0xf) 也不 ldxb 5*([14]&0xf) (我们只能指定不同的偏移量,例如, ldxb 4*([16]&0xf))。 很明显,这个寻址方案被添加到 BPF 正是为了接收 X (索引寄存器)IPv4 标头长度。

所以在第 7 行我们尝试加载半个单词 (X+16)。 请记住,以太网标头占用了 14 个字节,并且 X 包含IPv4报头的长度,我们理解在 A TCP 目标端口已加载:

       14           X           2             2
|ethernet header|ip header|source port|destination port|

最后,在第 8 行,我们将目标端口与所需值进行比较,在第 9 行或第 10 行,我们返回结果 - 是否复制数据包。

Tcpdump:正在加载

在前面的示例中,我们没有具体详细说明如何将 BPF 字节码加载到内核中以进行数据包过滤。 一般来说, tcpdump 移植到许多系统并与过滤器一起使用 tcpdump 使用图书馆 libpcap。 简而言之,使用以下方法在界面上放置过滤器 libpcap,您需要执行以下操作:

看看功能如何 pcap_setfilter 在Linux中实现,我们使用 strace (一些行已被删除):

$ sudo strace -f -e trace=%network tcpdump -p -i eth0 ip
socket(AF_PACKET, SOCK_RAW, 768)        = 3
bind(3, {sa_family=AF_PACKET, sll_protocol=htons(ETH_P_ALL), sll_ifindex=if_nametoindex("eth0"), sll_hatype=ARPHRD_NETROM, sll_pkttype=PACKET_HOST, sll_halen=0}, 20) = 0
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...

在输出的前两行我们创建 原始套接字 读取所有以太网帧并将其绑定到接口 eth0。 从 我们的第一个例子 我们知道过滤器 ip 将由四个 BPF 指令组成,在第三行我们看到如何使用该选项 SO_ATTACH_FILTER 系统调用 setsockopt 我们加载并连接一个长度为 4 的过滤器。这是我们的过滤器。

值得注意的是,在经典BPF中,加载和连接过滤器总是作为原子操作发生,而在新版本的BPF中,加载程序和将其绑定到事件生成器在时间上是分开的。

隐藏的真相

输出的稍微完整的版本如下所示:

$ sudo strace -f -e trace=%network tcpdump -p -i eth0 ip
socket(AF_PACKET, SOCK_RAW, 768)        = 3
bind(3, {sa_family=AF_PACKET, sll_protocol=htons(ETH_P_ALL), sll_ifindex=if_nametoindex("eth0"), sll_hatype=ARPHRD_NETROM, sll_pkttype=PACKET_HOST, sll_halen=0}, 20) = 0
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=1, filter=0xbeefbeefbeef}, 16) = 0
recvfrom(3, 0x7ffcad394257, 1, MSG_TRUNC, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable)
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...

如上所述,我们将过滤器加载并连接到第 5 行的套接字,但是第 3 行和第 4 行会发生什么情况? 事实证明,这 libpcap 照顾我们 - 以便我们的过滤器的输出不包括不满足它的数据包,库 连接 虚拟过滤器 ret #0 (丢弃所有数据包),将套接字切换到非阻塞模式,并尝试减去先前过滤器中可能保留的所有数据包。

总的来说,要使用经典 BPF 在 Linux 上过滤包,您需要一个结构形式如下的过滤器 struct sock_fprog 和一个打开的套接字,之后可以使用系统调用将过滤器附加到套接字 setsockopt.

有趣的是,过滤器可以连接到任何插座,而不仅仅是原始插座。 这里 例子 一个程序,它切断所有传入 UDP 数据报中除前两个字节之外的所有字节。 (我在代码中添加了注释,以免文章变得混乱。)

更多使用详情 setsockopt 有关连接过滤器的信息,请参见 插座(7),但是关于编写自己的过滤器,例如 struct sock_fprog 不靠别人帮助 tcpdump 我们将在本节中讨论 自己动手编程BPF.

经典 BPF 和 XNUMX 世纪

BPF 于 1997 年被纳入 Linux,并且在很长一段时间内一直是主力 libpcap 无需任何特殊更改(当然,Linux 特定的更改, ,但他们并没有改变全球格局)。 BPF 发展的第一个重要迹象出现在 2011 年,当时 Eric Dumazet 提出 补丁,它将 Just In Time Compiler 添加到内核 - 用于将 BPF 字节码转换为本机的翻译器 x86_64 代码。

JIT 编译器是变革链中的第一个:2012 年 出现 编写过滤器的能力 赛康,使用 BPF,2013 年 XNUMX 月有 添加xt_bpf,它允许您编写规则 iptables 在 BPF 的帮助下,并于 2013 年 XNUMX 月 添加 也是一个模块 cls_bpf,它允许您使用 BPF 编写流量分类器。

我们很快就会更详细地查看所有这些示例,但首先,学习如何为 BPF 编写和编译任意程序将很有用,因为该库提供的功能 libpcap 有限(简单的例子:过滤器生成 libpcap 只能返回两个值 - 0 或 0x40000)或者通常(如 seccomp 的情况)不适用。

自己动手编程BPF

我们来熟悉一下BPF指令的二进制格式,非常简单:

   16    8    8     32
| code | jt | jf |  k  |

每条指令占用64位,其中前16位是指令代码,然后有两个八位缩进, jt и jf,参数为 32 位 K,其目的因命令而异。 例如,命令 ret,终止程序的代码是 6,返回值取自常量 K。 在C中,单个BPF指令被表示为一个结构体

struct sock_filter {
        __u16   code;
        __u8    jt;
        __u8    jf;
        __u32   k;
}

整个程序是一个结构体的形式

struct sock_fprog {
        unsigned short len;
        struct sock_filter *filter;
}

这样,我们就已经可以编写程序了(例如,我们知道指令代码为 [1])。 这就是过滤器的样子 ip6 из 我们的第一个例子:

struct sock_filter code[] = {
        { 0x28, 0, 0, 0x0000000c },
        { 0x15, 0, 1, 0x000086dd },
        { 0x06, 0, 0, 0x00040000 },
        { 0x06, 0, 0, 0x00000000 },
};
struct sock_fprog prog = {
        .len = ARRAY_SIZE(code),
        .filter = code,
};

该计划 prog 我们可以在通话中合法使用

setsockopt(sk, SOL_SOCKET, SO_ATTACH_FILTER, &prog, sizeof(prog))

以机器代码的形式编写程序不是很方便,但有时是必要的(例如,用于调试、创建单元测试、撰写关于 Habré 的文章等)。 为了方便起见,在文件中 <linux/filter.h> 定义了辅助宏 - 与上面相同的示例可以重写为

struct sock_filter code[] = {
        BPF_STMT(BPF_LD|BPF_H|BPF_ABS, 12),
        BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, ETH_P_IPV6, 0, 1),
        BPF_STMT(BPF_RET|BPF_K, 0x00040000),
        BPF_STMT(BPF_RET|BPF_K, 0),
}

然而,这个选项不是很方便。 这是 Linux 内核程序员的推理,因此在目录中 tools/bpf 在内核中,您可以找到用于使用经典 BPF 的汇编器和调试器。

汇编语言与调试输出非常相似 tcpdump,但除此之外我们还可以指定符号标签。 例如,下面是一个丢弃除 TCP/IPv4 之外的所有数据包的程序:

$ cat /tmp/tcp-over-ipv4.bpf
ldh [12]
jne #0x800, drop
ldb [23]
jneq #6, drop
ret #-1
drop: ret #0

默认情况下,汇编器生成的代码格式为 <количество инструкций>,<code1> <jt1> <jf1> <k1>,...,对于我们的 TCP 示例来说,它将是

$ tools/bpf/bpf_asm /tmp/tcp-over-ipv4.bpf
6,40 0 0 12,21 0 3 2048,48 0 0 23,21 0 1 6,6 0 0 4294967295,6 0 0 0,

为了方便 C 程序员,可以使用不同的输出格式:

$ tools/bpf/bpf_asm -c /tmp/tcp-over-ipv4.bpf
{ 0x28,  0,  0, 0x0000000c },
{ 0x15,  0,  3, 0x00000800 },
{ 0x30,  0,  0, 0x00000017 },
{ 0x15,  0,  1, 0x00000006 },
{ 0x06,  0,  0, 0xffffffff },
{ 0x06,  0,  0, 0000000000 },

可以将此文本复制到类型结构定义中 struct sock_filter,正如我们在本节开头所做的那样。

Linux 和 netsniff-ng 扩展

除了标准 BPF 之外,Linux 和 tools/bpf/bpf_asm 支持和 非标套装。 基本上,指令用于访问结构体的字段 struct sk_buff,它描述了内核中的网络数据包。 但是,还有其他类型的帮助指令,例如 ldw cpu 将加载到寄存器中 A 运行核函数的结果 raw_smp_processor_id()。 (在新版本的 BPF 中,这些非标准扩展已得到扩展,为程序提供一组内核帮助程序,用于访问内存、结构和生成事件。)这是一个有趣的过滤器示例,其中我们仅复制使用扩展将数据包标头放入用户空间 poff,有效载荷偏移量:

ld poff
ret a

BPF 扩展不能用于 tcpdump,但这是熟悉实用程序包的一个很好的理由 netsniff-ng,其中包含一个高级程序 netsniff-ng,除了使用BPF进行过滤之外,还包含一个有效的流量生成器,并且比 tools/bpf/bpf_asm,一个 BPF 汇编器称为 bpfc。 该软件包包含非常详细的文档,另请参阅文章末尾的链接。

赛康

因此,我们已经知道如何编写任意复杂度的 BPF 程序,并准备好查看新的示例,其中第一个是 seccomp 技术,它允许使用 BPF 过滤器来管理可用的系统调用参数集。给定的进程及其后代。

seccomp 的第一个版本于 2005 年添加到内核中,但并不是很受欢迎,因为它只提供了一个选项 - 将进程可用的系统调用集限制为以下内容: read, write, exit и sigreturn,并且违反规则的进程被使用杀死 SIGKILL。 然而,在 2012 年,seccomp 添加了使用 BPF 过滤器的功能,允许您定义一组允许的系统调用,甚至对其参数执行检查。 (有趣的是,Chrome 是该功能的首批用户之一,Chrome 人员目前正在开发基于新版本 BPF 的 KRSI 机制,并允许定制 Linux 安全模块。)其他文档的链接可以在末尾找到文章的。

请注意,中心上已经有关于使用 seccomp 的文章,也许有人会想在阅读以下小节之前(或代替)阅读它们。 文章中 容器和安全性:seccomp 提供了使用 seccomp 的示例,包括 2007 版本和使用 BPF 的版本(使用 libseccomp 生成过滤器),讨论了 seccomp 与 Docker 的连接,还提供了许多有用的链接。 文章中 使用 systemd 隔离守护进程或者“你不需要 Docker!” 它特别介绍了如何为运行 systemd 的守护进程添加系统调用黑名单或白名单。

接下来我们将看到如何编写和加载过滤器 seccomp 在裸 C 中并使用该库 libseccomp 以及每个选项的优缺点是什么,最后让我们看看程序如何使用 seccomp strace.

为 seccomp 编写和加载过滤器

我们已经知道如何编写BPF程序,所以我们首先看一下seccomp编程接口。 您可以在进程级别设置过滤器,所有子进程都会继承这些限制。 这是使用系统调用完成的 seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

哪里 &filter - 这是一个指向我们已经熟悉的结构的指针 struct sock_fprog, IE。 BPF 计划。

seccomp 程序与套接字程序有何不同? 传输的上下文。 在套接字的情况下,我们得到一个包含数据包的内存区域,在 seccomp 的情况下,我们得到一个像这样的结构

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

这是 nr 是要启动的系统调用的编号, arch - 当前架构(更多内容见下文), args - 最多六个系统调用参数,以及 instruction_pointer 是指向进行系统调用的用户空间指令的指针。 因此,例如,将系统调用号加载到寄存器中 A 我们不得不说

ldw [0]

seccomp 程序还有其他功能,例如,上下文只能通过 32 位对齐访问,并且在尝试加载过滤器时无法加载半个字或一个字节 ldh [0] 系统调用 seccomp 会回来 EINVAL。 该函数检查加载的过滤器 seccomp_check_filter() 内核。 (有趣的是,在添加 seccomp 功能的原始提交中,他们忘记添加使用该函数的指令的权限 mod (除法余数),现在不可用于 seccomp BPF 程序,因为它的添加 会坏 阿比。)

基本上,我们已经知道编写和读取 seccomp 程序的所有内容。 通常程序逻辑被安排为系统调用的白名单或黑名单,例如程序

ld [0]
jeq #304, bad
jeq #176, bad
jeq #239, bad
jeq #279, bad
good: ret #0x7fff0000 /* SECCOMP_RET_ALLOW */
bad: ret #0

检查黑名单中有四个系统调用,编号分别为304、176、239、279。这些系统调用是什么? 我们不能肯定地说,因为我们不知道程序是为哪种架构编写的。 因此,seccomp 的作者 提供 通过架构检查启动所有程序(当前架构在上下文中指示为字段 arch 结构 struct seccomp_data)。 检查架构后,示例的开头将如下所示:

ld [4]
jne #0xc000003e, bad_arch ; SCMP_ARCH_X86_64

然后我们的系统调用号就会得到一定的值。

我们使用 seccomp 编写和加载过滤器 libseccomp

在本机代码或 BPF 程序集中编写过滤器允许您完全控制结果,但同时,有时最好拥有可移植和/或可读的代码。 图书馆会帮助我们解决这个问题 libseccomp,它提供了用于编写黑色或白色滤镜的标准接口。

例如,让我们编写一个程序来运行用户选择的二进制文件,之前已经安装了来自以下位置的系统调用黑名单: 上面的文章 (该程序已被简化以提高可读性,完整版本可以找到 这里):

#include <seccomp.h>
#include <unistd.h>
#include <err.h>

static int sys_numbers[] = {
        __NR_mount,
        __NR_umount2,
       // ... еще 40 системных вызовов ...
        __NR_vmsplice,
        __NR_perf_event_open,
};

int main(int argc, char **argv)
{
        scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);

        for (size_t i = 0; i < sizeof(sys_numbers)/sizeof(sys_numbers[0]); i++)
                seccomp_rule_add(ctx, SCMP_ACT_TRAP, sys_numbers[i], 0);

        seccomp_load(ctx);

        execvp(argv[1], &argv[1]);
        err(1, "execlp: %s", argv[1]);
}

首先我们定义一个数组 sys_numbers 40 多个要阻止的系统调用号码。 然后,初始化上下文 ctx 并告诉图书馆我们想要允许什么(SCMP_ACT_ALLOW)默认所有系统调用(更容易建立黑名单)。 然后,我们一一添加黑名单中的所有系统调用。 为了响应列表中的系统调用,我们请求 SCMP_ACT_TRAP,在这种情况下 seccomp 将向进程发送信号 SIGSYS 并描述哪个系统调用违反了规则。 最后,我们使用以下命令将程序加载到内核中 seccomp_load,它将编译程序并使用系统调用将其附加到进程 seccomp(2).

为了成功编译,程序必须与库链接 libseccomp例如:

cc -std=c17 -Wall -Wextra -c -o seccomp_lib.o seccomp_lib.c
cc -o seccomp_lib seccomp_lib.o -lseccomp

成功启动示例:

$ ./seccomp_lib echo ok
ok

被阻止的系统调用的示例:

$ sudo ./seccomp_lib mount -t bpf bpf /tmp
Bad system call

我们用 strace详情:

$ sudo strace -e seccomp ./seccomp_lib mount -t bpf bpf /tmp
seccomp(SECCOMP_SET_MODE_FILTER, 0, {len=50, filter=0x55d8e78428e0}) = 0
--- SIGSYS {si_signo=SIGSYS, si_code=SYS_SECCOMP, si_call_addr=0xboobdeadbeef, si_syscall=__NR_mount, si_arch=AUDIT_ARCH_X86_64} ---
+++ killed by SIGSYS (core dumped) +++
Bad system call

我们如何知道程序因使用非法系统调用而终止 mount(2).

因此,我们使用该库编写了一个过滤器 libseccomp,将重要的代码装入四行。 在上面的示例中,如果存在大量系统调用,则执行时间可以显着减少,因为检查只是比较列表。 为了优化,libseccomp 最近有 包含补丁,这增加了对过滤器属性的支持 SCMP_FLTATR_CTL_OPTIMIZE。 将此属性设置为 2 会将过滤器转换为二分搜索程序。

如果您想了解二分搜索过滤器的工作原理,请查看 简单的脚本,它通过拨打系统调用号在BPF汇编器中生成此类程序,例如:

$ echo 1 3 6 8 13 | ./generate_bin_search_bpf.py
ld [0]
jeq #6, bad
jgt #6, check8
jeq #1, bad
jeq #3, bad
ret #0x7fff0000
check8:
jeq #8, bad
jeq #13, bad
ret #0x7fff0000
bad: ret #0

不可能以更快的速度编写任何东西,因为 BPF 程序无法执行缩进跳转(例如,我们不能这样做, jmp A или jmp [label+X])因此所有转换都是静态的。

seccomp 和 strace

大家都知道实用性 strace 是研究 Linux 上进程行为不可或缺的工具。 不过很多人也听说过 性能问题 使用此实用程序时。 事实是 strace 实施使用 ptrace(2),并且在这种机制中,我们无法指定我们需要停止进程的系统调用集,即,例如,命令

$ time strace du /usr/share/ >/dev/null 2>&1

real    0m3.081s
user    0m0.531s
sys     0m2.073s

и

$ time strace -e open du /usr/share/ >/dev/null 2>&1

real    0m2.404s
user    0m0.193s
sys     0m1.800s

尽管在第二种情况下我们只想跟踪一个系统调用,但它们的处理时间大约相同。

新选项 --seccomp-bpf添加到 strace 5.3版本,可以让你的进程加速很多倍,并且在一个系统调用的跟踪下的启动时间已经可以与常规启动的时间相媲美:

$ time strace --seccomp-bpf -e open du /usr/share/ >/dev/null 2>&1

real    0m0.148s
user    0m0.017s
sys     0m0.131s

$ time du /usr/share/ >/dev/null 2>&1

real    0m0.140s
user    0m0.024s
sys     0m0.116s

(当然,这里有一个轻微的欺骗,因为我们没有跟踪这个命令的主系统调用。例如,如果我们正在跟踪, newfsstat,然后 strace 会像没有一样用力刹车 --seccomp-bpf.)

这个选项如何运作? 没有她 strace 连接到进程并使用以下命令启动它 PTRACE_SYSCALL。 当托管进程发出(任何)系统调用时,控制权将转移到 strace,它查看系统调用的参数并使用 PTRACE_SYSCALL。 一段时间后,进程完成系统调用,退出时,控制权再次转移 strace,它查看返回值并使用以下命令启动进程 PTRACE_SYSCALL, 等等。

适合小孩子的 BPF,第 XNUMX 部分:经典 BPF

然而,使用 seccomp,这个过程可以完全按照我们想要的方式进行优化。 也就是说,如果我们只想查看系统调用 X,那么我们可以编写一个 BPF 过滤器 X 返回一个值 SECCOMP_RET_TRACE,以及我们不感兴趣的电话 - SECCOMP_RET_ALLOW:

ld [0]
jneq #X, ignore
trace: ret #0x7ff00000
ignore: ret #0x7fff0000

在这种情况下, strace 该过程最初启动为 PTRACE_CONT,我们的过滤器会针对每个系统调用进行处理,如果系统调用不是 X,那么该过程继续运行,但如果这 X,那么 seccomp 将转移控制权 strace它将查看参数并启动该过程,例如 PTRACE_SYSCALL (因为 seccomp 无法在系统调用退出时运行程序)。 当系统调用返回时, strace 将使用重新启动该过程 PTRACE_CONT 并将等待来自 seccomp 的新消息。

适合小孩子的 BPF,第 XNUMX 部分:经典 BPF

使用该选项时 --seccomp-bpf 有两个限制。 首先,不可能加入已经存在的流程(选项 -p 节目 strace),因为 seccomp 不支持这一点。 其次,没有可能 没有 查看子进程,因为 seccomp 过滤器由所有子进程继承,而无法禁用它。

关于具体如何进行的更多细节 strace 与...合作 seccomp 可以从 最近的报告。 对于我们来说,最有趣的事实是,以 seccomp 为代表的经典 BPF 至今仍在使用。

xt_bpf

现在让我们回到网络世界。

背景:很久以前,2007年,核心是 添加xt_u32 对于网络过滤器。 它是通过类比更古老的流量分类器来编写的 cls_u32 并允许您使用以下简单操作为 iptables 编写任意二进制规则:从包中加载 32 位并对它们执行一组算术运算。 例如,

sudo iptables -A INPUT -m u32 --u32 "6&0xFF=1" -j LOG --log-prefix "seen-by-xt_u32"

加载 IP 标头的 32 位(从填充 6 开始),并对它们应用掩码 0xFF (取低字节)。 这个领域 protocol IP 标头,我们将其与 1 (ICMP) 进行比较。 您可以将多项检查合并到一条规则中,还可以执行运算符 @ — 向右移动 X 个字节。 例如,规则

iptables -m u32 --u32 "6&0xFF=0x6 && 0>>22&0x3C@4=0x29"

检查 TCP 序列号是否不相等 0x29。 我不会进一步详细说明,因为很明显,手动编写此类规则不是很方便。 文章中 BPF——被遗忘的字节码,有几个链接,其中包含用法和规则生成的示例 xt_u32。 另请参阅本文末尾的链接。

自2013年起模块取代模块 xt_u32 您可以使用基于 BPF 的模块 xt_bpf。 读到这里的任何人都应该已经清楚其操作原理:按照 iptables 规则运行 BPF 字节码。 您可以创建一个新规则,例如,如下所示:

iptables -A INPUT -m bpf --bytecode <байткод> -j LOG

这里 <байткод> - 这是汇编输出格式的代码 bpf_asm 默认情况下,例如

$ cat /tmp/test.bpf
ldb [9]
jneq #17, ignore
ret #1
ignore: ret #0

$ bpf_asm /tmp/test.bpf
4,48 0 0 9,21 0 1 17,6 0 0 1,6 0 0 0,

# iptables -A INPUT -m bpf --bytecode "$(bpf_asm /tmp/test.bpf)" -j LOG

在此示例中,我们将过滤所有 UDP 数据包。 模块中 BPF 程序的上下文 xt_bpf当然,在 iptables 的情况下,指向数据包数据到 IPv4 标头的开头。 BPF 程序的返回值 布尔值哪里 false 表示数据包不匹配。

很明显,该模块 xt_bpf 支持比上面的示例更复杂的过滤器。 让我们看一下 Cloudfare 的真实示例。 直到最近他们还使用该模块 xt_bpf 以防止 DDoS 攻击。 文章中 BPF 工具简介 他们解释了如何(以及为什么)生成 BPF 过滤器,并发布了一组用于创建此类过滤器的实用程序的链接。 例如,使用实用程序 bpfgen 您可以创建一个与名称的 DNS 查询相匹配的 BPF 程序 habr.com:

$ ./bpfgen --assembly dns -- habr.com
ldx 4*([0]&0xf)
ld #20
add x
tax

lb_0:
    ld [x + 0]
    jneq #0x04686162, lb_1
    ld [x + 4]
    jneq #0x7203636f, lb_1
    ldh [x + 8]
    jneq #0x6d00, lb_1
    ret #65535

lb_1:
    ret #0

在程序中我们首先加载到寄存器中 X 行起始地址 x04habrx03comx00 在 UDP 数据报中,然后检查请求: 0x04686162 <-> "x04hab" 等等

稍后,Cloudfare 发布了 p0f -> BPF 编译器代码。 文章中 介绍 p0f BPF 编译器 他们谈论 p0f 是什么以及如何将 p0f 签名转换为 BPF:

$ ./bpfgen p0f -- 4:64:0:0:*,0::ack+:0
39,0 0 0 0,48 0 0 8,37 35 0 64,37 0 34 29,48 0 0 0,
84 0 0 15,21 0 31 5,48 0 0 9,21 0 29 6,40 0 0 6,
...

目前不再使用 Cloudfare xt_bpf,因为他们转向了 XDP——使用新版本 BPF 的选项之一,请参阅。 L4Drop:XDP DDoS 缓解措施.

cls_bpf

在内核中使用经典 BPF 的最后一个例子是分类器 cls_bpf 用于 Linux 中的流量控制子系统,于 2013 年底添加到 Linux 中,并在概念上取代了古老的 cls_u32.

不过,我们现在不会描述这项工作 cls_bpf,因为从经典 BPF 知识的角度来看,这不会给我们带来任何东西 - 我们已经熟悉了所有功能。 另外,在后续讨论扩展BPF的文章中,我们会不止一次遇到这个分类器。

不谈论使用经典 BPF c 的另一个原因 cls_bpf 问题在于,与扩展 BPF 相比,这种情况的适用范围从根本上缩小了:经典程序无法更改包的内容,也无法在调用之间保存状态。

所以是时候告别经典的 BPF 并展望未来了。

告别经典的 BPF

我们研究了 32 年代初开发的 BPF 技术如何成功地生活了四分之一个世纪,直到最后找到了新的应用。 然而,类似于从堆栈机到RISC的转变,推动了经典BPF的发展,在64年代,出现了从XNUMX位机器到XNUMX位机器的转变,经典BPF开始过时。 另外,经典BPF的能力非常有限,而且除了过时的架构——我们没有能力在BPF程序的调用之间保存状态,没有直接用户交互的可能性,没有交互的可能性与内核一起使用,除了读取有限数量的结构字段 sk_buff 并启动最简单的辅助函数,您无法更改数据包的内容并重定向它们。

事实上,目前Linux中经典的BPF仅剩下API接口,并且在内核内部所有经典程序,无论是socket过滤器还是seccomp过滤器,都会自动转换为新的格式,即扩展BPF。 (我们将在下一篇文章中详细讨论这是如何发生的。)

向新架构的过渡始于 2013 年,当时 Alexey Starovoitov 提出了 BPF 更新方案。 2014年对应补丁 开始出现 在核心。 据我了解,最初的计划只是优化架构和 JIT 编译器,以便在 64 位机器上更高效地运行,但这些优化却标志着 Linux 开发新篇章的开始。

本系列的其他文章将涵盖新技术的架构和应用,最初称为内部 BPF,然后是扩展 BPF,现在简称为 BPF。

引用

  1. Steven McCanne 和 Van Jacobson,“BSD 数据包过滤器:用户级数据包捕获的新架构”, https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne,“libpcap:数据包捕获的架构和优化方法”, https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf
  3. tcpdump, libpcap: https://www.tcpdump.org/
  4. IPtable U32 匹配教程.
  5. BPF - 被遗忘的字节码: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. BPF工具简介: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. seccomp 概述: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr:容器和安全性:seccomp
  11. habr:使用 systemd 隔离守护进程或者“你不需要 Docker!”
  12. Paul Chaignon,“strace --seccomp-bpf:深入了解”, https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

来源: habr.com

添加评论