使用 eBPF-TC 在内核中安全地改变数据包 介绍 eBPF 允许你在 OS 内核中安全地运行沙箱程序,用于安全性和网络等功能,而无需修改内核源代码或加载内核模块。eBPF-TC 具有牢固的数据包处理能力,并支持入口和出口操作,而且性能很高。 这是我使用 TC-eBPF 构建插件 TPROXY 截流防火墙 (IFW) 来截取并将数据包发送到我的目标应用程序 OpenZiti Edge Routers 的方法。你可以使用类似的 eBPF-TC 实现来截取要发送到你特定可观察性、安全性或网络应用程序的数据包。代码和 README.md 在这里:GitHub - r-caamano/ebpf-tproxy-splicer。流程图和数据流图在本文末尾。 项目背景 - 将数据包发送到 OpenZiti 端点 OpenZiti(开源零信任网络平台)能够在完整网络覆盖网络中建立私有连接。Ziti 端点作为代码嵌入在应用程序中(通过 Ziti SDK),并作为 OS 代理、守护程序、容器或虚拟机部署。你可以在你的私有网络中启动任意数量的 Ziti 路由器 - 如果你部署了两个路由器,它看起来像这样: Ziti Edge 路由器通常在 Linux 上使用,默认使用 iptables 将传入的感兴趣流量映射到服务侦听端口,使用 IP 表 Tproxy 目标。如果你正在运行 Ubuntu 和 FWD,这就很好用了。然而,还有许多带有关键差异的 Linux 发行版。因此,我使用 TC-eBPF 为支持 eBPF 的 Linux 发行版构建了更通用的选项,使这些发行版能够截取感兴趣的流量。eBPF 特性集还提供了 iptables/nftables 原生不支持的附加数据包过滤和操作。 TC-eBPF IFW - tproxy 目标条目 因此,在开始构建 eBPF IFW 之前,我需要反向工程 OpenZiti 边缘路由器如何将服务本地转换为 iptables 规则 - 基于路由器从 OpenZiti Controller 学习的服务,需要添加/删除哪些 TPROXY 目标语句。以下是通过 iptables 创建 tproxy 目标条目所使用的信息。 IP Destination Prefix: Dotted Decimal IP/mask bit-length TCP/UDP port range in the format Decimal Low_Port:High_Port Protocol: TCP/UDP TPROXY Listening port: Decimal port TC-eBPF IFW - 插入和映射 现在,有了这些信息,我可以开始考虑我将使用的 eBPF map 类型和结构,以便在用户空间映射工具和 IFW 之间进行通信,以动态更新规则。 由于我希望我的程序的功能与 ufw/iptables 类似,所以我选择了 TC-eBPF 作为我的插入点,因为它在接口级别的组合(在转发到 Linux IP 栈之前丢弃数据包的能力)和当前可用的 sk 帮助程序(用于 socket 查找/拼接)。 为了检查传入的数据包是否与 Ziti 网络管理员创建的截获策略(Ziti 特别定义了流量,而不是默认截获所有流量)匹配,我需要一个支持结构体键类型的固定映射。eBPF 哈希映射允许使用结构体作为键,这使我能够自定义查找,并随着用例的发展添加或删除标准。使用固定映射允许多个 ebpf 程序的副本运行(每个入站接口上运行一个),并共享由映射工具/Ziti 更新的映射。 下图描述了我选择的初始映射定义: struct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(id, BPF_MAP_ID_IFINDEX_IP); __uint(key_size, sizeof(uint32_t)); __uint(value_size, sizeof(struct ifindex_ip4)); __uint(max_entries, 50); __uint(pinning, LIBBPF_PIN_BY_NAME); } ifindex_ip_map SEC(".maps"); The initial key I chose a struct of the following form: struct tproxy_key { __u32 dst_ip; __u16 prefix_len; __u16 protocol; } 这些数据结构允许基于目的 IP 前缀、CIDR 长度和 IP 协议(tcp/udp)进行查找,这些都可以从传入的数据包中推断出来。 对于值,我使用了以下格式的结构体: struct tproxy_tuple { __u16 index_len; /*tracks the number of entries in the index_table*/ __u16 index_table[MAX_INDEX_ENTRIES]; /* Array used as index table which points to Struct *tproxy_port_mapping in the port_maping array with each poulated index representing a udp or tcp tproxy mapping in the port_mapping */ struct tproxy_port_mapping port_mapping[MAX_TABLE_SIZE]; /* Array to store unique tproxy mappings with each index match the low_port of the struct tproxy_port_mapping{ __u16 low_port; __u16 high_port; __u16 tproxy_port; __u32 tproxy_ip; } */ } 由于 OpenZiti 基于 IP 的服务策略可以在任何粒度级别定义,包括网络 CIDR 块,所以我不希望为包含在块中的每个主机地址生成哈希条目。因此,我实现了一种最长匹配查找算法,该算法逐次扩大掩码检查,以查看传入的 IP 元组是否直接匹配主机地址,或者是否在与 tproxy_key 中的 ip_dest/prefix_len 字段匹配的 CIDR 块范围内,以及匹配 IP 运输协议(TCP 或 UDP)。 struct tproxy_tuple *tproxy __u32 exponent=24; /* unsugend integer used to calulate prefix matches */ __u32 mask = 0xffffffff; /* starting mask value used in prefix match calculation */ __u16 maxlen = 32; /* max number ip ipv4 prefixes */ for (__u16 count = 0;count <= maxlen; count++){ struct tproxy_key key = {(tuple->ipv4.daddr & mask), maxlen-count,protocol} if ((tproxy = get_tproxy(key))){ { Redacted for brevity} /*algorithm used to calucate mask while traversing each octet.*/ if(mask == 0x00ffffff){ exponent=16; } if(mask == 0x0000ffff){ exponent=8; } if(mask == 0x000000ff){ exponent=0; } if(mask == 0x00000080){ return TC_ACT_SHOT; } if((mask >= 0x80ffffff) && (exponent >= 24)){ mask = mask - (1 << exponent); }else if((mask >= 0x0080ffff) && (exponent >= 16)){ mask = mask - (1 << exponent); }else if((mask >= 0x000080ff) && (exponent >= 8)){ mask = mask - (1 << exponent); }else if((mask >= 0x00000080) && (exponent >= 0)){ mask = mask - (1 << exponent); } exponent++; } 此外,由于 Ziti 最终用户可以将任意数量的端口范围与协议基础上的目标关联,在这第一次尝试中,我不希望为每个端口创建哈希映射条目,因为可能存在大的端口范围,例如 1-65535。相反,我在索引表中创建了一个条目,其中每个条目都指向 port_mapping 表中已填充的数组索引,其中索引是映射规则的 low_port 值。这将查找端口匹配的搜索限制为仅已填充的端口范围条目,而不是直接在端口映射表中的顺序索引搜索。我计划测试为每个成员端口创建哈希映射条目与范围起始的索引查找的性能和资源限制。下面是一个用于基于传入的 tuple->ipv4.dport 查找匹配的代码片段。 for (int index = 0; index < max_entries; index++) { /* set port_key equal to the port value stored at current Index */ int port_key = tproxy->index_table[index]; /* check if tuple destination port is greater than low port and lower than high port at mapping[port_key] if matched get associated tproxy port and attempt to find listening socket if successfull jump to assign: */ if ((bpf_ntohs(tuple->ipv4.dport) >= bpf_ntohs(tproxy->port_mapping[port_key].low_port)) && (bpf_ntohs(tuple->ipv4.dport) <=bpf_ntohs(tproxy>port_mapping[port_key].high_port))){ If(local){ /* if tuple->daddr == router’s ip then forward to stack */ return TC_ACT_OK; } /* construct tuple to used to lookup TPROXY sk */ sockcheck.ipv4.daddr = tproxy->port_mapping[port_key].tproxy_ip; sockcheck.ipv4.dport = tproxy->port_mapping[port_key].tproxy_port; /* look up sk based on protocol in map key */ if(protocol == 6){ sk = bpf_skc_lookup_tcp(skb, &sockcheck, sizeof(sockcheck.ipv4), BPF_F_CURRENT_NETNS, 0); }else{ sk = bpf_sk_lookup_udp(skb, &sockcheck, sizeof(sockcheck.ipv4), BPF_F_CURRENT_NETNS, 0); } if(!sk){ return TC_ACT_SHOT; } if((protocol == IPPROTO_TCP) && (sk->state != BPF_TCP_LISTEN)){ bpf_sk_release(sk); return TC_ACT_SHOT; } goto assign; } } assign: /*attempt to splice the skb to the tproxy or local socket*/ ret = bpf_sk_assign(skb, sk, 0); /*release sk*/ if(ret == 0){ //if succedded forward to the stack return TC_ACT_OK; } /*else drop packet if not running on loopback*/ if(skb->ingress_ifindex == 1){ return TC_ACT_OK; }else{ return TC_ACT_SHOT; } } TC-eBPF – 有状态防火墙 由于我希望 eBPF 执行有状态防火墙的功能(至少在对于从该主机接收的已确认数据包必须有一个活动的出站会话到主机的程度上),我需要考虑如何使程序管理 UDP 和 TCP 的会话状态。最初我认为这可能很复杂。然而,在使用用于拼接套接字的 ebpf 帮助程序时,我意识到可以使用相同的帮助程序来检查是否已发起了出站套接字。在传入数据包元组与现有出站会话匹配的情况下,拼接传入的 skb 到现有的 sk 中,同时执行与程序已经为 openziti 服务 tproxy sk(s) 执行的相同查找。下面的代码摘录显示了我用于 TCP 的基本状态检查代码: /* if tcp based tuple implement statefull inspection to see if they were initiated by the local OS if not pass on to tproxy logic to determin if theopenziti router has tproxy intercepts defined for the flow */ sk = bpf_skc_lookup_tcp(skb, tuple, tuple_len,BPF_F_CURRENT_NETNS, 0); if(sk){ if (sk->state != BPF_TCP_LISTEN){ goto assign; } bpf_sk_release(sk); } assign: /*attempt to splice the skb to the tproxy or local socket*/ ret = bpf_sk_assign(skb, sk, 0); /*release sk*/ if(ret == 0){ //if succeeded forward to the stack return TC_ACT_OK; } /*else drop packet if not running on loopback*/ if(skb->ingress_ifindex == 1){ return TC_ACT_OK; }else{ return TC_ACT_SHOT; } } TC-eBPF IFW – 入站 SSH 将事情进一步,我希望默认情况下允许 ssh 入站,但是希望将 ssh 限制为 eBPF 程序附加的外部接口的 IP 地址。但是,在 TC 级别运行 eBPF 时,您无法访问接口的 IP 地址。当使用标准端口在 Ziti 上使用 ssh 服务时,具有此功能是必不可少的,因为服务还将具有目标端口 TCP/22。因此,如果无法分辨路由器的 IP,则程序将无法知道是否应转发到 Linux 栈或 Ziti Tproxy 服务端口。为了解决此问题,我最初允许 ssh 传递到任何地址,然后让我的用户空间 map_update 工具通过数组映射告诉 eBPF 其附加的 IP /* Array Map */ struct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(id, BPF_MAP_ID_IFINDEX_IP); __uint(key_size, sizeof(uint32_t)); __uint(value_size, sizeof(struct ifindex_ip4)); __uint(max_entries, 50); __uint(pinning, LIBBPF_PIN_BY_NAME); } ifindex_ip_map SEC(".maps"); /*value to ifindex_ip_map*/ struct ifindex_ip4 { __u32 ipaddr; char ifname[IF_NAMESIZE]; }; static inline struct ifindex_ip4 *get_local_ip4(__u32 key){ struct ifindex_ip4 *ifip4; ifip4 = bpf_map_lookup_elem(&ifindex_ip_map, &key); return ifip4; } /*look up attached interface IP address*/ struct ifindex_ip4 *local_ip4 = get_local_ip4(skb->ingress_ifindex); /* if ip of attached interface found in map only allow ssh to that IP */ if(((!local_ip4) || (!local_ip4->ipaddr)) || (tuple->ipv4.daddr == local_ip4->ipaddr)){ if((bpf_ntohs(tuple->ipv4.dport) == 22)){ return TC_ACT_OK; } } UDP 遵循相同的基本原则,但我需要特别为 DHCP 做一个例外,因为它的操作方式使状态检查困难(像 UFW 这样的其他有状态 FW 也做出了相同的入站例外) /* forward DHCP messages to local system */ if(udp && (bpf_ntohs(tuple->ipv4.sport) == 67) && (bpf_ntohs(tuple->ipv4.dport) == 68)){ return TC_ACT_OK; } eBPF 用户空间集成 我创建了两个用户空间应用 map_update.c 和 map_delete_elem.c,它们更新了前面描述的固定映射。Map update 创建填充带有附加的 eBPF 程序的接口的 IP 地址/名称,并使用以下模式插入 nf_tproxy_map 的规则: Usage:./map_update <ip dest address or prefix> <prefix length> <low_port> <high_port> <tproxy_port> <protocol id> sudo ./map_update 172.16.240.0 24 5060 5060 58997 17 Usage: ./map_delete_elem <ip dest address or prefix> <prefix len> <low_port> <protocol id> sudo ./map_delete_elem 172.16.240.0 24 5060 17 将这些用户空间应用编译并放置在 ziti-edge 路由器上的可执行路径中,然后在 https://github.com/openziti/edge 项目中修改 tunnel/intercept/tproxy/tproxy_linux.go。修改允许 OpenZiti edge-router 动态地调用系统,更新由 IFW 使用的固定 BPF nt_trproxy_map,以进行转发决策。当对任何服务进行添加或删除操作时,都会动态地调用系统调用。可以在以下存储库中访问修改后的边缘:https://github.com/r-caamano/edge/tree/v0.26.10 以下是从修改后的 edge-router 代码生成的日志输出,当初始学习 Ziti 控制器上的服务时。您可以看到,它调用 map-update 用户空间程序的系统调用,将学到的服务作为 bpf 映射的哈希映射条目添加。 [ 3.913] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: dst_ip=172.16.1.0/28 [ 3.913] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: protocol=tcp [ 3.914] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: low port=30000, highport=40000 [ 3.914] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: tproxy_ip=127.0.0.1 [ 3.914] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: tproxy_port=33401 [ 3.915] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: dst_prefix=172.16.1.0 [ 3.917] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: Updated ebpf zt_tproxy_map: map_update 172.16.1.0 28 30000 40000 33401 6 Adding TCP mapping lookup success [ 3.918] INFO edge/tunnel/intercept.(*ServiceListener).HandleServicesChange: {service=[TESTSVC]} adding service [ 3.918] INFO edge/tunnel/intercept.(*ServiceListener).addService: starting tunnel for newly available service TESTSVC [ 3.919] INFO edge/tunnel/intercept/tproxy.(*interceptor).newTproxy: tproxy listening on udp:127.0.0.1:39250, remoteAddr: <nil> [ 3.919] INFO edge/tunnel/dns.(*resolver).AddHostname: adding test.dns.ziti = 100.64.0.2 to resolver [ 3.920] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: dst_ip=100.64.0.2/32 [ 3.920] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: protocol=udp [ 3.921] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: low port=5000, highport=10000 [ 3.921] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: tproxy_ip=127.0.0.1 [ 3.921] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: tproxy_port=39250 [ 3.922] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: dst_prefix=100.64.0.2 [ 3.924] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: Updated ebpf zt_tproxy_map: map_update 100.64.0.2 32 5000 10000 39250 17 Adding UDP mapping TC-eBPF IFW – 数据流图 总结: 本文描述了我如何利用 eBPF-TC 构建插件 TPROXY IFW,将流量引导到我的目标应用程序。希望您发现我分享的经验有用。很想听听其他 eBPF 开发人员如何使用这种强大的功能。 原文地址: [Using eBPF-TC to securely mangle packets in the kernel, and pass them to my secure networking application]( 2023-01-07 0 0
使用eBPF实现基于DWARF的堆栈遍历 译者注: 原文为https://www.parca.dev/的Javier Honduvilla Coto,文章地址DWARF-based Stack Walking Using eBPF,副标题Deep dive into the new DWARF-based stack unwinder using eBPF,即深度解析使用eBPF基于DWARF的栈展开。 文章为机翻,以及人工矫正。译者水平有限,有疑问请看原文。 parca项目介绍 parca是一款基于eBPF技术实现的CPU、内存观测产品。可以持续分析 CPU 和内存使用情况产品,细化到行号和整个时间。 节省基础架构成本、提高性能并提高可靠性。 GitHub地址:https://github.com/parca-dev/parca 原文: 采样CPU分析器周期性地获取目标进程的堆栈,比如用C、C++、Rust等语言编写的进程,可能比大家想象的要复杂一些。最普遍的问题是因为缺少frame pointer帧指针。 我们已经开发了一个改进的堆栈遍历器 :Parca continuous profiling Agent,即使在省略了帧指针的情况下也能工作。 x86_64中的堆栈 x86_64架构除了描述它的指令集和其他一些重要特征外,还定义了数据在其应用二进制接口或简称ABI中应如何布局的规则。该规范显示了该架构下的堆栈应该如何设置。 当这段代码被执行时,不同的call指令将把返回地址压入堆栈中。一旦函数返回,CPU将读取返回地址并跳转到该地址,继续执行该函数的调用点。 由于没有额外的信息,不可能可靠地生成堆栈跟踪。可能有其他的值,比如函数的本地数据,被存储在堆栈中,可能看起来像函数地址。这就是帧指针要解决的问题。 Can I have a (frame) pointer? 如下伪代码为例,假设没有编译器优化 int top(void) { for(;;) { } } int c1(void) { top(); } int b1(void) { c1(); } int a1(void) { b1(); } int main(void) { a1(); } 使用这方法来遍历堆栈,我们需要保留指向上一帧的指针。 在x86体系结构中,这通常在帧指针$rbp中。 由于函数可能调用其他函数,因此必须在函数进入时存储该寄存器,并在函数退出时恢复该寄存器。 这是通过所谓的function prologue函数序言来实现的,在函数入口处,它可能看起来像这样 push $rbp # saves the stack frame pointer mov $rbp, $rsp # sets the current stack pointer to the frame pointer function epilogue函数序言在函数返回处 pop $rbp # restores the function's frame pointer ret # pops the saved return address and jumps to it 如果我们用帧指针编译并运行上面的C代码,堆栈将具有遍历堆栈所需的所有信息。 有效地调用不同的函数会创建一个需要遍历的链表。 反汇编使用帧指针编译的代码 # compiled with `gcc sample.c -o sample_with_frame_pointers -fno-omit-frame-pointer` $ objdump -d ./sample_with_frame_pointers 0000000000401106 <top>: 401106: 55 push %rbp 401107: 48 89 e5 mov %rsp,%rbp 40110a: eb fe jmp 40110a <top+0x4> 000000000040110c <c1>: 40110c: 55 push %rbp 40110d: 48 89 e5 mov %rsp,%rbp 401110: e8 f1 ff ff ff call 401106 <top> 401115: 90 nop 401116: 5d pop %rbp 401117: c3 ret 0000000000401118 <b1>: 401118: 55 push %rbp 401119: 48 89 e5 mov %rsp,%rbp 40111c: e8 eb ff ff ff call 40110c <c1> 401121: 90 nop 401122: 5d pop %rbp 401123: c3 ret 0000000000401124 <a1>: 401124: 55 push %rbp 401125: 48 89 e5 mov %rsp,%rbp 401128: e8 eb ff ff ff call 401118 <b1> 40112d: 90 nop 40112e: 5d pop %rbp 40112f: c3 ret 0000000000401130 <main>: 401130: 55 push %rbp 401131: 48 89 e5 mov %rsp,%rbp 401134: e8 eb ff ff ff call 401124 <a1> 401139: b8 00 00 00 00 mov $0x0,%eax 40113e: 5d pop %rbp 40113f: c3 ret 上面的例子代码中的native stack的内容是在顶部函数运行时用帧指针编译的 要遍历堆栈,我们必须遵循上面生成的链表,读取每个保存的$rbp之前压入的值,这将使我们的堆栈帧,直到$rbp为零,意味着已经到达了堆栈的末尾。 这很好,因为它允许我们以很低的成本计算出堆栈跟踪。 对于编译器实现者来说,添加它也相对容易,而且一般来说,只需要相当少量的周边基础设施就可以使它工作。 尽管有这些优点,但我们所依赖的许多代码并不是用帧指针编译的。 我们中的许多人依赖于我们的Linux发行版应用程序和库,其中绝大多数选择省略帧指针。 即使你使用框架指标编译程序,动态或静态链接你的发行版本所提供的任何库,也可能会让你无法仅使用帧指针来正确展开堆栈。 我们不会深入探讨在某些环境中禁用帧指针的原因以及与之相关的细微差别,但我们认为必须逐个应用地对它们的开销进行基准测试。 禁用帧指针带来的成本也应该考虑在内。 反汇编不使用帧指针编译的代码 # compiled with `gcc sample.c -o sample_without_frame_pointers -fomit-frame-pointer` $ objdump -d ./sample_without_frame_pointers [...] 0000000000401106 <top>: 401106: eb fe jmp 401106 <top> 0000000000401108 <c1>: 401108: e8 f9 ff ff ff call 401106 <top> 40110d: 90 nop 40110e: c3 ret 000000000040110f <b1>: 40110f: e8 f4 ff ff ff call 401108 <c1> 401114: 90 nop 401115: c3 ret 0000000000401116 <a1>: 401116: e8 f4 ff ff ff call 40110f <b1> 40111b: 90 nop 40111c: c3 ret 000000000040111d <main>: 40111d: e8 f4 ff ff ff call 401116 <a1> 401122: b8 00 00 00 00 mov $0x0,%eax 401127: c3 ret [...] 二者差异 top: - push %rbp - mov %rsp,%rbp jmp 40110a <top+0x4> c1: - push %rbp - mov %rsp,%rbp call 401106 <top> nop - pop %rbp ret b1: - push %rbp - mov %rsp,%rbp call 40110c <c1> nop - pop %rbp ret a1: - push %rbp - mov %rsp,%rbp call 401118 <b1> nop - pop %rbp ret main: - push %rbp - mov %rsp,%rbp call 401124 <a1> mov $0x0,%eax - pop %rbp ret 假设我们在分析c1的执行过程,堆栈可能如下所示: top运行时上述代码的native stack的内容 我们需要一些其他信息或硬件支持,以便能够可靠地展开堆栈。 硬件方法 我们可以使用一些硬件设施来展开堆栈,例如Intel的Last Branch Record (LBR).LBR产生起始地址和目的地址对FROM_IP和TO_IP。我们可以使用它们来建立堆栈跟踪。 他的缺点就是他能记录的深度有限。 虽然 LBR 用途广泛且功能强大,但我们决定不将其用于 CPU 分析,因为并非每个虚拟化环境都提供此功能,而且它是英特尔特有的。这些缺点延伸到其他感兴趣的供应商特定的处理器特征,例如 Intel Processor Trace (PT) 一场特殊的邂逅 你可能会想,我怎么可能编译没有帧指针的C++应用程序,并且异常仍然工作得很好? 那么Rust呢?在Rust中,默认情况下帧指针是禁用的,但是调用panic() 会显示完整且正确的堆栈跟踪。 为了使C++异常无论二进制代码是如何编译的都能工作,以及添加一些其他必要的工具来使它们发挥作用,编译器可以发出一些元数据来指示如何展开堆栈。 该信息提供程序计数器到关于如何恢复所有寄存器的指令的映射。 更多详情参见 DWARF debugging information format 和 x86_64 ABI两篇文章。 DWARF’s Call Frame Information (CFI) CFI的主要目标是提供如何在我们代码执行的任何部分恢复前一帧的每个寄存器的答案。直接存储一个包含每个程序计数器和所有寄存器及其位置(例如它们是否已被压入堆栈)的表,将生成巨大的展开表。 因此,此格式尽量紧凑,仅包含所需的信息。 它使用各种技术来达到此效果,例如 使用 LEB128实现数字的可变长度压缩 使用状态机进行数据压缩。 这一点很重要,因为它允许以增加复杂性为代价来实现数据的非常简洁的表示 回溯表以CFI格式编码,采用我们需要计算的操作码的形式。 它有两个主要的层。第一层是在VM中编码的状态机。 这有助于压缩性能良好的重复模式,并允许更紧凑地表示某些数据,因为在某些情况下,有一个专门的操作码占用1、2或4个字节,而不是一直使用4个字节。 未压入堆栈的寄存器可能不会出现在表中。 我称之为第二层的是一个特殊的操作码,它包含另一组操作码,包含我们需要计算的任意表达式。 这两个层次之间的主要区别是,对于第一层次,我们只需要一个堆栈来记忆和恢复寄存器(分别为DW_CFA_remember_state和DW_CFA_restore_state),对于第二层,我们需要计算任意图灵完整表达式。 因此,我们需要一个成熟的VM来计算任何表达式。 在BPF中实现VM并不太实际,因此我们决定采用一种实用的方法,首先对2个表达式进行硬编码,这2个表达式在我们评估的大多数二进制文件中出现的次数超过50%。 我们有一些关于如何进一步改进表达式支持的想法,但是这篇博客文章已经太长了。 使用DWARF CFI遍历堆栈 要使用此方法遍历给定程序计数器(PC)的堆栈,我们需要找到其相应的展开信息。 但是,我们所说的展开信息到底是什么意思呢? 我们需要恢复: 已保存的返回地址 前一帧的栈指针($rsp)和帧指针($rbp)寄存器的值,用于恢复前一帧的栈指针 前一帧的堆栈指针的值,就在我们当前函数被调用之前,在 DWARF 的 CFI 术语中,称为规范帧地址或 CFA。 正如我们之前看到的,在 x86_64 中,保存的返回地址总是在 CFA 之前 8 个字节(一个字)。 展开算法看起来像这样 读取初始寄存器 指令指针 $rip。 需要在展开表中查找行。 堆栈指针$rsp和帧指针$rbp,用于计算前一帧的堆栈指针值 (CFA)。 我们可以在 CFA 的偏移量处找到压入堆栈的返回地址和其他寄存器。 While循环 unwind_frame_count <= MAX_STACK_DEPTH: 找到 i 满足的 PC 的展开表行$unwind_table[i].PC <= $target_PC <= $unwind_table[i+1].PC。 如果没有记录并且 $rbp 为零,我们就到达了堆栈的底部。 将指令指针添加到堆栈。 计算前一帧的栈指针。 这可以基于当前帧的 $rsp 或 $rbp,如果它不是表达式或直接注册。 用前一帧的计算值更新寄存器。 继续下一帧。 转到2。 Note: 为简单起见,我们省略了我们的展开器实现的一些重要细节. 为此,我们需要读取回溯操作码,对它们求值,然后生成表。 这个过程可能会非常昂贵,但它是由C++中的异常处理基础结构完成的。 因为异常应该是,呃,异常,所以这些代码路径不应该经常使用,开销也不会太高。 对于调试器(如GDB)也是如此,在调试器中,用户可能希望了解各处的堆栈跟踪,以了解他们在执行中的位置。 这些用例有时被归类为离线展开。 分析器有点不同,因为它们通常每秒对堆栈进行数十次或数百次采样。必须读取、解析和评估展开信息的开销可能非常高。虽然堆栈展开器可能会做一些缓存,但整个过程仍然非常昂贵。 在我们的案例中,一个关键的观察结果是,我们不需要恢复每个寄存器,我们只需要这2个寄存器和保存的返回地址。 这种洞察力使我们能够生成一种更适合在线展开用例的表示。 可能的实现 我们开发的分析器并不是第一个使用这种技术的分析器。 Perf是一个古老的Linux分析器,它支持基于DWARF的堆栈展开已经有一段时间了。 通过利用Linux 3.4中的perf_event_open系统调用中引入的PERF_SAMPLE_REGS_USER和PERF_SAMPLE_STACK_USER,它可以接收所分析进程的寄存器以及每个样本的堆栈副本。 虽然这种方法已经被证明是可行的,并且我们对实现我们的分析器进行了类似的评估,但它有一些我们希望避免的缺点: 性能:内核将为每个样本复制用户堆栈。 它会复制当前正在使用的用户堆栈,但这可能是相当多的数据。 假设非常保守的情况是:每栈1 K * 每秒100个样本 * CPU运行时间的30%* 10个CPU =每秒300 KB。 安全性:使另一个进程具有另一个进程的堆栈的值的含义可能是复杂的。 如果那里存在一些私钥或任何种类的个人身份信息(PII)怎么办? 虽然300 KB/s的数据量看起来并不多,但我们认为,对于运行CPU密集型应用程序的繁忙机器来说,这个数字可能会高得多。 我们希望通过在分析器运行时减少测量的影响,减少专用于分析器的资源,否则应用程序可能会使用这些资源。 另一个突然出现在我们脑海中的想法是复制BPF程序中的堆栈,但这仍然有我们希望避免的缺点,我们必须重新实现内核已经拥有的功能,而且它被证明工作得很好! 这就引出了我们最终采用的方法,仍然利用BPF! Why BPF? 我们是 BPF 的忠实信徒,有很多原因。 从广义上讲,它允许 Linux 内核以更高的安全保证和更低的学习门槛进行编程。 在 BPF 中开发分析器非常有意义,因为一旦实现了堆栈遍历机制,我们就可以利用 perf 子系统来获取有关 CPU 周期、指令、L3 缓存未命中或我们机器中可用的任何其他性能计数器的样本。 它还有助于开发其他工具,如分配跟踪器、off-CPU分析器等。 你可能会想,为什么要在BPF中讨论堆栈展开? 使用bpf_get_stackid(ctx,& map,BPF_F_USER_STACK) helper,我们可以获取用户堆栈!事实上,这个助手使用帧指针遍历堆栈,而一个功能齐全的 DWARF 展开器不太可能登陆内核 展开表的BPF友好表示形式 大多数离线堆栈展开器不会处理大部分 DWARF CFI 信息,因为它们只针对很少的程序计数器。 另一方面,分析器可能会产生更高的程序计数器基数。 出于这个原因,事实上我们只需要数据的一个子集来遍历堆栈,因为我们不需要知道如何恢复每个寄存器,也不需要生成一些表示来最小化 BPF 的工作 unwinder 必须做,我们决定预先承担 unwind 表生成成本。 在用户空间中,我们首先解析、计算和生成回溯表。 到目前为止,我们只支持存储在.eh_frame ELF段中的信息。 生成的表是由以下行类型构建的几个数组: typedef struct { u64 pc; u16 _reserved_do_not_use; u8 cfa_type; u8 rbp_type; s16 cfa_offset; s16 rbp_offset; } stack_unwind_row_t; 我们使用一个完整的字作为程序计数器,然后有几个字段帮助我们计算CFA。 例如,它可以与当前的$rsp或$rbp有一定的偏移量。 我们还将了解如何恢复$rbp。 使用上述算法,我们通过恢复前一帧的寄存器来遍历帧。 该表按程序计数器排序,以便能够在BPF程序中对该表进行二进制搜索。 当我们找不到给定PC的回溯信息,且当前的$rbp为零时,我们就完成了对堆栈的遍历。 然后,我们在内核空间的BPF map中聚合堆栈,以最大限度地提高效率,我们每分钟从用户空间收集两次数据,在用户空间生成配置文件并将其发送到Parca Server兼容的服务器。 开发 在我们开始这个项目的早期,我们就意识到有很多变量会影响它的成功。据我们所知,没有其他功能完整的、开源的、基于 DWARF 的 BPF 展开器,所以我们不确定它的可行性。 因此,为了最大限度地提高我们的成功机会,我们试图大幅减少问题空间,同时仍给我们尽可能多的信号。 在Polar Signals,我们为每一个我们想要讨论或获得反馈的大功能或主题创建了一个征求意见(RFC)。对于这项工作,我们从一个文档开始,列出了我们希望在第一次迭代中实现的目标,包括目标,更重要的是,非目标。 经过几个星期的工作,我们得到了第一个版本,它专注于正确性。 我们继续进行后续工作,以放宽最低内核要求(kernel ~4.10),并使展开表行更加紧凑。 在BPF中构建这是一个有趣的挑战。 内核必须确保任何加载的程序都不会使其崩溃,它使用BPF 验证器静态分析代码,验证器将拒绝或接受程序。 在写这篇文章的时候,一些当前的规则是,没有动态分配,终止必须是可证明的,以及许多其他的,所以我们必须有创造性地让验证器接受我们的程序。 这篇文章太长了,所以这将是另一个故事。 Testing测试 为了让展开器工作,展开表和 BPF 中实现的展开算法都必须运行良好。 确保table正确是该项目开发的重中之重。 在这种情况下,我们很早就决定以非常简单的形式使用快照测试。 我们在一个单独的git仓库中有一些测试二进制文件和预期的回溯表。 作为Agent中测试套件的一部分,我们重新生成表并确保没有任何更改。 这种技术允许我们快速迭代DWARF展开信息解析器,帮助我们找到大量的bug,并节省了大量的时间,否则我们将花费大量的时间来试图理解为什么我们在遍历堆栈时失败。 未来工作 我们正在修复、研发很多功能,将很快与您分享! 我们几天前才发布了第一个版本,其中包括基于DWARF的堆栈展开。 但是我们已经做了一些更多的改进,以确保分析器在内存受限的机器上运行良好,改进了体系结构,更好地支持JIT代码,等等。 近期,我们将重点转向可靠性、性能和更广泛的支持。 DWARF的回溯信息的解析、求值和处理尚未优化。 我们还希望确保我们的分析器具有详细的性能指标。 最后,我们希望对Clang和Rust编译器工具链生成的表进行更详尽的测试。 这个项目的最终目标是在默认情况下为我们的所有用户启用这个分析器,而不会导致明显更高的资源使用率。 试用 如上所述,这个新特性是在一个特性标志后面,但是我们将在下一个版本中默认启用它,一旦我们获得了一些改进,你可下载parca-agent当前的版本来体验。 $ ./parca-agent [...] --experimental-enable-dwarf-unwinding \ --debug-process-names="(postgres|mysql|redpanda)" 与社区合作 我们认为,帧指针的普遍缺乏对于应用程序开发人员以及分析器、调试器和编译器的开发人员来说是一个大问题。 幸运的是,这个问题空间正在被更广泛的工程社区的许多成员积极地研究,比如在Fedora中默认启用帧指针的提议,或者the .ctf_frame work,这是一种DWARF展开的代替格式,专门为分析器和其他工具需要的在线、异步(意味着可以解旋任何程序计数器,而不仅仅是从特定部分)用例定制。 开源和与其他社区合作是我们公司精神的重要组成部分。 这就是为什么我们很早就开始谈论这个项目,从去年9月的 Linux Plumbers talk开始,在那里我们宣布了这项工作。 我们的unwinder是根据 GPL 许可证获得许可的。它开放供检查和贡献,我们很乐意与面临类似问题的其他项目合作。不要犹豫,伸出手!如果您希望在我们的Discord 或 GitHub discussion中看到任何反馈或功能,请告诉我们。 致谢 如果没有许多人的努力,这项工作是不可能完成的。 感谢如下项目。 Go 的 Delve 调试器,我们的 DWARF 展开信息解析器基于它。 Ian Lance Taylor 的 关于 .eh_frame 的博客系列。 MaskRay 的博客 包含有趣的编译器和链接器内容。 参与 DWARF 标准和不同 ABI 的工程师。想出如此灵活的东西并不容易。开放和详细的规范是一个很好的资源。 编译器工程师是这项工作的无名英雄。创建展开表并不容易。在编译过程中保持它们同步是一项艰巨的任务。 如果没有 BPF 及其周围的生态系统,我们将无法安全地创建可编程的系统级分析器。虽然有时验证者可能很强硬,但它是我们最好的盟友。 可靠且快速的基于 DWARF 的堆栈展开论文 对我们在这篇文章中描述的所有系统提供了极好的描述,并尝试了一些不同的非 BPF 方法来加速基于 dwarf 的展开器,他们进行的一些正确性测试发现了几个错误。我们欠他们的不仅是提高许多系统(包括我们的系统)所依赖的展开表的质量,而且还帮助提高了对所有这些系统及其重要性的认识。 杂谈 虽然术语stack walking堆栈遍历在分析器的上下文中更为正确,而stack unwinding堆栈展开通常在运行时处理异常时使用,但我们可以互换使用这两个术语。 我们的表格式使用了我们可以使用的最小数据类型,它对偏移量的最小值和最大值等设置了一些限制。您可以在本设计文档中查看它们,其中还包括一些unwinder的要求。我们已经在努力消除上面提到的限制 Reliable and fast DWARF-based stack unwonding一文中的一个非常有趣的想法是,当展开表由于某种原因不存在或不完整时,从目标代码合成展开表。这是我们将来可能会考虑的事情。 为了增加对复杂性影响的一些见解,仅就代码大小而言,之前基于帧指针的展开器可以在不到 50 行的 BPF 中重新实现,而这个基于 dwarf 的展开器超过 500 行。这排除了用户空间和测试中所有必要的支持代码。 最后但并非最不重要的一点是,不要把这项工作当作删除帧指针的道歉!如果我们可以改变计算行业中的一些技术和低级别的东西,它可能会默认启用帧指针。这是 Facebook 和谷歌等超大规模企业已经在做的事情,尽管可能会产生额外的计算成本,因为在故障排除的每一分钟都需要花费大量金钱的情况下,它们可以节省他们的头痛和时间。话虽如此,我们知道即使每个人都同意启用帧指针,也需要数年时间才能使我们的所有用户都获得好处。 旁注:C++ 异常机制非常复杂,必须完成本文中描述的大量工作。 一些有趣的事情需要考虑:展开表在内存中与不在内存中时的成本是多少? 这可能是您的应用程序的问题吗? 这些路径是如何行使的? 2022-12-01 0 0
实战eBPF kprobe函数插桩 本文内容,如非特殊说明,均基于 4.18 内核,x86-64 CPU 架构。 插桩的程序类型选择 说起 eBPF 大家都不陌生,就内核而言,hook 会尽可能选在 tracepoint,如果没有 tracepoint,会考虑使用 kprobe。 tracepoint 的范围有限,而内核函数又太多,基于各种需求场景,kprobe 的出场机会较多;但需要注意的,并不是所有的内核函数都可以选做 hook 点,inline 函数无法被 hook,static 函数也有可能被优化掉;如果想知道究竟有哪些函数可以选做 hook 点,在 Linux 机器上,可以通过less /proc/kallsyms查看。 使用 eBPF 时,内核代码 kprobe 的书写范例如下: SEC("kprobe/vfs_write") int kprobe_vfs_write(struct pt_regs *regs) { struct file *file file = (struct file *)PT_REGS_PARM1(regs); // ... } 其中 pt_regs 的结构体如下: struct pt_regs { /* * C ABI says these regs are callee-preserved. They aren't saved on kernel entry * unless syscall needs a complete, fully filled "struct pt_regs". */ unsigned long r15; unsigned long r14; unsigned long r13; unsigned long r12; unsigned long bp; unsigned long bx; /* These regs are callee-clobbered. Always saved on kernel entry. */ unsigned long r11; unsigned long r10; unsigned long r9; unsigned long r8; unsigned long ax; unsigned long cx; unsigned long dx; unsigned long si; unsigned long di; /* * On syscall entry, this is syscall#. On CPU exception, this is error code. * On hw interrupt, it's IRQ number: */ unsigned long orig_ax; /* Return frame for iretq */ unsigned long ip; unsigned long cs; unsigned long flags; unsigned long sp; unsigned long ss; /* top of stack page */ }; 通常来说,我们要获取的参数,均可通过诸如 PT_REGS_PARM1 这样的宏来拿到,宏定义如下: #define PT_REGS_PARM1(x) ((x)->di) #define PT_REGS_PARM2(x) ((x)->si) #define PT_REGS_PARM3(x) ((x)->dx) #define PT_REGS_PARM4(x) ((x)->cx) #define PT_REGS_PARM5(x) ((x)->r8) 可以看到,上述的宏只能获取 5 个参数;但是在最近的一个项目中,就遇到了如何获取超过 5 个参数的难题,这也是本文的由来,如果你也有类似的困惑,本文也许是为你准备的。 如何获取插桩函数中第 6 个参数 上述的 5 个宏已经可以覆盖大多数的获取小于 5 个参数的需求,不知道大家有没有想过,使用 eBPF 时如果获取的参数个数大于 5 个怎么办呢? 如下的内核函数__get_user_pages(幸运的是,该 static 函数并未被优化掉): static long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm, unsigned long start, unsigned long nr_pages, unsigned int gup_flags, struct page **pages, struct vm_area_struct **vmas, int *nonblocking) 在希望对这个函数进行 hook 的时候犯了难,该函数总共有 8 个参数,如果想拿到最后 3 个参数,该如何操作呢? 且看 BCC 是如何操作的。 BCC 代码中明确表明:只支持寄存器参数。那什么是寄存器参数呢?其实就是内核函数调用约定中的前 6 个参数要通过寄存器传递,只支持这前六个寄存器参数。 constexpr int MAX_CALLING_CONV_REGS = 6; const char *calling_conv_regs_x86[] = { "di", "si", "dx", "cx", "r8", "r9" }; bool BTypeVisitor::VisitFunctionDecl(FunctionDecl *D) { if (D->param_size() > MAX_CALLING_CONV_REGS + 1) { error(GET_BEGINLOC(D->getParamDecl(MAX_CALLING_CONV_REGS + 1)), "too many arguments, bcc only supports in-register parameters"); return false; } } BCC 中使用如下的代码对用户写的BPF text进行rewrite,覆盖的参数刚好是前 6 个参数,分别保存于di, si, dx, cx, r8, r9寄存器: const char *calling_conv_regs_x86[] = { "di", "si", "dx", "cx", "r8", "r9" }; void BTypeVisitor::genParamDirectAssign(FunctionDecl *D, string& preamble, const char **calling_conv_regs) { for (size_t idx = 0; idx < fn_args_.size(); idx++) { ParmVarDecl *arg = fn_args_[idx]; if (idx >= 1) { // Move the args into a preamble section where the same params are // declared and initialized from pt_regs. // Todo: this init should be done only when the program requests it. string text = rewriter_.getRewrittenText(expansionRange(arg->getSourceRange())); arg->addAttr(UnavailableAttr::CreateImplicit(C, "ptregs")); size_t d = idx - 1; const char *reg = calling_conv_regs[d]; preamble += " " + text + " = (" + arg->getType().getAsString() + ")" + fn_args_[0]->getName().str() + "->" + string(reg) + ";"; } } } 看到这里,大家应该明白,之所以能使用 BCC 提供的如此简便的 python 接口(内核函数前面加上前缀 kprobe__,第一个参数永远是struct pt_regs *,然后需要使用几个内核参数就填写几个)来做一些监控工作,是因为 BCC 在幕后做了大量的 rewirte 工作,respect! int kprobe__tcp_v4_connect(struct pt_regs *ctx, struct sock *sk) { [...] } 之前总是由于 eBPF 给的限制(按照 eBPF 的 calling convention,只有 5 个参数可以传递),以为更多的参数是无法获取的。实际上可以回忆下,实际上按照 amd64 的调用约定,最多是可以通过寄存器传递 6 个参数的。 这么看下来,获取第 6 个参数的方案其实也是很简单,手动添加如下的宏即可: #define PT_REGS_PARM6(x) ((x)->r9) 插桩函数超过 6 个参数怎么办 amd64 的调用约定同样规定了,超过 6 个的参数,都会在栈上传递,具体可以参考regs_get_kernel_argument 那么如果参数超过 6 个,处理方案呼之欲出:从栈上获取。 regs_get_kernel_argument该函数在新版本的内核中才有,实现如下: static inline unsigned long regs_get_kernel_argument(struct pt_regs *regs, unsigned int n) { static const unsigned int argument_offs[] = { #ifdef __i386__ offsetof(struct pt_regs, ax), offsetof(struct pt_regs, dx), offsetof(struct pt_regs, cx), #define NR_REG_ARGUMENTS 3 #else offsetof(struct pt_regs, di), offsetof(struct pt_regs, si), offsetof(struct pt_regs, dx), offsetof(struct pt_regs, cx), offsetof(struct pt_regs, r8), offsetof(struct pt_regs, r9), #define NR_REG_ARGUMENTS 6 #endif }; if (n >= NR_REG_ARGUMENTS) { n -= NR_REG_ARGUMENTS - 1; return regs_get_kernel_stack_nth(regs, n); } else return regs_get_register(regs, argument_offs[n]); } 从上述的代码可以看到,常用的前 6 个参数,确实是在寄存器中获取,分别是di, si, dx, cx, r8, r9,这也印证了我们之前的想法,且和 BCC 中的行为是一致的。 从regs_get_kernel_argument中也可以看到,从第 7 个参数开始,便开始从栈上获取了,关键函数为:regs_get_kernel_stack_nth,这个函数在 4.18 内核中也有,如下: static inline unsigned long regs_get_kernel_stack_nth(struct pt_regs *regs, unsigned int n) { unsigned long *addr = (unsigned long *)kernel_stack_pointer(regs); addr += n; if (regs_within_kernel_stack(regs, (unsigned long)addr)) return *addr; else return 0; } // 等价于bpf提供的帮助宏 #define PT_REGS_SP(x) ((x)->sp) static inline unsigned long kernel_stack_pointer(struct pt_regs *regs) { return regs->sp; } regs_get_kernel_stack_nth是标准的栈上操作获取,只不过内核提供了一些地址合法性的检查,不考虑这些的话,在 eBPF 中其实可以一步到位;使用如下函数,便能返回栈上的第 n 个参数(从 1 开始)。 static __always_inline unsigned long regs_get_kernel_stack_nth(struct pt_regs *regs, unsigned int n) { unsigned long *addr; unsigned long val; addr = (unsigned long *)PT_REGS_SP(x) + n; if (addr) { bpf_probe_read(&val, sizeof(val), addr); return val; } return 0; } 捎带提一句,在 amd64 中,eBPF calling ABI 使用了 R1-R5 来传递参数,且做了如下的寄存器映射约定,方便 jit 转换为 native code,提高效率。 R0 – rax return value from function R1 – rdi 1st argument R2 – rsi 2nd argument R3 – rdx 3rd argument R4 – rcx 4th argument R5 – r8 5th argument R6 – rbx callee saved R7 - r13 callee saved R8 - r14 callee saved R9 - r15 callee saved R10 – rbp frame pointer 而 R0 - R10,是 bpf 虚拟机的内部的特殊标识符(函数调用等地方使用),如果 jit 可用,bpf code 会被翻译为native code。 Linux Amd64 调用约定 demo 验证 那 Amd64 的 ABI 是如何操作的呢?可以使用如下的代码进行验证: # cat myfunc.c int utilfunc(int a, int b, int c) { int xx = a + 2; int yy = b + 3; int zz = c + 4; int sum = xx + yy + zz; return xx * yy * zz + sum; } int myfunc(int a, int b, int c, int d, int e, int f, int g, int h) { int xx = (a + b) * c * d * e * (f + (g * h)); int zz = utilfunc(xx, 2, xx % 2); return zz + 20; } int main() { myfunc(1, 2, 3, 4, 5, 6, 7, 8); return 0; } gcc -c -g myfunc.c进行编译汇编得到 myfunc.o eBPF 字节码反汇编 objdump -S myfunc.o反汇编,查看调用约定是不是和我们从教科书上看到的一致 先看 main 函数,可以简单地得出如下结论: 超过 6 个参数的函数调用,需要用到栈传递 前 6 个参数,分别使用 di、si、dx、cx、r8、r9 使用栈传递的参数,是从右向左压栈,此例中先压入 8,再压入 7 00000000000000c4 <main>: int main() { c4: f3 0f 1e fa endbr64 c8: 55 push %rbp c9: 48 89 e5 mov %rsp,%rbp myfunc(1, 2, 3, 4, 5, 6, 7, 8); cc: 6a 08 push $0x8 #栈上传递参数 ce: 6a 07 push $0x7 #栈上传递参数 d0: 41 b9 06 00 00 00 mov $0x6,%r9d #如下是寄存器传递参数 d6: 41 b8 05 00 00 00 mov $0x5,%r8d dc: b9 04 00 00 00 mov $0x4,%ecx e1: ba 03 00 00 00 mov $0x3,%edx e6: be 02 00 00 00 mov $0x2,%esi eb: bf 01 00 00 00 mov $0x1,%edi #第1个参数,寄存器传递 f0: e8 00 00 00 00 call f5 <main+0x31> f5: 48 83 c4 10 add $0x10,%rsp return 0; f9: b8 00 00 00 00 mov $0x0,%eax } fe: c9 leave ff: c3 ret 用户空间程序调用 再看被 main 调用的 myfunc 函数的反汇编: 和 main 函数的调用参数排列一致,参数1-6是寄存器传递,参数7-8是栈上传递 int myfunc(int a, int b, int c, int d, int e, int f, int g, int h) { 50: f3 0f 1e fa endbr64 54: 55 push %rbp 55: 48 89 e5 mov %rsp,%rbp 58: 48 83 ec 28 sub $0x28,%rsp 5c: 89 7d ec mov %edi,-0x14(%rbp) #第1个参数,从edi中复制到栈上 5f: 89 75 e8 mov %esi,-0x18(%rbp) 62: 89 55 e4 mov %edx,-0x1c(%rbp) 65: 89 4d e0 mov %ecx,-0x20(%rbp) 68: 44 89 45 dc mov %r8d,-0x24(%rbp) 6c: 44 89 4d d8 mov %r9d,-0x28(%rbp) #第6个参数 int xx = (a + b) * c * d * e * (f + (g * h)); 70: 8b 55 ec mov -0x14(%rbp),%edx 73: 8b 45 e8 mov -0x18(%rbp),%eax 76: 01 d0 add %edx,%eax # a+b 78: 0f af 45 e4 imul -0x1c(%rbp),%eax #(a+b) * c 7c: 0f af 45 e0 imul -0x20(%rbp),%eax #(a+b) * c * d 80: 0f af 45 dc imul -0x24(%rbp),%eax #(a+b) * c * d * e 84: 89 c2 mov %eax,%edx 86: 8b 45 10 mov 0x10(%rbp),%eax #栈上第1个参数 g 89: 0f af 45 18 imul 0x18(%rbp),%eax # g*h 8d: 89 c1 mov %eax,%ecx 8f: 8b 45 d8 mov -0x28(%rbp),%eax # 参数f 92: 01 c8 add %ecx,%eax # (g*h) + f 94: 0f af c2 imul %edx,%eax # ((g*h) + f) * (a+b) * c * d * e 97: 89 45 f8 mov %eax,-0x8(%rbp) 寄存器堆栈状态 main 函数调用 myfunc,做完 prolog 操作后,栈和寄存器的状态如下: 实战 kprobe 获取 6 个以上参数 说了那么多,到底是不是符合预期呢额,尝试使用 BCC 验证下,为了方便验证,换了一个比较容易从用户态验证的 hook 点:inotify_handle_event 如果在 BCC 中使用了超过 6 个的参数,则会报错,比如函数 kprobe__inotify_handle_event 的原型如下: int kprobe__inotify_handle_event(struct pt_regs *ctx, struct fsnotify_group *group, struct inode *inode, u32 mask, const void *data, int data_type, const unsigned char *file_name, u32 cookie, struct fsnotify_iter_info *iter_info) 当在 BCC 中做超过 6 个参数的获取时,得到如下错误: error: too many arguments, bcc only supports in-register parameters 如果只使用前 6 个寄存器的参数,如下代码即可: #!/usr/bin/python from bcc import BPF # load BPF program b = BPF(text=""" #include <uapi/linux/ptrace.h> int kprobe__inotify_handle_event(struct pt_regs *ctx, struct fsnotify_group *group, struct inode *inode, u32 mask, const void *data, int data_type, const unsigned char *file_name) { char comm[128]; int pid = bpf_get_current_pid_tgid() >> 32; bpf_get_current_comm(comm, sizeof(comm)); bpf_trace_printk("pid is:%d, comm is: %s\\n", pid, comm); bpf_trace_printk("file is: %s\\n", file_name); return 0; } """) b.trace_print() 但是我们可以使用如下的方式,拿到剩下的参数(以 cookie 为例): unsigned long cookie; bpf_probe_read(&cookie, 8, (unsigned long*)PT_REGS_SP(ctx) + 1); 完整代码如下: #!/usr/bin/python from bcc import BPF # load BPF program b = BPF(text=""" #include <uapi/linux/ptrace.h> int kprobe__inotify_handle_event(struct pt_regs *ctx, struct fsnotify_group *group, struct inode *inode, u32 mask, const void *data, int data_type, const unsigned char *file_name) { char comm[128]; unsigned long cookie; int pid = bpf_get_current_pid_tgid() >> 32; bpf_probe_read(&cookie, 8, (unsigned long*)PT_REGS_SP(ctx) + 1); bpf_get_current_comm(comm, sizeof(comm)); bpf_trace_printk("pid is:%d, comm is: %s\\n", pid, comm); bpf_trace_printk("cookie is %d, file is: %s\\n", cookie, file_name); return 0; } """) b.trace_print() shell 1 运行 BCC 代码 ./get-stack-arg.py shell 2 使用 inotify-tools 验证 [root@rmed ~]# inotifywait -m ./ shell 3 做如下的操作 [root@rmed ~]# mv testFileA testFileB shell 1 如下输出 shell 2 如下输出 为了保持严谨性,可以使用https://man7.org/linux/man-pages/man7/inotify.7.html 中的代码进行验证, 主要是做了如下改动,增加对IN_MOVED_FROM | IN_MOVED_TO的监控: diff --git a/inotify.c b/inotify.c index 08fa55a..7116a9a 100644 --- a/inotify.c +++ b/inotify.c @@ -61,6 +61,10 @@ if (event->mask & IN_CLOSE_WRITE) printf("IN_CLOSE_WRITE: "); + if (event->mask & IN_MOVED_FROM) + printf("IN_MOVED_FROM: "); + if (event->mask & IN_MOVED_TO) + printf("IN_MOVED_TO: "); /* Print the name of the watched directory. */ for (int i = 1; i < argc; ++i) { @@ -75,6 +79,8 @@ if (event->len) printf("%s", event->name); + if (event->cookie) + printf("cookie: %d", event->cookie); /* Print type of filesystem object. */ if (event->mask & IN_ISDIR) @@ -123,7 +129,7 @@ for (i = 1; i < argc; i++) { wd[i] = inotify_add_watch(fd, argv[i], - IN_OPEN | IN_CLOSE); + IN_OPEN | IN_CLOSE | IN_MOVED_FROM | IN_MOVED_TO); if (wd[i] == -1) { fprintf(stderr, "Cannot watch '%s': %s\n", argv[i], strerror(errno)); @@ -182,3 +188,4 @@ 同样的,使用 BCC 和自己编译的 inotify 工具验证。 BCC 输出: inotify 输出: 输出符合预期,剩下的第 8 个参数,大家可自行修改代码验证。 祝大家玩得开心。 参考文献: https://eyakubovich.github.io/2022-04-19-ebpf-kprobe-params/ https://eli.thegreenplace.net/2011/09/06/stack-frame-layout-on-x86-64 https://man7.org/linux/man-pages/man7/inotify.7.html https://github.com/iovisor/bcc/blob/master/src/cc/frontends/clang/b_frontend_action.cc https://elixir.bootlin.com/linux/latest/source/arch/x86/include/asm/ptrace.h#L346 2022-11-21 0 0
如何使用eBPF观测用户空间应用程序 译者注 近来这一年,很多刚接触eBPF的朋友会问我,eCapture的原理是什么,为什么区分OpenSSL、Gnutls、Nspr等类库实现?为什么要设定OpenSSL类库地址?为什么C、JAVA、Go实现的https通讯程序,在eCapture上实现却不一样。对于这些问题,我觉得核心问题是大家对「eBPF实现用户空间的行为跟踪」原理不了解,一直想写一篇文章介绍这个知识点,但总是太忙,没时间。这几天看到外网一篇简单的介绍,文章名是How to Instrument UserLand Apps with eBPF,我在这里翻译、调整一下,分享给大家。 前言 eBPF彻底改变了Linux内核中的可观察性。在之前的博客文章中,我介绍了eBPF生态系统的基本构建,揭开了XDP的面纱,并展示了它与eBPF基础设施如何紧密合作,以便在网络堆栈中引入快速处理的数据路径。 然而,eBPF并不是kernel-space内核空间跟踪所独有的。如果我们能够检测在生产环境中运行的应用程序,同时享受eBPF驱动的跟踪的好处,那不是很赞吗? 这就是eBPF uprobe的价值所在。可以直白地把它们看成附加到用户空间的跟踪点,跟内核符号的kprobe类似。 许多语言的运行时、数据库系统以及其他软件堆栈都包含可供BCC工具使用的钩子。具体地说,ustat工具会收集有价值的事件,例如垃圾回收事件、对象创建统计信息、方法调用等等。 但是“,很多官方语言运行时版本,都不附带对DTrace支持,比如Node.js和Python等,这意味着您必须从源代码构建时,就设定好参数。也就是说,编译python这个解释语言时,就需要在参数中指定。将--with -dtrace标志传递给编译器。当然,这不是必要条件。对于ELF文件,只要符号表可用,就可以对它Section段中的任何符号进行应用动态跟踪。对Go或Rust stdlib的函数调用是通过这种方式完成的。 也就是说,对于eCapture来说,哪怕是TLS类库是静态编译的或者没有符号表的,也是可以通过自行确定Offset的方式,来实现对指定偏移地址进行动态跟踪。在eHIDS-Agent也有过一个例子,user/probe_ujava_rasp.go的92行: /* openjdk version "1.8.0_292" OpenJDK Runtime Environment (build 1.8.0_292-8u292-b10-0ubuntu1-b10) OpenJDK 64-Bit Server VM (build 25.292-b10, mixed mode) */ //ex, err := link.OpenExecutable("/usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/libjava.so") // sub_19C30 == JDK_execvpe(p->mode, p->argv[0], p->argv, p->envv); // md5sum : 38590d0382d776234201996e99487110 /usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/libjava.so Probes: []*manager.Probe{ { Section: "uprobe/JDK_execvpe", EbpfFuncName: "java_JDK_execvpe", AttachToFuncName: "JDK_execvpe", UprobeOffset: 0x19C30, BinaryPath: "/usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/libjava.so", }, } 对于相应JVM的libjava.so中,符号表是少了JDK_execvpe这个函数。但是,依旧可以通过IDA pro等工具,对so文件进行静态分析,定位到JDK_execvpe的偏移地址是0x19C30,从而使用eBPF uprobe的HOOK方式完成HOOK。 其实,在eBPF的加载器类库中,不管是C的libbbpf还是Go的cilium/ebpf,都会自行读取uprobe的二进制ELF文件,自行读取符号表,进行被HOOK函数的偏移地址定位,最终依旧使用偏移地址作为HOOK参数。 用于检测应用程序的eBPF技术 有多种方法可以跟踪用户空间进程: 静态声明的USDT 动态声明USDT 使用uprobe进行动态跟踪 静态声明USDT USDT(Userland Statically Defined Tracing)体现了直接在用户代码中嵌入探针的想法。该技术的起源可以追溯到Solaris/BSD DTrace时代,包括使用DTRACE_PROBE()宏来声明策略代码位置的跟踪点。与普通符号不同,USDT钩子可以保证在代码被重构的情况下保持稳定。下图描述了在用户代码中声明USDT跟踪点,以及其在内核中执行的整个过程。 开发人员将首先通过DTRACE_PROBE和DTRACE_PROBE1宏来植入跟踪点,用来圈定感兴趣的代码块。这两个宏都接受几个强制性参数,例如provider/probe的名称,然后是你想从追踪点了解的任何值。编译器会在目标二进制文件的ELF部分中压制USDT追踪点。同时,编译器和追踪工具之间有个契约规定,也就是USDT的元数据所在的.note.stapstd段必须存在。 USDT跟踪工具会对ELF部分进行自检,并在跟踪点得位置放置一个断点,该断点将转换为int 3中断。每当在跟踪点的标记处执行控制流时,都会触发中断处理程序,并在内核中调用与uprobe关联的程序来处理事件并将它们发送到用户空间,执行相应的数据聚合等处理。 动态声明USDT 由于USDT被推入静态生成的ELF部分,对于在解释型语言或基于JIT的语言上运行的软件来说,它违背了声明USDT的目的。幸运的是,可以通过libstapsdt在运行时定义跟踪点。它生成一个带有 USDT 信息的小型共享对象,该对象映射到进程的地址空间,因此跟踪工具可以附加到期望的目标跟踪点。libstapsdt的绑定在大部分语言中都有。可以阅读这个示例,来了解如何在Node.js中安装 USDT 探针。 使用 uprobes 进行动态跟踪 这种类型的跟踪机制除了目标程序的符号表是可访问以外,不需要何额外功能。这是最通用、最强大的插桩方法,因为它允许在任意指令上注入断点,甚至无需重新启动目标进程。 跟踪示例 在简单的理论介绍之后,让我们看看一些具体的例子,看看如何针对不同的语言的应用程序进行插桩。 C语言程序 Redis 是用 C 语言实现的热门KV对数据结构服务器。查看一下 Redis 符号表会发现大量函数可以通过 uprobes 捕获。 $ objdump -tT /usr/bin/redis-server … 000000000004c160 g DF .text 00000000000000cc Base addReplyDouble 0000000000090940 g DF .text 00000000000000b0 Base sha1hex 00000000000586e0 g DF .text 000000000000007c Base replicationSetMaster 00000000001b39e0 g DO .data 0000000000000030 Base dbDictType 00000000000ace20 g DF .text 0000000000000030 Base RM_DictGetC 0000000000041bc0 g DF .text 0000000000000073 Base sdsull2str 00000000000bba00 g DF .text 0000000000000871 Base raxSeek 00000000000ac8c0 g DF .text 000000000000000c Base RM_ThreadSafeContextUnlock 00000000000e3900 g DF .text 0000000000000059 Base mp_encode_lua_string 00000000001cef60 g DO .bss 0000000000000438 Base rdbstate 0000000000047110 g DF .text 00000000000000b5 Base zipSaveInteger 000000000009f5a0 g DF .text 0000000000000055 Base addReplyDictOfRedisInstances 0000000000069200 g DF .text 000000000000004a Base zzlDelete 0000000000041e90 g DF .text 00000000000008ba Base sdscatfmt 000000000009ac40 g DF .text 000000000000003a Base sentinelLinkEstablishedCallback 00000000000619d0 g DF .text 0000000000000045 Base psetexCommand 00000000000d92f0 g DF .text 00000000000000fc Base luaL_argerror 00000000000bc360 g DF .text 0000000000000328 Base raxRandomWalk 0000000000096a00 g DF .text 00000000000000c3 Base rioInitWithFdset 000000000003d160 g DF .text 0000000000000882 Base serverCron 0000000000032907 g DF .text 0000000000000000 Base je_prof_thread_name_set 0000000000043960 g DF .text 0000000000000031 Base zfree 00000000000a2a40 g DF .text 00000000000001ab Base sentinelFailoverDetectEnd 00000000001b8500 g DO .data 0000000000000028 Base je_percpu_arena_mode_names 00000000000b5f90 g DF .text 0000000000000018 Base geohashEstimateStepsByRadius 00000000000d95e0 g DF .text 0000000000000039 Base luaL_checkany 0000000000048850 g DF .text 00000000000002d4 Base createClient ... Redis 内部使用了一个有趣的createStringObject函数来分配robj结构的字符串。Redis 命令是以createStringObject调用名义生成的。我们可以通过挂钩这个函数来监视发送到 Redis 服务器的任何命令。为此,我将使用BCC工具箱中的跟踪工具来演示。 $ /usr/share/bcc/tools/trace '/usr/bin/redis-server:createStringObject "%s" arg1' PID TID COMM FUNC - 8984 8984 redis-server createStringObject b'COMMANDrn' 8984 8984 redis-server createStringObject b'setrn$4rnoctirn$4rnfestrn' 8984 8984 redis-server createStringObject b'octirn$4rnfestrn' 8984 8984 redis-server createStringObject b'festrn' 8984 8984 redis-server createStringObject b'getrn$4rnoctirn' 8984 8984 redis-server createStringObject b'octirn' 以上是在Redis CLI客户端执行set octi fest和get octi所产生的输出。 JAVA语言程序 现代的JVM版本带有对USDT的内置支持。所有的探针都是以libjvm共享对象的名义带来的。我们可以在ELF部分挖掘出可用的追踪点。 $ readelf -n /usr/lib/jvm/jdk-11-oracle/lib/server/libjvm.so ... stapsdt 0x00000037 NT_STAPSDT (SystemTap probe descriptors) Provider: hs_private Name: cms__initmark__end Location: 0x0000000000e2420c, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000 Arguments: stapsdt 0x00000037 NT_STAPSDT (SystemTap probe descriptors) Provider: hs_private Name: cms__remark__begin Location: 0x0000000000e24334, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000 Arguments: stapsdt 0x00000035 NT_STAPSDT (SystemTap probe descriptors) Provider: hs_private Name: cms__remark__end Location: 0x0000000000e24418, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000 Arguments: stapsdt 0x0000002f NT_STAPSDT (SystemTap probe descriptors) Provider: hotspot Name: gc__begin Location: 0x0000000000e2b262, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000 Arguments: 1@$1 stapsdt 0x00000029 NT_STAPSDT (SystemTap probe descriptors) Provider: hotspot Name: gc__end Location: 0x0000000000e2b31a, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000 Arguments: ... 要捕获所有class load类加载事件,我们可以使用以下命令: $ /usr/share/bcc/tools/trace 'u:/usr/lib/jvm/jdk-11-oracle/lib/server/libjvm.so:class__loaded "%s", arg1' 同样,我们可以观察线程创建事件: $ /usr/share/bcc/tools/trace 'u:/usr/lib/jvm/jdk-11-oracle/lib/server/libjvm.so:thread__start "%s", arg1' 结果输出 PID TID COMM FUNC 27390 27398 java thread__start b'Reference Handler' 27390 27399 java thread__start b'Finalizer' 27390 27400 java thread__start b'Signal Dispatcher' 27390 27401 java thread__start b'C2 CompilerThread0' 27390 27402 java thread__start b'C1 CompilerThread0' 27390 27403 java thread__start b'Sweeper thread' 27390 27404 java thread__start b'Service Thread' 当扩展探针启用时(即-XX:+ExtendedDTraceProbes属性),uflow 工具能够实时跟踪并绘制所有方法的执行过程。 $ /usr/share/bcc/tools/lib/uflow -l java 27965 Tracing method calls in java process 27965... Ctrl-C to quit. CPU PID TID TIME(us) METHOD 5 27965 27991 0.736 <- jdk/internal/misc/Unsafe.park 5 27965 27991 0.736 -> java/util/concurrent/locks/LockSupport.setBlocker' 5 27965 27991 0.736 -> jdk/internal/misc/Unsafe.putObject 5 27965 27991 0.736 <- jdk/internal/misc/Unsafe.putObject 5 27965 27991 0.736 <- java/util/concurrent/locks/LockSupport.setBlocker' 5 27965 27991 0.736 <- java/util/concurrent/locks/LockSupport.parkNanos 5 27965 27991 0.736 -> java/util/concurrent/locks/AbstractQueuedSynchronizer$ConditionObject.checkInterruptWhileWaiting 5 27965 27991 0.737 -> java/lang/Thread.interrupted 5 27965 27991 0.737 -> java/lang/Thread.isInterrupted 5 27965 27991 0.737 <- java/lang/Thread.isInterrupted 5 27965 27991 0.737 <- java/lang/Thread.interrupted 5 27965 27991 0.737 <- java/util/concurrent/locks/AbstractQueuedSynchronizer$ConditionObject.checkInterruptWhileWaiting 5 27965 27991 0.737 -> java/lang/System.nanoTime 5 27965 27991 0.737 <- java/lang/System.nanoTime 但是,扩展探针所产生的系统开销,是特别特别大的,所以,它们不适合生产环境,仅用于调试。 Go语言程序 我将用Go语言中的一个例子来完成对追踪技术的演示。 由于Go语言是一种原生编译语言,因此尝试使用trace工具在目标符号上附加uprobe程序。 您可以使用以下简单的代码片段亲自尝试: package main import "fmt" func main() { fmt.Println("Hi") } $ go build -o hi build.go $/usr/share/bcc/tools/trace '/tmp/hi:fmt.Println "%s" arg1' PID TID COMM FUNC - 31545 31545 hi fmt.Println b'xd6x8dK' 我们在参数列中得到不是我们期望的“Hi”字符串,而是一些随机的垃圾数据。这是由于trace不能处理Println变量参数造成的,但也是有关ABI调用约定的错误假设。 与C/C++不同的是,Go语言在堆栈上传递参数,而C/C++更喜欢在普通的寄存器中传递参数。 由于我们不能依靠trace来演示如何插桩Go代码,我将构建一个简单的工具来跟踪所有由http.Get函数发出的HTTP GET请求。 你可以很容易地修改它,来捕获其他HTTP请求。但我只是用这个例子演示, 完整的源代码可以在https://github.com/sematext/uprobe-http-tracer这个repo中找到。 由于我们使用libbcc的Go绑定来完成繁重的工作,所以我不会去讨论关于uprobe attach/load过程的细节。OK,一起来看看真实的uprobe程序。 在所需的包含include之后,我们定义了负责通过偏移处理从堆栈中读取参数的宏。 #define SP_OFFSET(offset) (void *)PT_REGS_SP(ctx) + offset * 8 接下来,我们声明用于封装通过reqs map流传输的事件结构。 这个map是用BPF_PERF_OUTPUT宏定义的。 我们的程序的核心是__uprobe_http_get函数。 每当调用http.Get时,在内核空间中触发这个函数。 我们知道http.Get有一个参数,它表示HTTP请求被发送到的URL。 C和Go语言的另一个区别是它们在内存中如何布局字符串。 C语言的字符串是以 null 结尾的序列,但 Go 将字符串视为包含指向内存缓冲区的指针和字符串长度的两个字值。这说明我们需要对bpf_probe_read进行两次调用,一次用于读取字符串,第二次用于读取其长度。 bpf_probe_read(&url, sizeof(url), SP_OFFSET(1)); bpf_probe_read(&req.len, sizeof(req.len), SP_OFFSET(2)); 触发之后,在用户空间中,URL从slice切片被修剪到其相应的长度。顺便说一下,这工具demo能够通过注入uretprobe来发现每个HTTP GET请求的延迟。然而,事实证明,每次 Go 运行时决定收缩/增长堆栈时,都会产生灾难性的影响,因为uretprobe 会将堆栈上的返回地址修补到在 eBPF VM 的上下文中执行的trampoline函数。在退出uretprobe函数时,指令指针恢复到原始返回地址,这个地址可能指向一个无效地址,从而扰乱堆栈并导致进程崩溃。有一些提议来解决这个问题:Go crash with uretprobe #1320。 结论 在这篇文章中,我们介绍了用于User Space用户空间进程插桩的eBPF特性。 通过几个实际案例,我们已经展示了BCC框架在捕获可观测性信号方面的通用性。 至此,相信你对eBPF uprobe的动态插桩有了一定的了解。也可以阅读eCapture源码,更好的实战。 在golang语言的二进制程序插桩实现中,一定要考虑ABI的规范差异,不过,golang官方也在考虑调整参数传递方式,从堆栈改到寄存器,你可以查看提案:基于寄存器的 Go 调用约定 了解更多详情。 2022-10-27 0 0
eBPF网络程序新手指南 By Liz Rice 前言 机翻 本视频转载Liz Rice在昨天(7月10日)的油管直播。原来视频地址youtu.be/l5l2EckwWME[1]。 笔者使用相关工具进行语音提取、转英文字幕,再自动翻译成中文字幕。时间短,工作量大。涉及音轨准确性、字幕与时间矫正等困难,视频有很多问题,请大家理解。英语好的同学可以无视字幕。 视频介绍 eBPF被称为为“Linux的超级明星”,最近我们看到大量工具使用它来支持云原生领域的服务网格、可观察性和安全性。 这是一项令人兴奋的技术,可以直接在内核中运行自定义程序。 在本次演讲中,Liz Rice在线敲代码,演示eBPF 程序如何在内核中加载、运行,以及如何附加到各种网络事件。本视频侧重于网络示例,深入了解 eBPF 程序如何检查和修改数据包,以形成复杂和高性能网络服务。 数据包流程图 视频内容 补充阅读 告别Sidecar——使用eBPF解锁内核级服务网格 Cilium eBPF实现机制源码分析 How to drop a packet in Linux in more ways than one[2] Packet flow in Netfilter and General Networking[3] 参考资料 [1] youtu.be/l5l2EckwWME: youtu.be/l5l2EckwWME [2] How to drop a packet in Linux in more ways than one: https://codilime.com/blog/how-to-drop-a-packet-in-linux-in-more-ways-than-one/ [3] Packet flow in Netfilter and General Networking: https://upload.wikimedia.org/wikipedia/commons/3/37/Netfilter-packet-flow.svg 2022-07-11 0 0
Tetragon进程阻断原理 TL;DR 文章较长,代码很多,可直接拖到文末看讲解视频。 背景 在云原生领域中,Cilium是容器管理上最著名的网络编排、可观察性、网络安全的开源软件。基于革命性技术eBPF实现,并用XDP、TC等功能实现了L3、L4层的防火墙、负载均衡软件,具备优秀的网络安全处理能力,但在运行时安全上,Cilium一直是缺失的。 2022年5月,在欧洲举行的KubeCon技术峰会期间,Cilium的母公司Isovalent发布了云原生运行时防护系统Tetragon,填补这一空缺。 Tetragon的面世,意味着与falco、tracee、KubeArmor、datadog-agent等几款产品正面竞争,eBPF运行时防护领域愈加内卷。 Tetragon介绍 摘自Tetragon官方仓库的产品介绍。 eBPF实时性 Tetragon 是一个运行时安全实时和可观察性工具。它直接在内核中对事件做相应动作,比如执行过滤、阻止,无需再将事件发送到用户空间处理。 对于可观察性用例,直接在内核中应用过滤器会大大减少观察开销。避免昂贵的上下文切换,尤其是对于高频事件,例如发送、读取或写入操作,减少了大量的内存、CPU等资源。 同时,Tetragon提供了丰富的过滤器(文件、套接字、二进制名称、命名空间等),允许用户筛选重要且相关的事件,并传递给用户空间。 eBPF灵活性 Tetragon 可以挂载到Linux Kernel中的任何函数,并过滤其参数、返回值、进程元数据(例如,可执行文件名称)、文件和其他属性。 跟踪策略 通过编写跟踪策略,用户可以解决各种安全性和可观察性用例。Tetragon社区中,提供了一些常见的跟踪策略,可以解决大部分的可观察性和安全用例。 用户也可以创建新的规则策略部署,自定义配置文件,以满足业务需求。 eBPF 内核感知 Tetragon 通过eBPF钩子感知Linux Kernel状态,并将状态与Kubernetes用户策略相结合,以创建由内核实时执行的规则,来增强云原生场景的安全防护功能。例如,当容器内恶意程序更改其权限时,我们可以创建一个策略来触发警报,甚至在进程有机会完成系统调用并可能运行其他系统调用之前终止该进程。 Tetragon阻断实现原理 以上是Tetragon官方的介绍,提到具备阻断能力,并在技术峰会上,展示了相关阻断的截图,有必要了解一下其实现原理。 tetragon的运行原理会在下篇详细介绍,本篇主要讲实时阻断原理。本文分析的代码版本为首次发布的tag v0.8.0 ,commit ID:75e49ab。 业界常见方式 LKM的内核模块、LD_PRELOAD的动态链接库修改、基于LSM的selinux、seccomp技术等都是常见做内核态/用户态运行时阻断的技术方案,而缺点就比较明显,系统稳定性、规则灵活性、变更周期等问题比较突出。 当然,也有使用内核模块方式。方法是把eBPF特性,封装在内核模块里,再安装到老版本的内核上,这样,就可以覆盖更多内核版本了。但backport新特性的做法在社区里很不推荐,维护成本特别高,需要比较大的内核研发团队与深厚的技术功底。。 云原生生态中,CNCF的项目Falco具备内核模块与eBPF探针两套驱动引擎,提供数据收集能力。同类产品Tracee也是,还基于LSM接口,实现了一定的防御阻断能力,同时支持使用者自定义语法配置文件,进行检测、判断、阻断规则的修改快速更新,以达到更好的防御能力。 作为云原生领域的容器管理软件领头羊,Cilium也会落后,但Linux Security Module钩子(以下简称LSM)需要Linux Kernel 5.7以上版本,而业界多数内核版本都不会这么新。Cilium有没有使用LSM类HOOK进行阻断呢?我们一起来看一下。 配置文件 前面提到,Tetragon灵活性更高,可以读取配置文件规则,应用到内核态。以代码仓库的crds/examples/open_kill.yaml为例,语法规则分为如下几部分 kprobe函数名 函数原型参数 进程过滤配置 参数过滤配置 执行动作 其中,matchActions字段为匹配后的执行动作,比如这里的Sigkill - call: "__x64_sys_write" syscall: true args: - index: 0 type: "fd" - index: 1 type: "char_buf" sizeArgIndex: 3 - index: 2 type: "size_t" selectors: - matchPIDs: - operator: NotIn followForks: true isNamespacePID: true values: - 0 - 1 matchArgs: - index: 0 operator: "Prefix" values: - "/etc/passwd" matchActions: - action: Sigkill yaml配置文件的解析在pkg/k8s/apis/cilium.io/v1alpha1/types.go中的TracingPolicySpec结构体中,包含KProbeSpec和TracepointSpec, 对应json:"kprobes"和json:"tracepoints"两个json的结构。 type TracingPolicySpec struct { // +kubebuilder:validation:Optional // A list of kprobe specs. KProbes []KProbeSpec `json:"kprobes"` // +kubebuilder:validation:Optional // A list of tracepoint specs. Tracepoints []TracepointSpec `json:"tracepoints"` } 同时,Tetragon还支持远程下发配置,配置结构与yaml结构是一样的。这里相比传统的内核模块等技术方案,灵活性更高。 用户空间 详细执行流程会在下篇分享,直入主题。 数据结构 在项目中,抽象出一些概念: Tetragon由多个Sensors传感器构成 Sensor由多个Programs和Maps构成 每个Program对应eBPF代码的HOOK函数 每个Map是相应Program的bpf的数据交互map // Program reprents a BPF program. type Program struct { // Name is the name of the BPF object file. Name string // Attach is the attachment point, e.g. the kernel function. Attach string // Label is the program section name to load from program. Label string // PinPath is the pinned path to this program. Note this is a relative path // based on the BPF directory FGS is running under. PinPath string // RetProbe indicates whether a kprobe is a kretprobe. RetProbe bool // ErrorFatal indicates whether a program must load and fatal otherwise. // Most program will set this to true. For example, kernel functions hooks // may change across verions so different names are attempted, hence // avoiding fataling when the first attempt fails. ErrorFatal bool // Needs override bpf program Override bool // Type is the type of BPF program. For example, tc, skb, tracepoint, // etc. Type string LoadState State // TraceFD is needed because tracepoints are added different than kprobes // for example. The FD is to keep a reference to the tracepoint program in // order to delete it. TODO: This can be moved into loaderData for // tracepoints. TraceFD int // LoaderData represents per-type specific fields. LoaderData interface{} // unloader for the program. nil if not loaded. unloader unloader.Unloader } ExecveV53 = program.Builder( "bpf_execve_event_v53.o", "sched/sched_process_exec", "tracepoint/sys_execve", "event_execve", "execve", ) Sensors传感器加载 默认传感器注册 pkg/sensors/tracing包下由两个文件对传感器进行默认注册,分别是generictracepoint.go与generickprobe.go,写入如下两个Sensors到registeredTracingSensors中。 observerKprobeSensor ,kprobe类型HOOK observerTracepointSensor, tracepoint类型HOOK 同时,还会注册自定义eBPF probe加载器到registeredProbeLoad中。 // generickprobe.go#Line=43 func init() { kprobe := &observerKprobeSensor{ name: "kprobe sensor", } sensors.RegisterProbeType("generic_kprobe", kprobe) sensors.RegisterTracingSensorsAtInit(kprobe.name, kprobe) observer.RegisterEventHandlerAtInit(ops.MSG_OP_GENERIC_KPROBE, handleGenericKprobe) } 策略文件解析 GetSensorsFromParserPolicy方法遍历所有Sensors,调用它的SpecHandler方法,解析yaml配置。解析配置后,生成新的传感器对象,作为整个应用启动、注入、监听的所有传感器。 // cmd/tetragon/main.go#line=209 startSensors, err = sensors.GetSensorsFromParserPolicy(&cnf.Spec) // pkg/sensors/sensors.go#line=160 for _, s := range registeredTracingSensors { sensor, err := s.SpecHandler(spec) if err != nil { return nil, err } if sensor == nil { continue } sensors = append(sensors, sensor) } 新增传感器 接上,yaml解析器读取格式后,根据当前传感器的类型(kprobe还是tracepoint),处理yaml配置文件对应的HOOK配置。 kprobe类型 以kprobe类型的hook配置为例,调用 addGenericKprobeSensors 详细处理每一个配置内容。 // pkg/sensors/tracing/generickprobe.go#line=242-516 func addGenericKprobeSensors(kprobes []v1alpha1.KProbeSpec, btfBaseFile string) (*sensors.Sensor, error) { // 遍历所有kprobes的元素 for i := range kprobes { f := &kprobes[i] funcName := f.Call // 1. 验证配置文件中配置依赖,比如Sigkill需要内核大于5.3 // 2. 解析匹配参数(进程名、namespace、路径、五元组等)写入BTF对象 // 3. 解析ReturnArg 返回值参数,写入到BTF对象 // 4. 过滤保留参数,写入BTF对象 // 5. 解析Filters逻辑,写入BTF对象 // 6. 解析Binary名字到内核数据结构体 // 7. 将属性写入BTF指针,以便加载 // 8. 判断action是否为SIGKILL,并写入BTF对象 kernelSelectors, err := selectors.InitKernelSelectors(f) kprobeEntry := genericKprobe{ loadArgs: kprobeLoadArgs{ filters: kernelSelectors, btf: uintptr(btfobj), retprobe: setRetprobe, syscall: is_syscall, }, argSigPrinters: argSigPrinters, argReturnPrinters: argReturnPrinters, userReturnFilters: userReturnFilters, funcName: funcName, pendingEvents: map[uint64]pendingEvent{}, tableId: idtable.UninitializedEntryID, } genericKprobeTable.AddEntry(&kprobeEntry) // 9. 设定这个kprobe所需的ebpf字节码文件信息 loadProgName := "bpf_generic_kprobe_v53.o" // 10. 利用如上信息,填充到prog的结构体中。 // 11. 在prog结构体中,label字段的值都是**kprobe/generic_kprobe** load := program.Builder( path.Join(option.Config.HubbleLib, loadProgName), funcName, "kprobe/generic_kprobe", "kprobe"+"_"+funcName, "generic_kprobe"). SetLoaderData(kprobeEntry.tableId) } // 创建生成新的sensor,并返回 return &sensors.Sensor{ Name: "__generic_kprobe_sensors__", Progs: progs, Maps: []*program.Map{}, }, nil } 解析过程如下: 验证配置文件中配置依赖,比如Sigkill需要内核大于5.3 解析匹配参数(进程名、namespace、路径、五元组等)写入BTF对象 解析ReturnArg 返回值参数,写入到BTF对象 过滤保留参数,写入BTF对象 解析Filters逻辑,写入BTF对象 解析Binary名字到内核数据结构体 将属性写入BTF指针,以便加载 判断action是否为SIGKILL,并写入BTF对象 设定这个kprobe所需的ebpf字节码文件信息 利用如上信息,填充到prog的结构体中。 在prog结构体中,label字段的值都是kprobe/generic_kprobe 解析完成后,返回一个新的sensor,并添加到Sensors传感器数组中。 至此,完成了配置文件的解析。 在这里,阻断指令的配置,是保存在genericKprobe.loadArgs.filters这个byte数组中的。 动态更新 在tetragon项目中,还具备从远程下发新的规则,更新、添加Sensor传感器功能,相应代码在pkg/observer/observer.go中,本篇不做过多展开,会在下篇分享。 // InitSensorManager starts the sensor controller and stt manager. func (k *Observer) InitSensorManager() error { var err error SensorManager, err = sensors.StartSensorManager(k.bpfDir, k.mapDir, k.ciliumDir) return err } eBPF加载与挂载 Sensors启动 传感器启动加载,执行流程为obs.Start(ctx, startSensors) -> config.LoadConfig -> load.Load 。 在Load方法里,对每一个Program元素,调用observerLoadInstance方法进行加载。 // load.Load // Load loads the sensor, by loading all the BPF programs and maps. func (s *Sensor) Load(stopCtx context.Context, bpfDir, mapDir, ciliumDir string) error { for _, p := range s.Progs { // ... // 加载每个prog if err := observerLoadInstance(stopCtx, bpfDir, mapDir, ciliumDir, p); err != nil { return err } // ... } } eBPF Program加载、挂载 在对每个eBPF program进行加载时,会判断HOOK的类型,针对tracepoint特殊判断处理。这里还是以Kprobe为例。代码调用loadInstance函数,逻辑中判断是否存在自定义的加载器: 若有,则调用s.LoadProbe加载; 若没有,则调用loader.LoadKprobeProgram加载; // pkg/sensors/load.go#Line=297 if s, ok := registeredProbeLoad[load.Type]; ok { logger.GetLogger().WithField("Program", load.Name).WithField("Type", load.Type).Infof("Load probe") return s.LoadProbe(LoadProbeArgs{ BPFDir: bpfDir, MapDir: mapDir, CiliumDir: ciliumDir, Load: load, Version: version, Verbose: verbose, }) } return loader.LoadKprobeProgram( version, verbose, btfObj, load.Name, load.Attach, load.Label, filepath.Join(bpfDir, load.PinPath), mapDir, load.RetProbe) 同样,以前面提到的observerKprobeSensor类型传感器,已经注册自己的Probe加载器,那么会走s.LoadProbe()逻辑,之后,调用loadGenericKprobeSensor() -> loadGenericKprobe()进行加载。 这里的load.Name、load.Attach、load.Label的值,来自前面的yaml配置文件读取部分,值分别为bpf_generic_kprobe_v53.o、 __x64_sys_write 、 kprobe/generic_kprobe,也就是说,不管是哪个kprobe函数,都会被挂载到kprobe/generic_kprobe上,都被generic_kprobe_event()这个eBPF 函数处理,起到统一管理的网关作用。 kprobe/generic_kprobe对应的eBPF代码在bpf/process/bpf_generic_kprobe.c文件里,我们在后面内核空间代码详细分析。 __attribute__((section(("kprobe/generic_kprobe")), used)) int generic_kprobe_event(struct pt_regs *ctx) { return generic_kprobe_start_process_filter(ctx); } filters过滤器 loadGenericKprobe()函数最后一个参数filters过滤器参数,类型是[4096]byte func loadGenericKprobe(bpfDir, mapDir string, version int, p *program.Program, btf uintptr, genmapDir string, filters [4096]byte) error { } 其内容为前面yaml格式解析中的第5步, kernelSelectors, err := selectors.InitKernelSelectors(f) 格式构成为 filter := [length][matchPIDs][matchBinaries][matchArgs][matchNamespaces][matchCapabilities][matchNamespaceChanges][matchCapabilityChanges] 这些数据,也是在内核空间eBPF逻辑中,实现参数匹配,动作响应的判断依据。 Cgo函数调用 在调用BPF SYSCALL的实现上,Tetragon没有使用母公司自己的Cilium/ebpf库,而是使用CGo包装了libbpf进行加载。使用的版本还是0.2.0,当前社区最新版为0.7.0,比较老。(下一篇再讲原因) loadGenericKprobe()函数调用bpf.LoadGenericKprobeProgram(),并把filters传递给下一个CGO的函数C.generic_kprobe_loader(),函数定义在pkg/bpf/loader.go的476行。 int generic_kprobe_loader(const int version, const int verbosity, bool override, void *btf, const char *prog, const char *attach, const char *label, const char *__prog, const char *mapdir, const char *genmapdir, void *filters) { struct bpf_object *obj; int err; obj = generic_loader_args(version, verbosity, override, btf, prog, attach, label, __prog, mapdir, filters, BPF_PROG_TYPE_KPROBE); if (!obj) { return -1; } // ... } 之后,再调用CGO的C函数generic_loader_args()进行BPF SYSCALL调用,加载eBPF程序,挂载到对应kprobe函数上。之后,再写入eBPF Maps。 eBPF Maps创建写入 Tetragon使用eBPF Maps进行用户空间与内核空间的配置数据交互,比如yaml配置文件中,各种过滤条件,匹配后的处理动作等。 还是以open_kill.yaml为例,涉及了两类eBPF Map: 特征匹配规则,也就是配置的内容,比如需要保护的文件路径、IP黑名单等,称之为filters规则 路由分发规则,也就是tetragon程序内部,用于eBPF HOOK的函数网关处理各类参数的自用规则,用尾调用Tail Call类型的map实现。 filters规则Map 在generic_loader_args()里,创建了"filter_map" eBPF Map,并将判断规则、阻断规则filter bytes数组写入到这个map里,格式依旧是[4096]byte的字节流。 // ... char *filter_map = "filter_map"; // ... map_fd = bpf_object__find_map_fd_by_name(obj, filter_map); if (map_fd >= 0) { err = bpf_map_update_elem(map_fd, &zero, filter, BPF_ANY); if (err) { printf("WARNING: map update elem %s error %d\n", filter_map, err); } } Tail Call尾调用Map 在eBPF Program加载部分提到,eBPF Kprobe函数kprobe/generic_kprobe(即generic_kprobe_event())作为统一的过滤处理网关,针对不同的kprobe,其参数个数、参数类型一定是不一样的,比如文件读写函数__x64_sys_write有三个参数,分别是 args: - index: 0 type: "fd" - index: 1 type: "char_buf" sizeArgIndex: 3 - index: 2 type: "size_t" 而SOCKET连接函数__x64_connect只有两个参数,分别是 args: - index: 0 type: "sockfd" - index: 1 type: "sockaddr" 并且,他们的参数类型也不一样,作为统一网关,且面对参数个数、参数类型都不一样的问题,Tetragon在eBPF的解决方案是使用BPF_MAP_TYPE_PROG_ARRAY类型的eBPF Map实现,用于尾调用Tail Call处理。, kprobe_calls尾调用Map struct bpf_map_def __attribute__((section("maps"), used)) kprobe_calls = { .type = BPF_MAP_TYPE_PROG_ARRAY, .key_size = sizeof(__u32), .value_size = sizeof(__u32), .max_entries = 11, }; kprobe_calls Map写入 map_bpf = bpf_object__find_map_by_name(obj, "kprobe_calls"); // ... err = bpf_map__pin(map_bpf, map_name); // ... map_fd = bpf_map__fd(map_bpf); for (i = 0; i < 11; i++) { struct bpf_program *prog; char prog_name[20]; char pin_name[200]; int fd; snprintf(prog_name, sizeof(prog_name), "kprobe/%i", i); prog = bpf_object__find_program_by_title(obj, prog_name); if (!prog) goto out; fd = bpf_program__fd(prog); if (fd < 0) { err = errno; goto err; } snprintf(pin_name, sizeof(pin_name), "%s_%i", __prog, i); bpf_program__unpin(prog, pin_name); err = bpf_program__pin(prog, pin_name); if (err) { printf("program pin %s tailcall err %d\n", pin_name, err); goto err; } err = bpf_map_update_elem(map_fd, &i, &fd, BPF_ANY); if (err) { printf("map update elem i %i %s tailcall err %d %d\n", i, prog_name, err, errno); goto err; } } 用户空间程序,读取eBPF二进制文件,读取kprobe开头的的eBPF函数,以ID作为Key,写入到kprobe_calls尾调用表中。 一共11个函数,都在bpf/process/bpf_generic_kprobe.c文件里,分别是: kprobe/0 对应 generic_kprobe_process_event0 kprobe/1 对应 generic_kprobe_process_event1 kprobe/2 对应 generic_kprobe_process_event2 kprobe/3 对应 generic_kprobe_process_event3 kprobe/4 对应 generic_kprobe_process_event4 kprobe/5 对应 generic_kprobe_process_filter kprobe/6 对应 generic_kprobe_filter_arg1 kprobe/7 对应 generic_kprobe_filter_arg2 kprobe/8 对应 generic_kprobe_filter_arg3 kprobe/9 对应 generic_kprobe_filter_arg4 kprobe/10 对应 generic_kprobe_filter_arg5 至此,涉及eBPF阻断功能的用户空间逻辑全部完成。 内核空间 在内核空间,入口函数为用户空间HOOK的kprobe点 "kprobe/generic_kprobe" ,对应 generic_kprobe_event() 函数。这函数内部只有一个 generic_kprobe_start_process_filter()的调用。 统一网关触发 前面提到,tetragon是把open_kill.yaml中的所有kprobe syscall都交给这个eBPF函数处理,所以,当__x64_sys_write这些HOOK点触发后,逻辑都交给generic_kprobe_start_process_filter()处理。 // bpf/process/bpf_generic_kprobe.c#line=48 static inline __attribute__((always_inline)) int generic_kprobe_start_process_filter(void *ctx) { // ... /* Tail call into filters. */ tail_call(ctx, &kprobe_calls, 5); return 0; } 程序内部获取当前进程信息(struct task_struct、namespace、caps等)后,使用Tail Call尾调用转交kprobe_calls Maps的第5个函数处理,即generic_kprobe_process_filter()。 提醒 因eBPF虚拟机寄存器限制,只能获取前5个参数。 进程过滤流程 获取当前进程信息后,进入下一个流程,获取当前kprobe的进程参数。即进入generic_kprobe_process_filter()函数内部 // bpf/process/bpf_generic_kprobe.c#Line=146 // ... ret = generic_process_filter(msg, &filter_map, &process_call_heap); if (ret == PFILTER_CONTINUE) tail_call(ctx, &kprobe_calls, 5); else if (ret == PFILTER_ACCEPT) tail_call(ctx, &kprobe_calls, 0); /* If filter does not accept drop it. Ideally we would * log error codes for later review, TBD. */ return PFILTER_REJECT; 这里对kprobe所在进程信息,以及配置文件中信息匹配,判断是否要走过滤、阻断流程。流程逻辑如下 PFILTER_ACCEPT 逐个进入5类进程event事件判断 generic_process_event0() generic_process_event1() generic_process_event2() generic_process_event3() generic_process_event4() generic_kprobe_filter_arg1() PFILTER_CONTINUE直接进入参数判断 generic_kprobe_filter_arg1() OTHER 其他情况 直接返回PFILTER_REJECTeBPF HOOK流程。 参数判断流程 当前kprobe函数是否需要过滤判断完成后,流程转入真正的判断逻辑中,即tailcals尾调用的第6个函数处理,即generic_kprobe_filter_arg1() __attribute__((section(("kprobe/6")), used)) int generic_kprobe_filter_arg1(void *ctx) { return filter_read_arg(ctx, 0, &process_call_heap, &filter_map, &kprobe_calls, &override_tasks); } 其中,核心处理函数为filter_read_arg(),第四个参数&filter_map就是来自用户空间解析yaml的filters bytes数组。 filter_read_arg()函数判断触发当前kprobe的进程filter配置,若没找到,则调用kprobe/7至kprobe/9的尾调用函数,逐个查找filter配置。 阻断动作执行 当找到filter配置后,则读取配置中相应的action参数类型,开始进行相应动作分类判断,执行相关流程逻辑,这块都是在__do_action()函数中完成的。 actions = (struct selector_action *)&f[actoff]; postit = do_actions(e, actions, override_tasks); action动作的类型有下面几种 ACTION_POST = 0, ACTION_FOLLOWFD = 1, ACTION_SIGKILL = 2, ACTION_UNFOLLOWFD = 3, ACTION_OVERRIDE = 4, 每个action动作类型都有相应的处理逻辑,本文重点是阻断的实现,那么只需要关注ACTION_SIGKILL类型。 static inline __attribute__((always_inline)) long __do_action(long i, struct msg_generic_kprobe *e, struct selector_action *actions, struct bpf_map_def *override_tasks) { int action = actions->act[i]; switch (action) { case ACTION_UNFOLLOWFD: case ACTION_FOLLOWFD: // ... break; case ACTION_SIGKILL: if (bpf_core_enum_value(tetragon_args, sigkill)) send_signal(FGS_SIGKILL); break; case ACTION_OVERRIDE: default: break; } if (!err) { e->action = action; return ++i; } return -1; } 可以看到,针对类型,是调用了send_signal()函数进行下发FGS_SIGKILL指令给当前进程,完整阻断动作。send_signal()函数是ebpf的内置函数,在Kernel 5.3版本里增加。 阻断演示视频 可以到CNCF (Cloud Native Computing Foundation)的油管观看: Real Time Security - eBPF for Preventing attacks - Liz Rice, Isovalent LSM HOOK比较 LSM类HOOK是在Kernel 5.7以后才添加。阻断功能的实现,Tetragon选择send_signal()的方式,有着兼容更多内核版本的优势。并且其kprobe的HOOK点上,可以实现网关式通用处理,通过配置方式,更灵活的变更HOOK点,避免更新eBPF字节码的方式。 更灵活 网关式 内核版本覆盖多 总结 Tetragon是一个实时识别阻断的运行时防护系统。具备网关式统一处理抓手,可以覆盖更多内核版本,通过配置文件方式灵活变更HOOK点。在eBPF技术支持下,还具备热挂载,系统稳定性高,程序可靠性高等特点。是主机运行时防护系统HIDS的最佳学习项目。 笔者水平有限,若有错误,欢迎指出,谢谢。 阻断原理讲解视频 Tetragon阻断争论 2022年5月,云原生安全公司Isovalent的CTO宣布开源了其内部开发了多年的基于eBPF安全监控和阻断的方案:Tetragon。称可以防御容器逃逸的Linux内核漏洞。 安全研究人员Felix Wilhelm的质疑,在Tetragone: A Lesson in Security Fundamentals认为可以轻易绕过,并用CVE-2021-22555漏洞修改版演示。 这篇文章从标题上都充满了各种嘲讽,Tetragon单词加了e,大概是gone的谐音吧。grsecurity是linux 内核安全经验非常深厚的大厂,对这个领域比较精通。但Tetragon的优势并不是内核底层安全能力。 赛博堡垒(HardenedVault)也撰写一篇文章,云原生安全Tetragon案例之安全产品自防护 认为该产品必定失败。 随着云原生的流行,Linux内核安全成为了一个无法绕开的问题,某个容器被攻陷后可以向Linux内核发起攻击,一旦成功则整个主机都会被攻击者控制,如果你不想你的产品耗资上百万美金后攻击者两个小时就攻陷的话,那应该认真的考虑是否应该从一开始就建立正确的威胁模型。另外,eBPF机制更适合实现审计监控类的安全方案而非防护阻断类,VED的eBPF版本也仅仅是为审计而设计,剩下的事情你应该让SIEM和SOC团队去做,在安全流程上我们也应该遵循KISS(Keep it simple, stupid!)原则,不是吗? 我的看法 针对漏洞利用的方法(注:不是漏洞)的防御机制通常会针对三个阶段: Pre-exploitation(前漏洞利用阶段) Exploitation(漏洞利用阶段) Post-exploitation(后漏洞利用阶段) Tetragon的阻断功能是在Exploitation漏洞利用阶段生效的,因为是可以直接阻断,让此次漏洞攻击失败。 其次,认可几位安全人员的关于Tetragon适用场景说法,更适合阻断用户空间的内核逃逸漏洞。 否定的声音来自底层防御的传统厂商,其实他们没明白,Tetragon的优势是可以动态、轻量、无感知的提升防御能力,并不是完全防御所有攻击方式。 业务优先于安全 在云原生领域,业务类型多数是web服务,安全级别不需要那么高,硬件宿主机重启成本较高,性能要求大,业务优先,安全其次。过于严格的安全检测,占用过多资源,影响业务运行速度,性价比低,成本高。这是云原生场景不能接受的。但偶尔的入侵行为是能容忍的。所以业务优先级大于安全是第一守则。 安全优先于业务 传统安全厂商的产品对系统稳定性、可用性、性能都有较大影响,且存在热更新的难题,哪怕解决了,依旧是特别重的方案。在轻量、动态、热更新的需求下,显得特别笨重。 机密数据库等保密程度较高的服务器,数据安全大于业务功能,愿意牺牲性能换取安全性,那么这种场景适合传统安全厂商的产品。这种场景的规则是安全优先级大于业务,更适合传统安全厂商发挥。 争议总结 当今互联网的服务器市场中,云原生业务占比越来越高,这将会是Tetragon、Falco、Tracee、Datadog等运行时安全产品愈加内卷的动力。 对于用户来说,根据自己的业务特性,选择相应的防御检测产品,满足自己业务需求。 参考 https://isovalent.com/blog/post/2022-05-16-tetragon 2022-06-08 +1 5 0
分享一些eBPF技术相关的PDF 说明 笔者在学习eBPF技术期间,收集了很多高质量的PDF,覆盖网络安全、网络编排、运维监控、性能抽样、调试跟踪等场景,所谓吃水不忘挖井人,在这里分享给大家,帮助大家一起成长。希望在座的各位,也都能回馈业界。 文件首页展 PDF/slide列表 Security 安全 Stories from BPF Security Auditing at Google - Brendan Jackman.pdf eBPF Summit 2021 - Stories from BPF Security Auditing at Google - Brendan Jackman.pdf Think eBPF for Kernel Security Monitoring - Falco at Apple.pdf eBPF, I thought we were friends.pdf Rex Guo Junyuan Zeng - Phantom Attack - Evading System Call Monitoring.pdf BPFContain_Fixing_the_Soft_Underbelly_of_Container.pdf 2021-Secure Namespaced Kernel Audit for Containers.pdf SSTIC2021-Article-runtime_security_with_ebpf-fournier_afchain_baubeau.pdf eBPF_and_the_future_of_osquery_on_Linux.pdf Kernel_Runtime_Security_Instrumentation.pdf Container_Security_Issues_Challenges_and_the_Road_Ahead.pdf Fast and Safe Production Monitoring of JVM Applications with BPF Magic.pdf Kernel Runtime Security Instrumentation.pdf Creating and countering the next generation of Linux rootkits using eBPF.pdf us-21-With-Friends-Like-EBPF-Who-Needs-Enemies.pdf Mitigating Attacks on a Supercomputer with KRSI.pdf networking 网络 Efficient Software Packet Processing on FPGA NICs Fast-Packet-Processing-using-eBPF-and-XDP bpf-rethinkingthelinuxkernel bpf-turninglinuxintoamicroservices-awareoperatingsystem tracing profiling 跟踪抽样 Aug19_Designing_a_gRPC_Interface_for_Kernel_Tracing_with_eBPF_Leonardo_DiDonato Data-Centric Tracing with BPF - Alan Maguire Why is my eBPF code slow_ - Simar Singh observability monitoring 观测监控 Advanced_BPF_Kernel_Features_for_the_Container_Age_FOSDEM Performance Analysis Superpowers with Linux eBPF eBPF基础知识 eBPF Library Ecosystem Overview in Go, Rust, Python, C and More - Kyle Quest Beginners_guide_to_eBPF_programming_for_networking 高效入门eBPF-西安邮电大学-贺东升 LIz_Rice-Beginners_guide_to_eBPF eBPF进阶知识 LPC21_Towards_truly_portable_eBPF bpf internals tracing examples brendan gregg demystify-ebpf-jit-compiler eBPF Summit 2021 - BPF and Spectre - Daniel Borkmann - Final android eBPF-super-powers-on-ARM64-and-android 经典BLOG/站点 见 BLOG.md github 笔者把这些PDF都放到github仓库了,后续也会持续更新。也希望大家分享高质量PDF。 https://github.com/gojue/ebpf-slide 2022-01-25 0 0
Cilium eBPF实现机制源码分析 目的 本文面向eBPF开发者,旨在研究学习高质量开源产品设计思路、编码规范,学习更好得使用eBPF方法经验。内容比较干燥,谨慎阅读。 本文涉及cilium代码版本为2021-08-17的1695d9c59a版本 Cilium产品介绍 Cilium是由革命性内核技术eBPF驱动,用于提供、保护和观察容器工作负载(云原生)之间的网络连接的网络组件。 Cilium使用eBPF的强大功能来加速网络,并在Kubernetes中提供安全性和可观测性。现在 Cilium将eBPF的优势带到了Service Mesh的世界。Cilium服务网格使用eBPF来管理连接,实现了服务网格必备的流量管理、安全性和可观测性。 项目理解 从Cilium的架构图来看,位于容器编排系统和Linux Kernel之间,使用eBPF技术来控制容器网络的转发行为以及安全策略执行。其使用的eBPF功能包括宿主机网卡流量控制,容器container功能管理等。与系统交互的模块是Cilium Daemon,负责eBPF字节码生成、字节码注入到linux kernel,并进行数据读取等。 所以,本文重点将放在Cilium Daemon实现上。 Cilium Daemon模块在源码里对应https://github.com/cilium/cilium/tree/master/daemon目录,从main.go主文件开始阅读即可。但在阅读之前,先对cilium项目的目录结构做一个认识。 目录结构: 当前目标是理解分析cilium的eBPF应用,故笔者在总结时,只列出eBPF相关代码。 按照cilium官方的文档介绍github.com/cilium/cilium,从目录结构中,摘录了eBPF相关的目录。 顶级目录 bpf : eBPF datapath收发包路径相关代码,eBPF源码存放目录。 daemon : 各node节点上运行的cilium-agent代码,也是跟内核做交互,处理eBPF相关的核心代码。 pkg : 项目依赖的各种包。 pkg/bpf : eBPF运行时交互的抽象层 pkg/datapath datapath交互的抽象层 pkg/maps eBPF map的描述定义目录 pkg/monitor eBPF datapath 监控器抽象 源码阅读 cilium C语言eBPF源码 源码功能分类 bpf目录下有很多eBPF实现的源码,文件列表如下 bpf_alignchecker.c C与Go的消息结构体格式校验 bpf_host.c 物理层的网卡tc ingress\egress相关过滤器 bpf_lxc.c 容器上的网络环境、网络流量管控等 bpf_network.c 网络控制相关 bpf_overlay.c 叠加网络控制代码 bpf_sock.c sock控制相关,包含流量大小控制、TCP状态变化控制 bpf_xdp.c XDP层控制相关 sockops 目录下有多个文件,用于sockops相关控制,流量重定向等性能优化。 cilium-probe-kernel-hz.c probe测试的,忽略 作用解释 关于上面eBPF文件源码的作用,在官网也有文档eBPF Datapath Introduction解释,笔者稍做整理。 在详细阐述每个模块源码之前,我们先来复习一下linux kernel的网络栈 如图所示,入口网络流量在到达NIC后,依次经过XDP、TC、Netfilter、TCP、socket层。 cilium的ebpf相关程序,核心功能是针对pod宿主机、pod、容器几个角色之间的网络流量管控。那么其功能肯定是在这几个栈的对应部位做响应hook。 常用的nftables、iptables模块对应在Netfiliter层,站在安全的视角里,HOOK点如何选择,选在那一层,需要结合安全需求来确定。 XDP XDP BPF hook 是网络驱动程序中最早的一关,可以在接收到数据包时触发BPF程序。在这里实现了最高效的数据包处理。这个HOOK非常适合运行过滤程序来处理恶意或意外流量,以及实现常见的 DDOS保护机制。 Traffic Control Ingress/Egress 显然,是附加到流量控制 (TC) Ingress HOOK的BPF程序,与XDP类似,区别是其在网络堆栈完成后,数据包初始处理后运行。此HOOK在内核整个网络堆栈的L3层之前运行,可以读取与数据包关联的大部分元数据。很适合进行本地节点处理,比如应用L3/L4端点策略进行流量重定向等。 容器场景常使用veth pair的虚拟设备,它充当容器与主机的虚拟网桥。通过attach到这个veth pair的宿主机的TC Ingress钩子,Cilium 可以监控管理容器的所有流量。 socket operations sockops Hook是attach到特定的cgroup上,在TCP事件上一并运行。Cilium的实现是把BPF sockops程序attach到根cgroup上,来监视TCP状态转换,进行相关业务处理。 socket 发送/接收 该钩子在TCP socket执行的每个发送操作上运行。这个钩子可以对消息进行读取、删除、重定向到另一个socket。(PS:笔者说一句,这个就很可怕,在安全场景里,这可以很简单得实现端口复用的后门程序。复用80端口,监听unix socket做木马后门,HIDS如何发现?) cilium的eBPF场景应用 Cilium使用上面几个Hook与几个接口功能相结合,创建了以下几个网络对象。 虚拟接口(cilium_host、cilium_net) 可选接口(cilium_vxlan) linux内核加密支持 用户空间代理(Envoy) eBPF Hooks 预过滤器 prefilter XDP层实现的网络流量过滤过滤器规则。比如,由Cilium agent提供的一组CIDR映射用于查找定位、处理丢弃等。 endpoint策略 Cilium endpoint来继承实现。使用映射查找与身份和策略相关的数据包,该层可以很好地扩展到许多端点。根据策略,该层可能会丢弃数据包、转发到本地端点、转发到服务对象或转发到 L7 策略对象以获取进一步的L7规则。这是Cilium数据路径中的主要对象,负责将数据包映射到身份并执行L3和L4策略。 Service TC栈上的HOOK,用于L3/L4层的网络负载均衡功能。 L3 加密器 L3层处理IPsec头的流量加密解密等。 Socket Layer Enforcement socket层的两个钩子,即sockops hook和socket send/recv hook。用来监视管理Cilium endpoint关联的所有TCP套接字,包括任何L7代理。 L7 策略 L7策略对象将代理流量重定向到Cilium用户空间代理实例。使用Envoy实例作为其用户空间代理。然后,根据配置的L7策略转发流量。 如上组件是Cilium实现的灵活高效的 datapath。下图展示端点到端点的进出口网络流量经过的链路,以及涉及的cilium相关网络对象。 总结 综合C的代码,从数据流向来看,分为两类 用户态向内核态发送控制指令、数据 内核态向用户态发送数据 第一部分,cilium调用类bpftool工具来进行eBPF字节码注入。(具体实现的方式,go代码分析时会讲到); LB部分,会直接向map写入数据内容。(lb.h) 第二部分是内核向用户态发送数据,而数据内容几乎都是其他eBPF的运行日志。尤其是dbg.h里定义的cilium_dbg* 方法,实现了skb_event_output()和xdp_event_output()两种函数输出,来代替trace_printk()函数,方便用户快速读取日志。两种函数对应的事件输出都是用了perf buf类型的map来实现,对应go代码里做了详细的实现,抽象的非常好,后面笔者会重点介绍。 cilium go源码分析 eBPF map初始化 上面提到Cilium Daemon是管理eBPF的模块,那么从这个模块的入口文件开始阅读。 ebpf map是在ebpf prog加载之前,预先初始化的,在daemon/cmd/daemon.go469行 err = d.initMaps() initMaps函数实现在daemon/cmd/datapath.go文件的272行。 // initMaps opens all BPF maps (and creates them if they do not exist). This // must be done *before* any operations which read BPF maps, especially // restoring endpoints and services. func (d *Daemon) initMaps() error { lxcmap.LXCMap.OpenOrCreate() ipcachemap.IPCache.OpenParallel() metricsmap.Metrics.OpenOrCreate() tunnel.TunnelMap.OpenOrCreate() egressmap.EgressMap.OpenOrCreate() eventsmap.InitMap(possibleCPUs) signalmap.InitMap(possibleCPUs) policymap.InitCallMap() } initMaps函数中初始化了cilium的所有eBPF map,功能包括xdp、ct等网络对象处理。 eBPF maps作用博主rexrock在文章 https://rexrock.github.io/post/cilium2/中做个直观的图,见 本文挑选其中一个例子来讲。就是前提提到的events maps初始化,用于内核的ebpf字节码调试输出的日志,对应代码eventsmap.InitMap(possibleCPUs)。 代码文件在pkg/map/eventsmap/eventsmap.go的53行 eventsMap := bpf.NewMap(MapName, bpf.MapTypePerfEventArray, &Key{}, int(unsafe.Sizeof(Key{})), &Value{}, int(unsafe.Sizeof(Value{})), MaxEntries, 0, 0, bpf.ConvertKeyValue, ) 从代码中可以看到,map名字是cilium_events,类型是MapTypePerfEventArray。 eBPF代码编译 回到daemon/cmd/daemon.go文件,接着往下看,在816行对eBPF应用场景进行初始化。 err = d.init() //跳转到285行的init函数 // Remove any old sockops and re-enable with _new_ programs if flag is set sockops.SockmapDisable() sockops.SkmsgDisable() 对新老map进行删除、替换。 在237行进行datapath的重新初始化加载。 if err := d.Datapath().Loader().Reinitialize(d.ctx, d, d.mtuConfig.GetDeviceMTU(), d.Datapath(), d.l7Proxy); err != nil { datapath初始化ebpf环境 Reinitialize函数是抽象的interface的函数,具体实现在pkg/datapath/loader/base.go的230行 该函数前半部分对启动参数进行整理汇总。核心逻辑在421行。 prog := filepath.Join(option.Config.BpfDir, "init.sh") cmd := exec.CommandContext(ctx, prog, args...) cmd.Env = bpf.Environment() if _, err := cmd.CombinedOutput(log, true); err != nil { return err } 是的,你没看错,调用了外部的shell命令进行ebpf代码编译。对应文件是bpf/init.sh,这个shell里会进行编译ebpf文件。 比如:bpf_compile bpf_alignchecker.c bpf_alignchecker.o obj "" ,生成eBPF字节码.o文件。后面将用于校验C跟GO的结构体对齐情况。 bpf_compile也是封装的clang的编译函数,依旧使用llvm\llc编译链接eBPF字节码文件。 eBPF字节码加载 同样bpf/init.sh也会对bpf/*.c进行编译,再调用tc等命令,对编译生成的eBPF字节码进行加载。 其次,go代码里也有加载的地方,见pkg/datapath/loader/netlink.go的replaceDatapath函数内91行使用ip 或tc 命令对字节码文件进行加载,使内核加载新的字节码。完成新老字节码的注入替换。 C跟go结构体格式校验 430行,使用go代码,验证C跟G结构体对齐情况。 alignchecker.CheckStructAlignments(defaults.AlignCheckerName) 在pkg/alignchecker/alignchecker.go里,CheckStructAlignments函数会读取.o的eBPF字节码文件,按照elf格式进行解析,并获取DWARF段信息,查找.debug_*段或者.zdebug_段信息。 getStructInfosFromDWARF函数会按照elf里段内结构体名字与被检测结构体名字进行对比,验证类型,长度等等。 ebpf编译加载的其他方式 在pkg/datapath/loader/base.go210行左右reinitializeXDPLocked函数 调用compileAndLoadXDPProg函数进行ebpf字节码编译与加载。 // compileAndLoadXDPProg compiles bpf_xdp.c for the given XDP device and loads it. func compileAndLoadXDPProg(ctx context.Context, xdpDev, xdpMode string, extraCArgs []string) error { args, err := xdpCompileArgs(xdpDev, extraCArgs) if err != nil { return fmt.Errorf("failed to derive XDP compile extra args: %w", err) } if err := compile(ctx, prog, dirs); err != nil { return err } if err := ctx.Err(); err != nil { return err } objPath := path.Join(dirs.Output, prog.Output) return replaceDatapath(ctx, xdpDev, objPath, symbolFromHostNetdevEp, "", true, xdpMode) } 函数中,先进行参数重组,在调用pkg/datapath/loader/compile.go的compile函数进行编译。该函数依旧是调用了clang进行编译。 其他代码细节不在赘述。 go源码分析总结 编译:直接或间接调用clang/llc命令进行编译链接。 加载:调用外部bpftool\tc\ss\ip等命令加载。 MAP管理:调用外部命令或go cilium/ebpf库进行map删除、创建等 CORE兼容:会在每个endpoint上编译,没有使用eBPF CORE。 更新:每次重新加载都会编译。 内核态与用户态数据交互 交互map 名字 类型 所属文件 数据流向 备注 SIGNAL_MAP BPF_MAP_TYPE_PERF_EVENT_ARRAY signal.h ? LB4_REVERSE_NAT_SK_MAP BPF_MAP_TYPE_LRU_HASH bpf_sock.c ? LB6_REVERSE_NAT_SK_MAP BPF_MAP_TYPE_LRU_HASH bpf_sock.c ? CIDR4_HMAP_NAME BPF_MAP_TYPE_HASH bpf_xdp.c ? CIDR4_LMAP_NAME BPF_MAP_TYPE_LPM_TRIE bpf_xdp.c CIDR6_HMAP_NAME BPF_MAP_TYPE_HASH bpf_xdp.c CIDR6_LMAP_NAME BPF_MAP_TYPE_LPM_TRIE bpf_xdp.c bytecount_map BPF_MAP_TYPE_HASH bytecount.h cilium_xdp_scratch BPF_MAP_TYPE_PERCPU_ARRAY xdp.h EVENTS_MAP BPF_MAP_TYPE_PERF_EVENT_ARRAY event.h IPV4_FRAG_DATAGRAMS_MAP BPF_MAP_TYPE_LRU_HASH ipv4.h LB6_REVERSE_NAT_MAP BPF_MAP_TYPE_HASH lb.h LB6_SERVICES_MAP_V2 BPF_MAP_TYPE_HASH lb.h ENDPOINTS_MAP BPF_MAP_TYPE_HASH maps.h METRICS_MAP BPF_MAP_TYPE_PERCPU_HASH maps.h POLICY_CALL_MAP BPF_MAP_TYPE_PROG_ARRAY THROTTLE_MAP BPF_MAP_TYPE_HASH EP_POLICY_MAP BPF_MAP_TYPE_HASH_OF_MAPS maps.h ? Map to link endpoint id to per endpoint cilium_policy map POLICY_MAP BPF_MAP_TYPE_HASH maps.h ? Per-endpoint policy enforcement map EVENTS_MAP BPF_MAP_TYPE_SOCKHASH bpf_sockops.h ? 太多了,而且比较偏向cilium的业务功能,偏离本文主题,不写了。后面会按照数据流向分三类,总结说明。 map作用分类 内核态自用 常用与程序内部的临时缓存。比如__section("cgroup/connect4")时,TCP socket的状态每次变化,都需要将之前endpoint信息存储起来,下次状态变化时,再读取更改。 举个例子🌰 //bpf/sockops/bpf_sockops.c line 127 __section("sockops") int bpf_sockmap(struct bpf_sock_ops *skops) { // 调用bpf_sock_ops_ipv4 函数 sock_hash_update(skops, &SOCK_OPS_MAP, &key, BPF_NOEXIST); } //bpf/sockops/bpf_redir.c line 42 __section("sk_msg") int bpf_redir_proxy(struct sk_msg_md *msg) { msg_redirect_hash(msg, &SOCK_OPS_MAP, &key, flags); } 内核态写,用户态读 有个典型的场景,就是eBPF字节码运行日志的输出。以cilium events map为例,该map是内核态代码的日志输出map。 EVENTS_MAP map创建 MapName = "cilium_events" //eventsmap.go line 19 eventsMap := bpf.NewMap(MapName, bpf.MapTypePerfEventArray, &Key{}, int(unsafe.Sizeof(Key{})), &Value{}, int(unsafe.Sizeof(Value{})), MaxEntries, 0, 0, bpf.ConvertKeyValue, ) map的路径会被拼接,最终全路径时/sys/fs/bpf/tc/globals/cilium_events, // Path to where bpffs is mounted , /sys/fs/bpf mapRoot = defaults.DefaultMapRoot // Prefix for all maps (default: tc/globals) mapPrefix = defaults.DefaultMapPrefix m.path = filepath.Join(mapRoot, mapPrefix, name) // 即 /sys/fs/bpf/tc/globals/cilium_events 拼接好map路径后,调用os.MkdirAll创建/sys/fs/bpf/tc/globals目录;调用CreateMap函数,使用unix.Syacall创建BPF_MAP_CREATE操作的FD; ret, _, err := unix.Syscall( unix.SYS_BPF, BPF_MAP_CREATE, uintptr(unsafe.Pointer(&uba)), unsafe.Sizeof(uba), ) 调用objPin对map ID和cgroup path绑定,保存到pkg/bpf/map_Register_linx.go的mapRegister Map里,完成整个map的创建、关联。 map数据写入 map内数据写入是由dbg.h内cilium_dbg*相关函数写入,代码参见 static __always_inline void cilium_dbg(struct __ctx_buff *ctx, __u8 type, __u32 arg1, __u32 arg2) { struct debug_msg msg = { __notify_common_hdr(CILIUM_NOTIFY_DBG_MSG, type), .arg1 = arg1, .arg2 = arg2, }; ctx_event_output(ctx, &EVENTS_MAP, BPF_F_CURRENT_CPU, &msg, sizeof(msg)); } 其中,写入的map名字是EVENTS_MAP常量,定义在bpf/node_config.h里,默认是test_cilium_events,需要总控远程下发这个头文件。方便由go这边统一控制map的名字。详细代码在pkg/datapath/linux/config/config.go里WriteNodeConfig函数部分。比如 cDefinesMap["EVENTS_MAP"] = eventsmap.MapName cDefinesMap["SIGNAL_MAP"] = signalmap.MapName cDefinesMap["POLICY_CALL_MAP"] = policymap.PolicyCallMapName cDefinesMap["EP_POLICY_MAP"] = eppolicymap.MapName cDefinesMap["LB6_REVERSE_NAT_MAP"] = "cilium_lb6_reverse_nat" cDefinesMap["LB6_SERVICES_MAP_V2"] = "cilium_lb6_services_v2" 回到cilium_dbg函数,是内核eBPF部分最底层的日志事件输出函数,map声明在bpf/lib/events.h struct bpf_elf_map __section_maps EVENTS_MAP = { .type = BPF_MAP_TYPE_PERF_EVENT_ARRAY, .size_key = sizeof(__u32), .size_value = sizeof(__u32), .pinning = PIN_GLOBAL_NS, .max_elem = __NR_CPUS__, }; 调用的地方比较简单,不在一一赘述。 小结 map名统一由go部分控制,起到统一管理作用,避免两端不一致。 事件日志/调试日志避开trace_printk函数输出,统一发送至用户态go部分,避免人工查看/sys/kernel/debug/tracing/trace_pipe,提升工作效率。 由go部分决策如何处理。比如发送给相关模块订阅的角色,或者统一上传到日志中心,便于大规模分析展示。这是个可以借鉴的好思路。 map数据读取 在monitor/agent/agent.go里,初始化时对map进行了pin操作。 笔者要吐槽的是cilium_events map名字的常量是eventsMapName,这跟创建map时用的pkg/maps/eventsmap/eventsmap.go下的MapName不是同一个,而是重新定义一个。影响代码分析。 path := oldBPF.MapPath(eventsMapName) eventsMap, err := ebpf.LoadPinnedMap(path, nil) 在handleEvents函数中进行事件读取,并对异常错误进行计数,用作数据完整性校对。(笔者还没细跟进) func (a *Agent) processPerfRecord(scopedLog *logrus.Entry, record perf.Record) { a.Lock() defer a.Unlock() if record.LostSamples > 0 { // 丢失数据大小统计 a.MonitorStatus.Lost += int64(record.LostSamples) // 通知所有内部消费者,告诉他们数据丢失部分大小 a.notifyPerfEventLostLocked(record.LostSamples, record.CPU) // 存入外部订阅者队列,在队列的消费处,发送给所有监听者 a.sendToListenersLocked(&payload.Payload{ CPU: record.CPU, Lost: record.LostSamples, Type: payload.RecordLost, }) } // ... } 这里的事件发送分为两种接受者 monitor进程内部的消费者,抽象为consumer.MonitorConsumer,比如数据丢失监控、事件处理dispather派发器等。对应consumers属性,使用RegisterNewConsumer函数来注册为消费者。 monitor进程外部的订阅者,抽象为listener.MonitorListener,比如与其交互的外部进程,远程数据数据库、中心事件处理总控等。对应newListener属性,使用RegisterNewListener函数注册,目前只支持自定义的Version1_2,方便以后扩展。 不管是consumer还是listener,都是一对多的关系,遍历多个consumers\listener进行发送。 cilium的配套可视化组件Hubble就是作为其中一个consumer来接收数据的。 对于事件的进程外部发送,cilium采用本地unix socket的方式,监听/var/run/cilium/monitor1_2.sock,来支持本机进程间数据通讯。 func ServeMonitorAPI(monitor *Agent) error { listener, err := buildServer(defaults.MonitorSockPath1_2) if err != nil { return err } s := &server{ listener: listener, monitor: monitor, } log.Infof("Serving cilium node monitor v1.2 API at unix://%s", defaults.MonitorSockPath1_2) go s.connectionHandler1_2(monitor.Context()) return nil } 小结 对于内核态程序、稳定性质量做监控,结合内核态数据对服务更好的掌控。 事件的派发角色需要结合业务,进程内消费角色如何划分(对账、解码),进程间消费角色如何设计,多版本升级,通许协议如何设计等。 cilium在代码层面,角色功能上做了非常好的抽象,扩展性比较好。比如MonitorListener接口设计时,只规范了Enqueue(pl *payload.Payload)、Version() Version、Close()三个方法,实现的时候,可以随意扩展。 用户态写,内核态读 以XDP层的IP过滤为例,对应map path : cilium_cidr_v4_dyn,来给大家讲一下这个场景。 事件触发是由HTTP接口接收控制指令触发的,在daemon/cmd/prefilter.go的55行附近,patchPrefilter.Handle函数接收HTTP request,读取策略文件中的CIDRs,准备调用preFilter.Insert写入到eBPF Maps中。 reFilter.Insert是接口函数,抽象的实现在pkg/datapath/prefilter/prefilter.go119行Insert函数中实现。 打开 CIDRs写入到eBPF maps里之前,先进行map选择 for _, cidr := range cidrs { ones, bits := cidr.Mask.Size() which := p.selectMap(ones, bits) if which == mapCount || p.maps[which] == nil { ret = fmt.Errorf("No map enabled for CIDR string %s", cidr.String()) break } err := p.maps[which].InsertCIDR(cidr) if err != nil { ret = fmt.Errorf("Error inserting CIDR string %s: %s", cidr.String(), err) break } else { undoQueue = append(undoQueue, cidr) } } 循环遍历CIDRs,每个IP都判断是IPv4还是IPv6,选择对应的map,准备写入。写入的map在PreFilter.initOneMap函数里做了初始化读取。 先判断IP的类型prefixesV4Dyn、prefixesV4Fix、prefixesV6Dyn、prefixesV6Fix,再调用pkg/maps/cidrmap/cidrmap.go中147行cidrmap.OpenMapElems函数打开当前map。 打开map时,会先尝试创建bpf.MapTypeLPMTrie类型(也就是BPF_MAP_TYPE_LPM_TRIE类型)的map,若不支持,则改为MapTypeHash类型,来兼容低版本内核的linux。 写入 PreFilter.Insert调用CIDRMap.InsertCIDR,再调用bpf.UpdateElement写入相应CIDRs。 内核态读取 在bpf/bpf_xdp.c的check_v4函数中,map_lookup_elem函数查找CIDR4_LMAP_NAMEeBPF map,若包含在内,则直接返回CTX_ACT_DROP丢弃包。 #ifdef CIDR4_LPM_PREFILTER if (map_lookup_elem(&CIDR4_LMAP_NAME, &pfx)) return CTX_ACT_DROP; #endif 这段代码在__section("from-netdev")段运行,起到XDP层就可以过滤IP的作用。 CIDR4_LMAP_NAME常量就是对应的cilium_cidr_v4_dyn eBPF map ,老样子,也是由go层的代码生成的filter_config.h头文件,会把CIDR4_LMAP_NAME改为全路径的cilium_cidr_v4_dyn。 其中,go部分生成头文件的地方在pkg/datapath/prefilter/prefilter.go的59行WriteConfig函数里。 fmt.Fprintf(fw, "#define CIDR4_LMAP_NAME %s\n", path.Base(p.maps[prefixesV4Dyn].String())) 小结 eBPF map可以做内核态用户态数据交互 不同数据类型,选择不同的eBPF map类型,LPMTrie与HASH当前类库都支持。 在自己的项目中,也可以考虑内核态做基本的过滤策略,且策略内容可以动态下发。 总结 Cilium产品是面向微服务场景下的网络管理方案,涉及的安全也只是网络链路的可达性。对系统安全几乎没有涉猎。 但该产品是使用eBPF技术大规模应用的优秀项目之一,分析学习他的实现,可以帮助我们快速理解eBPF在go语言中的使用技巧。 通过笔者的分析学习,可以宏观的了解到Cilium在eBPF内核技术使用时,场景覆盖网络处理的XDP、TC、SOCKET等L3、L4、L7层,业务覆盖防火墙、网络路由、网络隔离、负载均衡等。通过集中式管理eBPF文件源码,下发到各endpoint分发式编译挂载。调试日志作为eBPF map事件统一收集处理。 支持用户态、内核态相互之间用eBPF map做双向通讯,实现策略下发与数据收集。具备数据对账、监控告警能力。 不足的地方在资源占用、熔断机制等功能。但考虑到cilium是宿主机上主要业务,CPU、内存等资源优先使用,对熔断机制需求不强烈。 这点不同于HIDS等安全防御产品,需要让资源给业务,严格控制自身资源使用。 2021-12-09 +1 5 0
[译]使用eBPF per-CPU Cgroup本地存储进行低功耗统计 译者注 背景 译者还在学习eBPF,在学习计划中,分了阅读、翻译、模仿、研发等几个阶段。这篇文章是译者学习过程中的第二篇eBPF文章翻译。在阅读过程中,动手练习,理解原文目的、思路、方法。加深对知识点的理解。 本文翻译自Using eBPF per-CPU Cgroup local storage for low overhead accounting,由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。 第一篇翻译文章为:使用 eBPF(并绕过 TCP/IP)加速云原生应用程序的经验教训。 对于本文中涉及的代码,都放在GitHub:ebpf-demo仓库下的bpf-accounting下,请自取。 per-CPU 随着多CPU架构的成熟发展,BPF Map也引入了per-cpu类型,如BPF_MAP_TYPE_PERCPU_HASH、BPF_MAP_TYPE_PERCPU_ARRAY等,当你使用这种类型的BPF Map时,每个CPU都会存储并看到它自己的Map数据,从属于不同CPU之间的数据是互相隔离的,这样做的好处是,在进行查找和聚合操作时更加高效,性能更好,尤其是你的BPF程序主要是在做收集时间序列型数据,如流量数据或指标等。参考 原文 前言 Linux eBPF生态系统正在迅速增长,每个发布周期都会出现新功能。从本质上讲,eBPF是一种通过attach到各种per-defines hooks和导出函数的方式,来编写Linux内核脚本的安全方式。在eBPF中,可以使用各种“map”类型来实现kernel space和user space通信。 每隔几个内核版本,就会引入或改进新的“map”类型,并且在3年前BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE引入,现在可以在4.20以后的内核中使用,git提交日志:bpf: introduce per-cpu cgroup local storage。 译者注: BPF Compiler Collection (BCC)维护了eBPF新特性变化的日志,包含每个特性的增加时间、内核版本号、commit时间等,详情见:BPF Features by Linux Kernel Version 我对它很感兴趣,想通过一个简单的Cgroup网络吞吐量监控应用来了解它。 通常情况下,在资源受限的机器上运行多个工作负载时的实际用例。但事实证明,我很难理解如何快速有效地使用它。之后,我决定写一篇文章,希望可以帮助你快速学习per-CPU eBPF Cgroup local storage。 示例演示 这篇文章将 在用户空间的应用使用Cilium 的ebpf库。当然,在C中使用libbpf或 在 Python 中使用BCC可以轻松实现类似的效果。内核侧eBPF无需更改,可以配合其他任何语言实现的eBPF数据读取应用。 为了监控目标Cgroup入口和出口吞吐量,演示程序将附加到cgroup_skb/ingress和cgroup_skb/egress Cgroup eBPF 钩子。当然,将map类型的result发送到用户空间BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE。 为了保持这篇文章简单并专注于eBPF部分,这篇文章将使用一个硬编码的cgroup地址。 示例代码 如下代码,这是eBPF内核态程序的概要。以一个伪注释开始,以便让go编译器不要编译它。然后是 linux内核以及BPF helper等各种头文件。 license可能是多余的,但eBPF程序与普通c内核模块遵循相同的规则。通常,某些符号导出受“仅GPL”约束(或兼容)。 C代码 // +build ignore #include <stdbool.h> #include <linux/bpf.h> #include <bpf/bpf_helpers.h> #include <netinet/ip.h> #include <netinet/ip6.h> // --------------------------------------------- // -- The real program will be somewhere here -- // --------------------------------------------- char __license[] __attribute__((section("license"), used)) = "MIT"; 编译器clang(llvm)将这个C文件编译为eBPF的字节码文件: clang -g -Wall -Werror -O2 -emit-llvm -c bpf-accounting.c -o - | llc -march=bpf -filetype=obj -o bpf-accounting.o 当然,需要先安装Clang,以及linux kernel对应的头文件,安装方法可以参考上篇译者的eBPF开发环境 go代码 这是现在的Go代码模版。它主要包含设置阶段和监控阶段两部分代码,监控阶段每秒运行一次,按“Ctrl+C”或TERM信号终止运行。 设定好TARGET_CGROUP_V2_PATH,确保关注的是Cgroup V2版本。在笔者开发环境的路径是/sys/fs/cgroup/unified前缀,因系统而异,这里是Ubuntu,但其Systemd配置仍使用v1的Cgroup。 如果bpf-accounting.c里的eBPF部分代码没有被调用,那么EBPF_PROG_ELF加载部分,就需要好好调整。 package main import ( "log" "os" "os/signal" "syscall" "time" "github.com/cilium/ebpf" "github.com/cilium/ebpf/link" "golang.org/x/sys/unix" ) const ( TARGET_CGROUP_V2_PATH = "/sys/fs/cgroup/unified/yadutaf" // <--- Change as needed for your system/use-case EBPF_PROG_ELF = "./bpf-accounting.o" ) func main() { log.Printf("Attaching eBPF monitoring programs to cgroup %s\n", TARGET_CGROUP_V2_PATH) // ------------------------------------------------------------ // -- The real program initialization will be somewhere here -- // ------------------------------------------------------------ // Wait until signaled c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT) signal.Notify(c, syscall.SIGTERM) // Periodically check counters ticker := time.NewTicker(1 * time.Second) out: for { select { case <-ticker.C: log.Println("-------------------------------------------------------------") // ------------------------------------------ // -- And here will be the counters report -- // ------------------------------------------ case <-c: log.Println("Exiting...") break out } } } 数据结构 现在介绍一下BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE类型。但请先思考一个问题,为什么不使用最典型、更容易使用的BPF_MAP_TYPE_HASHmap类型? 对于本文的用例,BPF_MAP_TYPE_HASH将会特别适合。但是,如果应用程序需要监视所有cgroup,并且在创建动态时attach,或在Cgroup终止时清除相关资源,BPF_MAP_TYPE_CGROUP_STORAGE类型则可能是更合适的选择。 但是,如果可以从多个CPU并发访问收集的数据,那么per-CPU对应的BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE类型是避免自旋锁的最佳选择。 从原理上看,BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE实际上是一个/virtual/映射,或更准确地说是叫Cgrouplocal storage本地存储的内核术语。在内部,Cgroup数据结构有一个专用于cgroup本地存储的成员。因此,指定类型的程序将为指定的Cgroup共享相同的存储区域。默认情况下,不同类型的程序在Cgroup中都会有一个固定的存储区域。然而,可以在指定的Cgroup的所有程序类型之间共享这个存储区域。 几个要点: 根据设计,map(虚拟)的key在概念上是一个(cgroup_id, program_type)元组。这不是固定的。比如,叶成员类型是灵活的。 如果多个相同类型的程序需要在同一个Cgroup中存储数据,那么必须配合进行叶子成员类型定义。 程序只能访问它们所连接的cgroup的存储区域。换句话说,即使由子Cgroup foo/bar中的事件触发,attach到Cgroup的程序foo也只会访问Cgroup的存储foo的值。如果需要专门跟踪子 Cgroup事件,则还需要将相同的代码逻辑attach到关注的Cgroup。 提醒一下BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE: 由于per-CPU是map类型,因此group中的存储不是叶子成员类型,而是这种类型的per-CPU数组。这对程序的eBPF部分是完全透明的,只有它当前的cgroup+CPU视图。然而,它需要对Go部分进行特殊处理,来获取每个cgroup+CPU存储的完整数据视图。 eBPF逻辑实现 对于本文,有两种业务目标 (cgroup_skb/ingress和 cgroup_skb/egress)来匹配incomming和outgoing流量。由于目标是测量每个方向传输的字节总数,因此叶子成员类型可以是uint64。 内核态C代码 在eBPF端,结构声明很简单: struct bpf_map_def SEC("maps") cgroup_counters_map = { .type = BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE, .key_size = sizeof(struct bpf_cgroup_storage_key), .value_size = sizeof(__u64), }; 这定义了 eBPF map cgroup_counters_map类型BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE,其主键struct bpf_cgroup_storage_key的类型为bpf.h中定义的类型,为无符号uint64,用它作为map的member来保存字节大小。 用户态Go代码 用户态的逻辑代码方面涉及更多,但别担心。首先,程序需要增加最大“locked”内存。虽然这个例子来说不是必需的,这里只是普通的字节大小计数器,不会占用啥太大内存。但在使用eBPF的场景里,这里可能是最容易疏忽的陷阱。因此,需要实现为: // Increase max locked memory (for eBPF maps) // For a real program, make sure to adjust to actual needs unix.Setrlimit(unix.RLIMIT_MEMLOCK, &unix.Rlimit{ Cur: unix.RLIM_INFINITY, Max: unix.RLIM_INFINITY, }) 现在需要应用层调用内核eBPF接口加载eBPF字节码的eBPF ELF文件。这里是Cilium的ebpf库的亮点。解析ELF字节码文件并提取map定义和所有程序metadata “collection”: collec, err := ebpf.LoadCollection(EBPF_PROG_ELF) if err != nil { log.Fatal(err) } 从collection中获取map的句柄非常简单: // Get a handle on the statistics map cgroup_counters_map := collec.Maps["cgroup_counters_map"] 在访问map时,程序需要key和member类型定义。重点是,Cilium的库还没有内置定义。由于key format是由内核的ABI强加的,因此需要按照struct bpf_cgroup_storage_key 从内核文档:BPF_MAP_TYPE_CGROUP_STORAGE 完成结构体的解析,以及注意32位虚拟字段的对齐问题: type BpfCgroupStorageKey struct { CgroupInodeId uint64 AttachType ebpf.AttachType _ uint32 } go程序需要定义成员类型。由于程序使用per-CPU数据结构,这需要是一个slice: type PerCPUCounters []uint64 由于我们只对传输的字节总数感兴趣,并不关注其他事情,那么直接for循环slice,进行相加即可。: func sumPerCpuCounters(perCpuCounters PerCPUCounters) uint64 { sum := uint64(0) for _, counter := range perCpuCounters { sum += counter } return sum } 最后一件事:要访问map的entry,只是定义map数据结构是不够的。其中需要有一些实际值。在AttachType已经讨论的,这是节目的类型(ingress VS egress)。最棘手的部分是CgroupInodeId。该字段是cgroup路径的Inode编号,需要提前读取。 由于这是一个简单的例子,那就先硬编码TARGET_CGROUP_V2_PATH cgroup的值。因此,可以间接写死Inode的ID: // Get cgroup folder inode number to use as a key in the per-cgroup map cgroupFileinfo, err := os.Stat(TARGET_CGROUP_V2_PATH) if err != nil { log.Fatal(err) } cgroupStat, ok := cgroupFileinfo.Sys().(*syscall.Stat_t) if !ok { log.Fatal("Not a syscall.Stat_t") } cgroupInodeId := cgroupStat.Ino 现在,我们来在结构中实际存储一些我们需要的数据。 ingress/egress大小统计 有几种情况需要考虑: 传输的数据包可能位于ingress或egress路径上。 传输的数据包可以是IPv4或IPv6。 所有这些情况都可以在 eBPF内核侧的一个通用函数中简单地处理: inline int handle_skb(struct __sk_buff *skb) { __u16 bytes = 0; // Extract packet size from IPv4 / IPv6 header switch (skb->family) { case AF_INET: { struct iphdr iph; bpf_skb_load_bytes(skb, 0, &iph, sizeof(struct iphdr)); bytes = ntohs(iph.tot_len); break; } case AF_INET6: { struct ip6_hdr ip6h; bpf_skb_load_bytes(skb, 0, &ip6h, sizeof(struct ip6_hdr)); bytes = ntohs(ip6h.ip6_plen); break; } default: // This should never be the case as this eBPF hook is called in // netfilter context and thus not for AF_PACKET, AF_UNIX nor AF_NETLINK // for instance. return true; } // Update counters in the per-cgroup map __u64 *bytes_counter = bpf_get_local_storage(&cgroup_counters_map, 0); __sync_fetch_and_add(bytes_counter, bytes); // Let the packet pass return true; } 实现起来特别简单。最大的难点来自IPv4与IPv6与错误处理。不过,我们关注的主要还是与map相关的那几行。 bpf_get_local_storage()封装实现了(AttachedCgroup, ProgramType, CPU)三元组加载到存储区的功能,直接调用即可。 由于eBPF程序不应该被中断并且数据是per-CPU的,所以__sync_fetch_and_add是个递增原子计数器。在当前代码例子中可能不需要。但上游的运行是支持eBPF抢占的。 这个通用函数可以同时处理ingress和egress两种流量: // Ingress hook - handle incoming packets SEC("cgroup_skb/ingress") int ingress(struct __sk_buff *skb) { return handle_skb(skb); } // Egress hook - handle outgoing packets SEC("cgroup_skb/egress") int egress(struct __sk_buff *skb) { return handle_skb(skb); } eBPF内核态部分现在已经完成。下一步是加载这些程序并将其attach到关注的Cgroup上,并对收集的数据做计算。 attach程序并收集数据 为了监控ingress和egress流量,需要为两个目标cgroup分别attach一次。同样,生成的数据需要在两个方向上进行查询。 为了保持代码简洁,声明一个简单的helper结构体: type BPFCgroupNetworkDirection struct { Name string AttachType ebpf.AttachType } var BPFCgroupNetworkDirections = []BPFCgroupNetworkDirection{ { Name: "ingress", AttachType: ebpf.AttachCGroupInetIngress, }, { Name: "egress", AttachType: ebpf.AttachCGroupInetEgress, }, } 如上代码为每个方向分别定义了attach的类型。 接下来,使用简单的循环将程序从加载的目标cgroup对应collection中读取数据: // Attach program to monitored cgroup for _, direction := range BPFCgroupNetworkDirections { link, err := link.AttachCgroup(link.CgroupOptions{ Path: TARGET_CGROUP_V2_PATH, Attach: direction.AttachType, Program: collec.Programs[direction.Name], }) if err != nil { log.Fatal(err) } defer link.Close() } 然后,用map查询结果: for _, direction := range BPFCgroupNetworkDirections { var perCPUCounters PerCPUCounters mapKey := BpfCgroupStorageKey{ CgroupInodeId: cgroupInodeId, AttachType: direction.AttachType, } if err := cgroup_counters_map.Lookup(mapKey, &perCPUCounters); err != nil { log.Printf("%s: error reading map (%v)", direction.Name, err) } else { log.Printf("%s: %d\n", direction.Name, sumPerCpuCounters(perCPUCounters)) } } 同样,此代码段在预定义的流量卡点上一直循环。然后不停的查询目标方向的流量产生的数据。 查询功能实现在很大程度上依赖于Cilium的eBPF库,用于内核态和Go用户态之间的数据通讯。其他部分代码的都不重要。 测试 现在一切就绪,假设目标cgroup和文件名保持不变,现在可以编译和运行程序。 编译: clang -g -Wall -Werror -O2 -emit-llvm -c bpf-accounting.c -o - | llc -march=bpf -filetype=obj -o bpf-accounting.o llvm-strip -g bpf-accounting.o go build . 要设置测试环境,最简单的方法是打开终端并运行: # Create the Cgroup sudo mkdir -p /sys/fs/cgroup/unified/yadutaf # Register the current shell PID in the cgroup echo $$ | sudo tee /sys/fs/cgroup/unified/yadutaf/cgroup.procs # Start an advanced networking command ping -n 2001:4860:4860::8888 # Or, for IPv4: ping -n 8.8.8.8 # 或者 wget下载几个文件之类的。 最后,再开一个shell: sudo ./bpf-accounting 译者提醒 这里需要提醒一下,上面cgroup的设定是针对执行命令的shell进程的,如果在其他shell进程里执行网络访问行为,那么这个程序就无法收集到流量了。就是说/sys/fs/cgroup/unified/yadutaf/cgroup.procs里的shell进程id是被cgroup监控的,产生网络流量的行为,必须也在这个进程里才行。 运行结果 应该打印如下内容: 2021/08/22 17:20:50 Attaching eBPF monitoring programs to cgroup /sys/fs/cgroup/unified/yadutaf 2021/08/22 17:20:51 ------------------------------------------------------------- 2021/08/22 17:20:51 ingress: 64 2021/08/22 17:20:51 egress: 64 2021/08/22 17:20:52 ------------------------------------------------------------- 2021/08/22 17:20:52 ingress: 128 2021/08/22 17:20:52 egress: 128 2021/08/22 17:20:53 ------------------------------------------------------------- 2021/08/22 17:20:53 ingress: 192 2021/08/22 17:20:53 egress: 192 2021/08/22 17:20:54 ------------------------------------------------------------- 2021/08/22 17:20:54 ingress: 256 2021/08/22 17:20:54 egress: 256 ^C2021/08/22 17:20:54 Exiting... 就酱! 😊 结论 本文通过一个简单的per-cgroup的BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE eBPF类型,对网络吞吐量进行监控,展示了local storage的eBPF虚拟map使用。 总结重点 在性能敏感的上下文中存储per-cgroup测量或状态时使用这个存储方式。 如果性能不是问题,请考虑使用BPF_MAP_TYPE_CGROUP_STORAGE以避免增加管理per-cpu数据的复杂性。 attach到指定Cgroup的所有给定类型的eBPF cgroup将共享相同的数据存储空间。 将eBPF程序attach到每个目标cgroup:此存储与特定附件相关联。 此示例可用作监控应用程序的基础。例如,可以监视Cgroup创建并动态地attach程序。还可以通过 OpenMetrics(Prometheus)等进行数据收集,集成到更大的系统中。 最后,我要感谢Cilium的ebpf社区在我写这篇文章时帮助我修复了库中一个微小但必不可少的错误。 提醒 对于本文中创建的bpf map,可以使用bpftool进行查询,安装方式见编译bpftool工具部分。 root@vmubuntu:/home/cfc4n# bpftool prog show 3: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 4: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 5: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 6: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 7: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:36+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 8: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:36+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 12: cgroup_skb name ingress tag e0be5f67a85710f4 loaded_at 2021-11-09T13:04:27+0000 uid 0 xlated 200B jited 127B memlock 4096B map_ids 6 btf_id 127 pids bpf-accounting(1480) 13: cgroup_skb name egress tag e0be5f67a85710f4 loaded_at 2021-11-09T13:04:27+0000 uid 0 xlated 200B jited 127B memlock 4096B map_ids 6 btf_id 127 pids bpf-accounting(1480) root@vmubuntu:/home/cfc4n# bpftool -p map show id 6 { "id": 6, "type": "percpu_cgroup_storage", "name": "cgroup_counters", "flags": 0, "bytes_key": 16, "bytes_value": 8, "max_entries": 0, "bytes_memlock": 0, "frozen": 0, "pids": [{ "pid": 1480, "comm": "bpf-accounting" } ] } cgroup目录清理: apt install cgroup-tools/hirsute cgdelete unified/yadutaf 或重启系统。 2021-11-09 0 0
[译]使用eBPF(绕过 TCP/IP)加速云原生应用程序的经验教训 译者注 本文翻译自 使用eBPF加速云原生应用程序的经验教训 ,前篇文章为How to use eBPF for accelerating Cloud Native applications,已经由国内博主ArthurChiao翻译+实践整理给出:[译] 利用 ebpf sockmap/redirection 提升 socket 性能 本文对比了eBPF绕过TCP/IP栈与普通TCP协议两种模式下,网络通讯的吞吐量、延迟、事务量等指标。 其技术原理在上篇中已经讲过,本篇的技术难度不大,译者参考原文,进行环境搭建,编码测试,根据自己的测试结果,对原文进行更加深刻的解读。 eBPF与主机安全 译者从事IDC的安全防御检测类产品研发,包括主机层面的入侵检测系统等。IDC领域,随着容器化技术不断演进,早已从主机时代转化到容器时代。而主机安全的研发思维,还停留在主机时代。比如: 物理机、虚拟机时代:进程间通讯是IPC、unixdomain socket,而容器化下,是GRPC\HTTP。 容器化时代:利用高效的eBPF技术做网络隔离,重新规划,能否应付得来 容器化时代:同一个宿主机上多个容器通讯,TCP改为本地socket转发(类似本文的技术) 当容器化技术依赖越来越多的内核新技术eBPF后,HIDS产品的检测技术,该如何演进? 木马后门利用了eBPF技术后,端口复用等问题变得相当容易,也没有独立的进程,没特征,HIDS产品该如何去发现? 翻译目的 带着这些问题,译者开始学习eBPF相关知识,为了更快更好的掌握eBPF,译者采用边学边动手的方式,来加快对新技术的理解,加深知识点的记忆。在学习过程过,感谢博主ArthurChiao 的一系列博文。 译者的开发环境 MacOS Big Sur 11.6 VMware Fusion 12.1.2 UBUNTU 21.04 server版的虚拟服务器(分配2核CPU,4G内存) 安装步骤 镜像地址: http://tw.archive.ubuntu.com/ubuntu-cd/hirsute/ubuntu-21.04-live-server-amd64.iso 安装相应工具包、类库 sudo apt-get install -y make gcc libssl-dev bc libelf-dev libcap-dev clang gcc-multilib llvm libncurses5-dev git pkg-config libmnl-dev bison flex graphviz sudo apt-get install -y make gcc clang llvm git pkg-config dpkg-dev gcc-multilib 修改apt源 修改源 /etc/apt/sources.list ,便于下载kernel源码 deb-src http://archive.ubuntu.com/ubuntu hirsute main deb-src http://archive.ubuntu.com/ubuntu hirsute- main sudo su apt update apt-get source linux-image-$(uname -r) apt-get source linux-image-unsigned-$(uname -r) apt install libbfd-dev libcap-dev zlib1g-dev libelf-dev libssl-dev 编译 bpftool 工具 cd /home/cfc4n/download/linux-5.11.0/tools/bpf/bpftool make sudo make install 编译bpf字节码 cd ~/project/os-eBPF/sockredir cfc4n@vmubuntu:~/project/os-eBPF/sockredir$ clang -O2 -g -target bpf -I /usr/include/linux/ -I /usr/src/linux-headers-5.11.0-37/include/ -c bpf_sockops_v4.c -o bpf_sockops_v4.o 加载bpf字节码 root@vmubuntu:/home/cfc4n/project/os-eBPF/sockredir# bpftool prog load bpf_sockops_v4.o "/sys/fs/bpf/bpf_sockops" libbpf: elf: skipping unrecognized data section(8) .rodata.str1.1 root@vmubuntu:/home/cfc4n/project/os-eBPF/sockredir# bpftool cgroup attach "/sys/fs/cgroup/unified/" sock_ops pinned "/sys/fs/bpf/bpf_sockops" 测试工具 netperf是本文的测试工具,安装方式 sudo apt install netperf netperf -V Netperf version 2.7.0 操作系统版本 uname -a Linux vmubuntu 5.11.0-37-generic #41-Ubuntu SMP Mon Sep 20 16:39:20 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux Linux内核TCP参数 拥塞控制 cat /proc/sys/net/ipv4/tcp_congestion_control cubic 接收缓冲区 buffer size 最小、默认、最大 cfc4n@vmubuntu:~$ cat /proc/sys/net/ipv4/tcp_rmem 4096 131072 6291456 发送缓冲区 cfc4n@vmubuntu:~$ cat /proc/sys/net/ipv4/tcp_wmem 4096 16384 4194304 eBPF TCP链路绕过实现 译文与原文 展示了如何编写简单的BPF程序实现socket level 重定向(redirection)。对于源和目的端都在同一台宿主机器的应用来说,可以实现绕过整个TCP/IP协议栈,直接将数据发送到 socket对端。效果如下图 译者注:在大公司的基础组件部门,可以在宿主机上,自动实现网络加速,不需要业务层根据不同环境而去分别实现unix domain socket和TCP两种sockt。大大减轻业务压力,提升基础架构部门的效率。 原文 我们多数客户对我们的服务有极高的性能SLA需求,我们也不断投资于性能增强以提高我们的服务质量。我们研究的其中一项技术就是eBPF,用于加速后端各种微服务之间的通信。 由于业界很少有文章全面介绍如何使用eBPF,我们决定与大家分享我们学到的知识。 这是由两部分组成的博客系列的第二部分,我们将在其中详细分享我们如何利用 eBPF 进行网络加速以及我们在此过程中所做的一些有趣观察。 在之前的博文中,我们分享 如何编写用于套接字数据重定向的 eBPF代码 以及如何使用 bpftool 将生成的eBPF字节码注入内核。在这篇文章中,我们深入探讨了从常规 TCP/IP 切换到eBPF后的各种性能提升。 使用 eBPF 进行网络加速——性能评估 一旦我们可以使用eBPF实现绕过TCP/IP堆栈,就必须要看到性能提升后的效果。为此,我们使用了较为知名的测量网络性能的工具netperf来评估吞吐量、延迟和事务率方面的收益。我们在以下两种情况下使用netperf评估了这些指标 : 流量正常通过TCP/IP堆栈, 流量使用eBPF sockhash映射重定向绕过TCP/IP堆栈 测试配置 测试设置如下: 在 MacOS High Sierra 上运行的 VirtualBox 6.1 上的 Ubuntu Bionic 18.04.03 LTS Linux 内核 5.3.0-40-generic TCP 设置 拥塞控制:cubic recv() 调用的默认缓冲区大小:131072 Bytes send() 调用的默认缓冲区大小:16384 Bytes MSS最大报文段长度(Maximum Segment Size):65483 Bytes netperf 2.6.0 测试步骤 网络吞吐测试 使用netserver服务端和netperf客户端进行60秒的各种发送消息大小的吞吐量测试: netserver -p 1000 netperf -H 127.0.0.1 -p 1000 -l 60 -- -m $msg_size 网络延迟测试 使用netperf命令,执行时长参数为60秒的各种请求和响应消息大小,进行延迟测量(第 50、90 和 99%): netserver -p 1000 netperf -P 0 -t TCP_RR -H 127.0.0.1 -p 1000 -l 60 -- -r $req_size, $resp_size -o P50_LATENCY, P90_LATENCY, P99_LATENCY 网络事务率测试 使用netperf命令来测试60秒运行的各种请求和响应消息大小的事务率: netserver -p 1000 netperf -t TCP_RR -H 127.0.0.1 -p 1000 -l 60 -- -r $req_size, $resp_size 测试结果 网络吞吐量结果 吞吐量:eBPF sockhash修改过的TCP/IP与常规TCP/IP比较 上图显示了eBPF sockhash修改过的TCP/IP与常规TCP/IP堆栈的吞吐量对比。比较有趣得是吞吐量的性能增长对于使用eBPF的发送请求消息大小是线性的。因为当应用程序在发送较大的消息时,几乎没有开销。 这结果与大家的认知不一致:会好奇为什么与常规TCP/IP路径相比吞吐量性能很差。事实证明,罪魁祸首是默认情况下在 TCP/IP堆栈中启用了Nagle算法。引入Nagle算法是为了解决小数据包淹没慢速网络从而导致拥塞的问题。因为如果它小于TCP MSS的大小,算法只需要一个TCP段是未完成的(未确认),在我们的测试中,这会导致TCP对数据进行批量传输。这种批处理会导致更多的数据传输和分摊开销,并且能够超过eBPF绕过TCP/IP的性能收益,它对每个发送调用缓冲区大小有固定的开销(见图7)。 当数据包大小接近TCP MSS时,常规TCP/IP没有合并发送的优势,TCP MSS中可以容纳的数据包更少(测试环境的MSS设置为65483 bytes)并通过以下方式发送到目的地TCP/IP内核堆栈。在这些大数据包发送大小下,eBPF凭借其低开销远远超过使用Nagle算法的TCP/IP堆栈的吞吐。接下来禁用Nagle算法(-D参数)来netperf重新测试: netserver -p 1000 netperf -H 127.0.0.1 -p 1000 -l 60 -- -m $msg_size -D 吞吐量:TCP/IP(禁用Nagle算法)与使用 eBPF sockhash 染过TCP/IP 禁用Nagle算法后,我们看到与eBPF sockhash重定向绕过相比,常规TCP的吞吐量优势完全消失。TCP/IP堆栈和eBPF sockhash映射的性能如预期的那样线性增加----eBPF比常规 TCP/IP具有更大的成长曲线,因为eBPF每次发送调用的固定成本开销。对于较大的发送消息大小和较小的 TCP MSS,这种性能差距更为明显。 译者测试结果 译者在测试时,把三种测试都集中在一张图里了。但测试使用TCP Nagle算法与eBPF绕过的差异并没有很大,而且也没有曲线上的交叉。译者暂时不清楚其中原因,大概是我测试的环境是macbook,恰恰同时我还在使用这台笔记本工作,导致了CPU、网络等不稳定吧。 吞吐量:eBPF VS TCP VS TCP-Disable-Nagle的比较 动态趋势图: 延迟测试结果 使用netperf重复我们的性能运行来测量延迟。我们测量了延迟的第50个、第90个和第99 个百分位数,这会改变发送和接收缓冲区(消息)的大小。测试结果里,使用中位数的延迟来显示趋势图。此外,我们还展示了64字节和256字节的不同请求消息大小的趋势。(请注意:netperf一次只有一个事务未完成。) 延迟测试:使用 eBPF sockhash绕过TCP/IP与常规TCP/IP 如图所示,eBPF sockhash绕过优于常规TCP/IP堆栈。其性能比常规 TCP/IP 堆栈高出近50%。考虑到eBPF通过将数据包从源socket的传输队列重定向到目标socket的接收队列,与TCP/IP中的那些相比,eBPF消除了任何协议级别的开销(慢启动、拥塞避免、流量控制等),这些优势带来的结果是显而易见的。此外,请求消息长度对延迟没有影响。我们没有尝试测量接近TCP MSS 的发送消息大小的延迟。 译者测试结果 译者对这个延迟指标也进行了测试,如下图 eBPF sockhash重定向与常规TCP堆栈的延迟对比 不管是64字节还是256字节的发送消息大小上,都比原生TCP的网络延迟更低。 虽然图表上的结果与原作者测试结果有一定差异,但整体分布比较均衡,也很清楚的体现出eBPF在低延迟上的优势。 动态趋势图 网络事务率 接下来,我们使用netperf继续测试事务率: 如果我们简单地将事务率曲线向左翻转,它们的趋势与延迟测量相同。 事务率:使用 eBPF sockhash重定向与TCP/IP 如图,针对不同的接收端消息大小绘制了各种发送消息请求大小的事务率。在延迟测量的情况下,我们看到事务率不受常规TCP/IP和eBPF sockhash重定向绕过以及请求响应消息大小的影响。 不同请求大小的事务率:TCP/IP vs TCP/IP bypass using eBPF sockhash map 译者测试结果 译者也进行了测试,只是测试环境不太稳定,macbook开的VM虚拟机,在性能测试的时候,同时还在用于办公。测试结果波动比较大,但总体来看,还是能从点的分布看得出eBPF与常规TCP的区别,深色的点是eBPF的结果,都分布在Y轴上面。浅色点是常规TCP的结果,都分布在Y轴下面。如下图: 动态图表 eBPF模式下网络吞吐量测试的内核开销 在下图中,我们绘制了当执行netperf吞吐量实验时eBPF sockhash映射绕过在内核中花费的时间。 可以看到对于256字节的发送消息大小,eBPF开销约为1.5秒,并且开销随着发送消息大小的增加而减少。 eBPF sockhash map重定向在内核中花费的时间 综述 eBPF确实是一项强大的技术,它允许从用户空间前所未有地访问Linux内核资源。当应用程序对延迟敏感时,使用eBPF绕过TCP/IP堆栈与同一主机上的另一个程序通信是一个最佳的使用场景。尤其是时下最热门的容器编排领域:在Kubernetes集群的同一pod中使用相互依赖的微服务就特别适合这个功能。也就是说,基于eBPF的TCP/IP绕过可以极大地减轻与使用RPC 和REST API进行大量通信的微服务相关的延迟。 我们还看到,虽然eBPF可以带来强大的性能提升,但盲目使用可能会产生意想不到的后果。具体来说,当我们只关心原始单向吞吐量时,使用默认设置,常规TCP/IP堆栈可以胜过eBPF(Nagle算法合并大量小包的情况)。 译者注:主机安全HIDS的未来趋势 正如本文演示的功能,用eBPF来解决K8S场景中,同一pod的多个docker container之间通讯延迟的问题。那在这种场景下,原来的HIDS产品能否感知到?物理机、虚拟机时代,原来的进程间通讯是IPC、unixdomain socket,而容器化下,是GRPC\HTTP,安全场景上已经有很大变化... 在当下微服务架构日益盛行的时代,必定追求更好性能,更快的速度,更大的负载方向走。而linux kernel的eBPF技术,必定是未来容器化、容器编排领域的热门技术。那么,在信息安全的主机安全领域,HIDS之类产品该何去何从?例如本文中的性能优化,在容器编排场景是特别有价值的使用,甚至已经有了成熟的产品上市。那HIDS还能检测的出来吗? 我们现在使用的单机化、虚拟化的传统思维模式研发安全防护产品,在面对云原生时代容器化的IDC安全问题时,还能跟得上吗?是时候用CIDS来代替HIDS了。 注: HIDS:Host-based Intrusion Detection System CIDS:Container Intrusion Detection System 附 测试脚本 <?php //benchmark.php function testTp($req_size) { $file = 'result/bench_tp_ebpf_r'.$req_size.'_30s'; $strCmd = "netperf -t TCP_RR -H 127.0.0.1 -p 1000 -l 30 -- -r $req_size, %d"; $arrAll = array(); for ($i=1; $i<=60; $i++) { $msg_size = $i*500; $cmd = sprintf($strCmd,$msg_size); echo $file, ": start to exec ",$cmd,"\n"; $arrExec = array(); exec($cmd, $arrExec); $r = array_pop($arrExec); $r = array_pop($arrExec); $r1 = preg_split('/\s+/',$r); $arrAll[$msg_size] = trim($r1[5]); $f = sprintf($file,$msg_size); } var_dump($arrAll); file_put_contents($f,json_encode($arrAll)); } $arrTest = array( 64, 128, 256); $strCmdServer = 'netserver -p 1000'; $rs = system($strCmdServer); var_dump($rs); foreach ($arrTest as $v) { echo "--------------------------------\n"; echo "--------开始测试:$v-----------\n"; echo "--------------------------------\n"; testTp($v); } echo "done\n"; 结果处理 执行./load.sh脚本,启用ebpf,执行php benchmark.php 合并 result/bench_tp_ebpf_*中几个文件,把json的结果转化到csv中 使用plotly.com创建图表,导入csv文件,开始制表画图 后续 我会开始分析Cilium的eBPF源码,了解eBPF在K8s产品中实现原理,更好的研发用eBPF实现行为观测的数据收集,安全防护的CIDS(HIDS)产品 2021-11-01 1 0