学习一个生产级别的项目代码,是提升技术深度的有效途径。Sysmon for Linux 就是这样一款由微软开源的系统监控工具,它能够记录详细的系统活动,包括进程生命周期、网络连接以及文件系统写入等操作。本文将通过分析其源码,探讨其中的 eBPF 编程实践与设计考量。
https://github.com/microsoft/SysmonForLinux
DOC
项目文档位于 doc 目录,其中解释了许多核心设计决策,非常值得一读。
https://github.com/microsoft/SysmonForLinux/tree/main/doc
例如,在 examples 目录下的一个头文件中,定义了一个事件结构体:
https://github.com/microsoft/SysmonForLinux/blob/main/doc/examples/openat_example/event_defs.h
// Event structure
typedef struct {
pid_t pid;
uint64_t flags;
uint64_t mode;
char filename[4096];
} event_s;
你可能会好奇:为什么 filename 字段要固定定义为 [4096] 这么大的数组?这其实是一个 eBPF 编程中的实用技巧,目的是为了绕过 eBPF 验证器(Verifier)的安全检查,从而安全地读取那些长度可变的字符串数据(比如文件名)。
eBPF 虚拟机出于安全考虑,规则非常严格。它规定:“程序只能访问其声明的结构体范围内的内存。” 如果你定义的结构体只有 100 字节,而实际要读取的字符串起始于第 104 字节,即使该内存是合法可访问的,eBPF 验证器也会直接判定为越界访问并拒绝加载该程序。
这通常与内核 Tracepoint 的数据格式有关。以 sched_process_exec 事件为例,其格式定义中包含了 __data_loc 类型的字段:
name: sched_process_exec
ID: 289
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:__data_loc char[] filename; offset:8; size:4; signed:1;
field:pid_t pid; offset:12; size:4; signed:1;
field:pid_t old_pid; offset:16; size:4; signed:1;
print fmt: "filename=%s pid=%d old_pid=%d", __get_str(filename), REC->pid, REC->old_pid
__data_loc 类型的字段是一个 32 位整数,它将数据的长度 (Length) 和偏移量 (Offset) 信息“打包”在了一起:
- 高 16 位:表示数据的长度(需要读取的字节数)。
- 低 16 位:表示数据相对于该事件记录起始位置的偏移量。
我们可以通过一个简化的内存布局来理解:
数据包位置: 00 01 02 03 04 05 06 07 08 09 10 11 ... 20 21 22 23 24
数据内容: [ ...头部..] [ PID ] [__data_loc] ... [ “h““e““l““l““o” ]
值: ... ... ... 00 00 12 34 00 05 00 14 ... 68 65 6C 6C 6F
假设读取到 __data_loc 字段的值为 0x00050014。
- 计算长度:
0x00050014 >> 16 = 0x0005,表示后续字符串长度为 5 个字节。
- 计算位置:
0x00050014 & 0xFFFF = 0x0014(十进制 20),表示字符串位于距记录开头 20 字节处。
- 读取操作:程序跳转到第 20 字节处,读取 5 个字节,得到 “hello”。
由于在实际读取前,我们无法预知 __data_loc 指向的字符串具体有多长,为了确保能够完整读取,在 eBPF 端的结构体定义中会预留一个足够大的缓冲区(如 4096 字节)。这是一种以空间换取确定性和绕过验证器的常见手法。
项目文档中另一个关键部分详细阐述了在 eBPF 中读取用户空间数据时面临的挑战及解决方案:
https://github.com/microsoft/SysmonForLinux/blob/main/doc/04_reading_memory.md
文档指出,在 sys_enter_execve 这类系统调用入口挂钩点直接读取用户传递的参数指针经常会失败,主要原因如下:
- 缺页异常:用户空间的内存可能采用“惰性分配”,或者被交换到磁盘。当 CPU 访问这类内存时,硬件会触发缺页异常,操作系统通常会挂起当前进程以处理异常。
- eBPF 的局限性:eBPF 程序运行在不可中断的上下文中,它既不能等待,也无法处理缺页异常。
- 后果:如果 eBPF 尝试读取的页面不在物理内存中,操作将直接失败,导致监控数据丢失。
为了解决这个可靠性问题,Sysmon for Linux 在架构上做出了重要权衡:
- 避免使用系统调用入口点:虽然
sys_enter 挂钩点能直接获取用户参数指针,但因上述缺页问题,数据往往无法稳定读取。
- 选择更深层的内核 Tracepoint:Sysmon 倾向于在内核处理流程的更深阶段进行挂钩,例如在
sched_process_exec 或 bprm_check_security 附近。
- 原因:到达这些位置时,内核 通常已经将用户空间的数据(如命令行参数)复制到了内核空间的缓冲区(例如
struct linux_binprm 结构体中)。
- 优势:此时数据位于内核地址空间,必然常驻内存,使用
bpf_probe_read_kernel 可以稳定、可靠地读取,彻底规避了因缺页异常导致的数据丢失风险。
文档中还涉及了如何避免验证器优化等其他高级主题,值得进一步探索。
代码分析
该项目的 eBPF 内核态代码主要集中在 ebpfKern 目录下:
https://github.com/microsoft/SysmonForLinux/tree/main/ebpfKern
首先需要关注三个重要的模板文件:
https://github.com/microsoft/SysmonForLinux/blob/main/ebpfKern/sysmonTEMPLATE.c
https://github.com/microsoft/SysmonForLinux/blob/main/ebpfKern/sysmonTEMPLATE_rawtp.c
https://github.com/microsoft/SysmonForLinux/blob/main/ebpfKern/sysmonTEMPLATE_tp.c
这些模板用于根据事件名称(eventname)自动生成对应的监控函数。当开发人员需要添加新的事件监控时,可以利用项目提供的脚本快速生成基础代码框架。例如:
# 假设要添加一个名为“FileAccess”的新事件监控
./makeEvent.sh FileAccess
这解释了为什么项目中许多功能相关的文件总是以三个为一组出现。
我们以 ProcCreate 事件的监控为例。由于其设计本身高度结构化,我们主要关注其轮廓而非深入每一行代码:
ebpfKern/sysmonProcCreate.c:负责采集和储存进程创建事件的信息。
ebpfKern/sysmonProcCreate_rawtp.c:在 raw tracepoint 的退出点执行相关操作。
ebpfKern/sysmonProcCreate_tp.c:在常规 tracepoint 处执行操作。
值得注意的是,sysmonProcCreate_tp.c 使用了自定义的 ELF 节区(SEC)。这意味着它需要特定的逻辑来决定是否以及如何加载对应的内核态代码。
再看用户态的主程序 sysmonforlinux.c。其中,ebpfTelemetryObject 结构体及其相关逻辑处理了不同内核版本下应该启用哪些具体的监控功能。
如果启用的是基于 tracepoint 的挂载点,就需要走自定义 SEC 的加载逻辑。这部分功能,该项目依赖于另一个 开源实战 项目:
https://github.com/microsoft/SysinternalsEBPF/blob/main/libsysinternalsEBPF.h
这个头文件提供了一系列辅助函数,用于解析目标文件(.o)中的自定义节区并完成加载。
总结
总体而言,Sysmon for Linux 是一个设计严谨、考虑了生产环境复杂性的 eBPF 监控项目。对于 eBPF 初学者来说,其代码可能显得过于复杂,但其中蕴含的编程技巧和设计思想极具参考价值。建议初学者可以先研究其中的示例,待积累一定实践经验后再来深入剖析其整体架构,这将对理解 eBPF 在真实世界中的应用大有裨益。如果你对这类系统级编程和监控技术感兴趣,欢迎到 云栈社区 交流探讨更多相关话题。