内核态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
[译]使用eBPF per-CPU Cgroup本地存储进行低功耗统计 译者注 背景 译者还在学习eBPF,在学习计划中,分了阅读、翻译、模仿、研发等几个阶段。这篇文章是译者学习过程中的第二篇eBPF文章翻译。在阅读过程中,动手练习,理解原文目的、思路、方法。加深对知识点的理解。 本文翻译自Using eBPF per-CPU Cgroup local storage for low overhead accounting,由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。 第一篇翻译文章为:使用 eBPF(并绕过 TCP/IP)加速云原生应用程序的经验教训。 对于本文中涉及的代码,都放在GitHub:ebpf-demo仓库下的bpf-accounting下,请自取。 per-CPU 随着多CPU架构的成熟发展,BPF Map也引入了per-cpu类型,如BPF_MAP_TYPE_PERCPU_HASH、BPF_MAP_TYPE_PERCPU_ARRAY等,当你使用这种类型的BPF Map时,每个CPU都会存储并看到它自己的Map数据,从属于不同CPU之间的数据是互相隔离的,这样做的好处是,在进行查找和聚合操作时更加高效,性能更好,尤其是你的BPF程序主要是在做收集时间序列型数据,如流量数据或指标等。参考 原文 前言 Linux eBPF生态系统正在迅速增长,每个发布周期都会出现新功能。从本质上讲,eBPF是一种通过attach到各种per-defines hooks和导出函数的方式,来编写Linux内核脚本的安全方式。在eBPF中,可以使用各种“map”类型来实现kernel space和user space通信。 每隔几个内核版本,就会引入或改进新的“map”类型,并且在3年前BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE引入,现在可以在4.20以后的内核中使用,git提交日志:bpf: introduce per-cpu cgroup local storage。 译者注: BPF Compiler Collection (BCC)维护了eBPF新特性变化的日志,包含每个特性的增加时间、内核版本号、commit时间等,详情见:BPF Features by Linux Kernel Version 我对它很感兴趣,想通过一个简单的Cgroup网络吞吐量监控应用来了解它。 通常情况下,在资源受限的机器上运行多个工作负载时的实际用例。但事实证明,我很难理解如何快速有效地使用它。之后,我决定写一篇文章,希望可以帮助你快速学习per-CPU eBPF Cgroup local storage。 示例演示 这篇文章将 在用户空间的应用使用Cilium 的ebpf库。当然,在C中使用libbpf或 在 Python 中使用BCC可以轻松实现类似的效果。内核侧eBPF无需更改,可以配合其他任何语言实现的eBPF数据读取应用。 为了监控目标Cgroup入口和出口吞吐量,演示程序将附加到cgroup_skb/ingress和cgroup_skb/egress Cgroup eBPF 钩子。当然,将map类型的result发送到用户空间BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE。 为了保持这篇文章简单并专注于eBPF部分,这篇文章将使用一个硬编码的cgroup地址。 示例代码 如下代码,这是eBPF内核态程序的概要。以一个伪注释开始,以便让go编译器不要编译它。然后是 linux内核以及BPF helper等各种头文件。 license可能是多余的,但eBPF程序与普通c内核模块遵循相同的规则。通常,某些符号导出受“仅GPL”约束(或兼容)。 C代码 // +build ignore #include <stdbool.h> #include <linux/bpf.h> #include <bpf/bpf_helpers.h> #include <netinet/ip.h> #include <netinet/ip6.h> // --------------------------------------------- // -- The real program will be somewhere here -- // --------------------------------------------- char __license[] __attribute__((section("license"), used)) = "MIT"; 编译器clang(llvm)将这个C文件编译为eBPF的字节码文件: clang -g -Wall -Werror -O2 -emit-llvm -c bpf-accounting.c -o - | llc -march=bpf -filetype=obj -o bpf-accounting.o 当然,需要先安装Clang,以及linux kernel对应的头文件,安装方法可以参考上篇译者的eBPF开发环境 go代码 这是现在的Go代码模版。它主要包含设置阶段和监控阶段两部分代码,监控阶段每秒运行一次,按“Ctrl+C”或TERM信号终止运行。 设定好TARGET_CGROUP_V2_PATH,确保关注的是Cgroup V2版本。在笔者开发环境的路径是/sys/fs/cgroup/unified前缀,因系统而异,这里是Ubuntu,但其Systemd配置仍使用v1的Cgroup。 如果bpf-accounting.c里的eBPF部分代码没有被调用,那么EBPF_PROG_ELF加载部分,就需要好好调整。 package main import ( "log" "os" "os/signal" "syscall" "time" "github.com/cilium/ebpf" "github.com/cilium/ebpf/link" "golang.org/x/sys/unix" ) const ( TARGET_CGROUP_V2_PATH = "/sys/fs/cgroup/unified/yadutaf" // <--- Change as needed for your system/use-case EBPF_PROG_ELF = "./bpf-accounting.o" ) func main() { log.Printf("Attaching eBPF monitoring programs to cgroup %s\n", TARGET_CGROUP_V2_PATH) // ------------------------------------------------------------ // -- The real program initialization will be somewhere here -- // ------------------------------------------------------------ // Wait until signaled c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT) signal.Notify(c, syscall.SIGTERM) // Periodically check counters ticker := time.NewTicker(1 * time.Second) out: for { select { case <-ticker.C: log.Println("-------------------------------------------------------------") // ------------------------------------------ // -- And here will be the counters report -- // ------------------------------------------ case <-c: log.Println("Exiting...") break out } } } 数据结构 现在介绍一下BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE类型。但请先思考一个问题,为什么不使用最典型、更容易使用的BPF_MAP_TYPE_HASHmap类型? 对于本文的用例,BPF_MAP_TYPE_HASH将会特别适合。但是,如果应用程序需要监视所有cgroup,并且在创建动态时attach,或在Cgroup终止时清除相关资源,BPF_MAP_TYPE_CGROUP_STORAGE类型则可能是更合适的选择。 但是,如果可以从多个CPU并发访问收集的数据,那么per-CPU对应的BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE类型是避免自旋锁的最佳选择。 从原理上看,BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE实际上是一个/virtual/映射,或更准确地说是叫Cgrouplocal storage本地存储的内核术语。在内部,Cgroup数据结构有一个专用于cgroup本地存储的成员。因此,指定类型的程序将为指定的Cgroup共享相同的存储区域。默认情况下,不同类型的程序在Cgroup中都会有一个固定的存储区域。然而,可以在指定的Cgroup的所有程序类型之间共享这个存储区域。 几个要点: 根据设计,map(虚拟)的key在概念上是一个(cgroup_id, program_type)元组。这不是固定的。比如,叶成员类型是灵活的。 如果多个相同类型的程序需要在同一个Cgroup中存储数据,那么必须配合进行叶子成员类型定义。 程序只能访问它们所连接的cgroup的存储区域。换句话说,即使由子Cgroup foo/bar中的事件触发,attach到Cgroup的程序foo也只会访问Cgroup的存储foo的值。如果需要专门跟踪子 Cgroup事件,则还需要将相同的代码逻辑attach到关注的Cgroup。 提醒一下BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE: 由于per-CPU是map类型,因此group中的存储不是叶子成员类型,而是这种类型的per-CPU数组。这对程序的eBPF部分是完全透明的,只有它当前的cgroup+CPU视图。然而,它需要对Go部分进行特殊处理,来获取每个cgroup+CPU存储的完整数据视图。 eBPF逻辑实现 对于本文,有两种业务目标 (cgroup_skb/ingress和 cgroup_skb/egress)来匹配incomming和outgoing流量。由于目标是测量每个方向传输的字节总数,因此叶子成员类型可以是uint64。 内核态C代码 在eBPF端,结构声明很简单: struct bpf_map_def SEC("maps") cgroup_counters_map = { .type = BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE, .key_size = sizeof(struct bpf_cgroup_storage_key), .value_size = sizeof(__u64), }; 这定义了 eBPF map cgroup_counters_map类型BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE,其主键struct bpf_cgroup_storage_key的类型为bpf.h中定义的类型,为无符号uint64,用它作为map的member来保存字节大小。 用户态Go代码 用户态的逻辑代码方面涉及更多,但别担心。首先,程序需要增加最大“locked”内存。虽然这个例子来说不是必需的,这里只是普通的字节大小计数器,不会占用啥太大内存。但在使用eBPF的场景里,这里可能是最容易疏忽的陷阱。因此,需要实现为: // Increase max locked memory (for eBPF maps) // For a real program, make sure to adjust to actual needs unix.Setrlimit(unix.RLIMIT_MEMLOCK, &unix.Rlimit{ Cur: unix.RLIM_INFINITY, Max: unix.RLIM_INFINITY, }) 现在需要应用层调用内核eBPF接口加载eBPF字节码的eBPF ELF文件。这里是Cilium的ebpf库的亮点。解析ELF字节码文件并提取map定义和所有程序metadata “collection”: collec, err := ebpf.LoadCollection(EBPF_PROG_ELF) if err != nil { log.Fatal(err) } 从collection中获取map的句柄非常简单: // Get a handle on the statistics map cgroup_counters_map := collec.Maps["cgroup_counters_map"] 在访问map时,程序需要key和member类型定义。重点是,Cilium的库还没有内置定义。由于key format是由内核的ABI强加的,因此需要按照struct bpf_cgroup_storage_key 从内核文档:BPF_MAP_TYPE_CGROUP_STORAGE 完成结构体的解析,以及注意32位虚拟字段的对齐问题: type BpfCgroupStorageKey struct { CgroupInodeId uint64 AttachType ebpf.AttachType _ uint32 } go程序需要定义成员类型。由于程序使用per-CPU数据结构,这需要是一个slice: type PerCPUCounters []uint64 由于我们只对传输的字节总数感兴趣,并不关注其他事情,那么直接for循环slice,进行相加即可。: func sumPerCpuCounters(perCpuCounters PerCPUCounters) uint64 { sum := uint64(0) for _, counter := range perCpuCounters { sum += counter } return sum } 最后一件事:要访问map的entry,只是定义map数据结构是不够的。其中需要有一些实际值。在AttachType已经讨论的,这是节目的类型(ingress VS egress)。最棘手的部分是CgroupInodeId。该字段是cgroup路径的Inode编号,需要提前读取。 由于这是一个简单的例子,那就先硬编码TARGET_CGROUP_V2_PATH cgroup的值。因此,可以间接写死Inode的ID: // Get cgroup folder inode number to use as a key in the per-cgroup map cgroupFileinfo, err := os.Stat(TARGET_CGROUP_V2_PATH) if err != nil { log.Fatal(err) } cgroupStat, ok := cgroupFileinfo.Sys().(*syscall.Stat_t) if !ok { log.Fatal("Not a syscall.Stat_t") } cgroupInodeId := cgroupStat.Ino 现在,我们来在结构中实际存储一些我们需要的数据。 ingress/egress大小统计 有几种情况需要考虑: 传输的数据包可能位于ingress或egress路径上。 传输的数据包可以是IPv4或IPv6。 所有这些情况都可以在 eBPF内核侧的一个通用函数中简单地处理: inline int handle_skb(struct __sk_buff *skb) { __u16 bytes = 0; // Extract packet size from IPv4 / IPv6 header switch (skb->family) { case AF_INET: { struct iphdr iph; bpf_skb_load_bytes(skb, 0, &iph, sizeof(struct iphdr)); bytes = ntohs(iph.tot_len); break; } case AF_INET6: { struct ip6_hdr ip6h; bpf_skb_load_bytes(skb, 0, &ip6h, sizeof(struct ip6_hdr)); bytes = ntohs(ip6h.ip6_plen); break; } default: // This should never be the case as this eBPF hook is called in // netfilter context and thus not for AF_PACKET, AF_UNIX nor AF_NETLINK // for instance. return true; } // Update counters in the per-cgroup map __u64 *bytes_counter = bpf_get_local_storage(&cgroup_counters_map, 0); __sync_fetch_and_add(bytes_counter, bytes); // Let the packet pass return true; } 实现起来特别简单。最大的难点来自IPv4与IPv6与错误处理。不过,我们关注的主要还是与map相关的那几行。 bpf_get_local_storage()封装实现了(AttachedCgroup, ProgramType, CPU)三元组加载到存储区的功能,直接调用即可。 由于eBPF程序不应该被中断并且数据是per-CPU的,所以__sync_fetch_and_add是个递增原子计数器。在当前代码例子中可能不需要。但上游的运行是支持eBPF抢占的。 这个通用函数可以同时处理ingress和egress两种流量: // Ingress hook - handle incoming packets SEC("cgroup_skb/ingress") int ingress(struct __sk_buff *skb) { return handle_skb(skb); } // Egress hook - handle outgoing packets SEC("cgroup_skb/egress") int egress(struct __sk_buff *skb) { return handle_skb(skb); } eBPF内核态部分现在已经完成。下一步是加载这些程序并将其attach到关注的Cgroup上,并对收集的数据做计算。 attach程序并收集数据 为了监控ingress和egress流量,需要为两个目标cgroup分别attach一次。同样,生成的数据需要在两个方向上进行查询。 为了保持代码简洁,声明一个简单的helper结构体: type BPFCgroupNetworkDirection struct { Name string AttachType ebpf.AttachType } var BPFCgroupNetworkDirections = []BPFCgroupNetworkDirection{ { Name: "ingress", AttachType: ebpf.AttachCGroupInetIngress, }, { Name: "egress", AttachType: ebpf.AttachCGroupInetEgress, }, } 如上代码为每个方向分别定义了attach的类型。 接下来,使用简单的循环将程序从加载的目标cgroup对应collection中读取数据: // Attach program to monitored cgroup for _, direction := range BPFCgroupNetworkDirections { link, err := link.AttachCgroup(link.CgroupOptions{ Path: TARGET_CGROUP_V2_PATH, Attach: direction.AttachType, Program: collec.Programs[direction.Name], }) if err != nil { log.Fatal(err) } defer link.Close() } 然后,用map查询结果: for _, direction := range BPFCgroupNetworkDirections { var perCPUCounters PerCPUCounters mapKey := BpfCgroupStorageKey{ CgroupInodeId: cgroupInodeId, AttachType: direction.AttachType, } if err := cgroup_counters_map.Lookup(mapKey, &perCPUCounters); err != nil { log.Printf("%s: error reading map (%v)", direction.Name, err) } else { log.Printf("%s: %d\n", direction.Name, sumPerCpuCounters(perCPUCounters)) } } 同样,此代码段在预定义的流量卡点上一直循环。然后不停的查询目标方向的流量产生的数据。 查询功能实现在很大程度上依赖于Cilium的eBPF库,用于内核态和Go用户态之间的数据通讯。其他部分代码的都不重要。 测试 现在一切就绪,假设目标cgroup和文件名保持不变,现在可以编译和运行程序。 编译: clang -g -Wall -Werror -O2 -emit-llvm -c bpf-accounting.c -o - | llc -march=bpf -filetype=obj -o bpf-accounting.o llvm-strip -g bpf-accounting.o go build . 要设置测试环境,最简单的方法是打开终端并运行: # Create the Cgroup sudo mkdir -p /sys/fs/cgroup/unified/yadutaf # Register the current shell PID in the cgroup echo $$ | sudo tee /sys/fs/cgroup/unified/yadutaf/cgroup.procs # Start an advanced networking command ping -n 2001:4860:4860::8888 # Or, for IPv4: ping -n 8.8.8.8 # 或者 wget下载几个文件之类的。 最后,再开一个shell: sudo ./bpf-accounting 译者提醒 这里需要提醒一下,上面cgroup的设定是针对执行命令的shell进程的,如果在其他shell进程里执行网络访问行为,那么这个程序就无法收集到流量了。就是说/sys/fs/cgroup/unified/yadutaf/cgroup.procs里的shell进程id是被cgroup监控的,产生网络流量的行为,必须也在这个进程里才行。 运行结果 应该打印如下内容: 2021/08/22 17:20:50 Attaching eBPF monitoring programs to cgroup /sys/fs/cgroup/unified/yadutaf 2021/08/22 17:20:51 ------------------------------------------------------------- 2021/08/22 17:20:51 ingress: 64 2021/08/22 17:20:51 egress: 64 2021/08/22 17:20:52 ------------------------------------------------------------- 2021/08/22 17:20:52 ingress: 128 2021/08/22 17:20:52 egress: 128 2021/08/22 17:20:53 ------------------------------------------------------------- 2021/08/22 17:20:53 ingress: 192 2021/08/22 17:20:53 egress: 192 2021/08/22 17:20:54 ------------------------------------------------------------- 2021/08/22 17:20:54 ingress: 256 2021/08/22 17:20:54 egress: 256 ^C2021/08/22 17:20:54 Exiting... 就酱! 😊 结论 本文通过一个简单的per-cgroup的BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE eBPF类型,对网络吞吐量进行监控,展示了local storage的eBPF虚拟map使用。 总结重点 在性能敏感的上下文中存储per-cgroup测量或状态时使用这个存储方式。 如果性能不是问题,请考虑使用BPF_MAP_TYPE_CGROUP_STORAGE以避免增加管理per-cpu数据的复杂性。 attach到指定Cgroup的所有给定类型的eBPF cgroup将共享相同的数据存储空间。 将eBPF程序attach到每个目标cgroup:此存储与特定附件相关联。 此示例可用作监控应用程序的基础。例如,可以监视Cgroup创建并动态地attach程序。还可以通过 OpenMetrics(Prometheus)等进行数据收集,集成到更大的系统中。 最后,我要感谢Cilium的ebpf社区在我写这篇文章时帮助我修复了库中一个微小但必不可少的错误。 提醒 对于本文中创建的bpf map,可以使用bpftool进行查询,安装方式见编译bpftool工具部分。 root@vmubuntu:/home/cfc4n# bpftool prog show 3: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 4: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 5: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 6: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:31+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 7: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:36+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 8: cgroup_skb tag 6deef7357e7b4530 gpl loaded_at 2021-11-09T13:03:36+0000 uid 0 xlated 64B jited 54B memlock 4096B pids systemd(1) 12: cgroup_skb name ingress tag e0be5f67a85710f4 loaded_at 2021-11-09T13:04:27+0000 uid 0 xlated 200B jited 127B memlock 4096B map_ids 6 btf_id 127 pids bpf-accounting(1480) 13: cgroup_skb name egress tag e0be5f67a85710f4 loaded_at 2021-11-09T13:04:27+0000 uid 0 xlated 200B jited 127B memlock 4096B map_ids 6 btf_id 127 pids bpf-accounting(1480) root@vmubuntu:/home/cfc4n# bpftool -p map show id 6 { "id": 6, "type": "percpu_cgroup_storage", "name": "cgroup_counters", "flags": 0, "bytes_key": 16, "bytes_value": 8, "max_entries": 0, "bytes_memlock": 0, "frozen": 0, "pids": [{ "pid": 1480, "comm": "bpf-accounting" } ] } cgroup目录清理: apt install cgroup-tools/hirsute cgdelete unified/yadutaf 或重启系统。 2021-11-09 0 0