
平时在做 Linux 安全审计或做云原生安全组件时经常会有一个疑问能不能在不写内核模块的情况下也拥有内核态的监控和拦截能力传统内核模块工作在内核态ring-0权限大风险也大一旦崩溃就可能直接拖垮整机。而 eBPF 提供了一种相对安全的“内核可编程”方式再加上 Linux 自带的 LSM 钩子与 IMA 完整性架构我们完全可以在实验环境里实现一个很小的玩具杀毒模块。这篇文章会围绕一个非常有意思的项目标题展开Build a crappy ring-0 toy antivirus in eBPF with the IMA LSM。我会先拆解 eBPF、LSM、IMA、ring-0 这些概念然后带你在自己的虚拟机里搭建环境编写一个能拦截指定文件打开的 eBPF LSM 程序再用完整的示例代码跑通整个流程。最后会聊聊 IMA 如何参与“完整性度量 文件拦截”以及为什么这个方案只能算“玩具”、生产级杀毒还需要补齐哪些能力。如果你对 eBPF 安全、容器安全、Linux 文件访问控制感兴趣或者只是想把内核安全机制搞清楚一点这篇实战记录应该会对你有帮助。1. 项目背景与核心概念1.1 ring-0 与杀毒软件的关系x86 架构里CPU 的指令执行分为多个特权级别通常叫 protection ring。最核心的内核运行在 ring 0用户进程运行在 ring 3。杀毒软件如果只能在用户态跑能看到的系统视野就很有限它看不到其他进程打开文件时的完整内核路径也难以拦截系统调用。所以传统杀毒软件往往需要内核驱动把自己的逻辑放进 ring 0 里。这样做确实能拿到足够高的权限但也带来了稳定性风险内核驱动是 C 语言运行在最高特权级一旦有内存越界、空指针、睡眠锁问题蓝屏或者 panic 是常有的事。这也是很多人对内核杀软又爱又恨的原因。eBPF 的出现改变了这个局面。它允许你把一段经过验证的小程序加载进内核在指定事件触发时执行。整段程序会经过 BPF 验证器的严格检查能访问的内核数据结构也有限内存读写必须通过 helper 函数来完成。这样一来我们既获得了“运行在内核态”的观察能力又不会像传统 LKM 那样拥有一击击溃整个系统的能力。标题里的 ring-0 toy antivirus其实就是利用 eBPF 程序运行在内核上下文这个特点做一个低风险的实验性杀毒模块。1.2 LSM 与 Linux 安全模块LSM 的全称是 Linux Security Module它是 Linux 内核提供的一组安全钩子。内核在文件打开、inode 创建、程序执行、网络连接等关键路径上都会插入安全检查点。各种安全模块通过这些钩子来实现自己的策略SELinux、AppArmor、Tomoyo、IMA 等都属于 LSM 生态。传统 LSM 模块通常需要编译进内核或者在启动早期加载。后来内核加入了 BPF LSM也就是CONFIG_BPF_LSM允许开发者用 eBPF 程序挂载到 LSM 钩子上。这意味着我们可以动态地向内核注入一个“安全策略程序”而不需要重新编译内核也不需要写一个常驻的内核模块。这个特性从 Linux 5.7 开始逐步可用目前在云原生安全里已经被大量使用。LSM 钩子的返回值很关键返回 0 表示允许继续返回非 0通常是-EPERM表示拒绝该操作。这个机制就是我们后面实现文件拦截的基础。1.3 IMA 是做什么的IMA 的英文全称是 Integrity Measurement Architecture中文叫完整性度量架构。它解决的问题是我怎么知道磁盘上这个文件被改过没有IMA 可以在文件被打开、执行或者 mmap 时计算文件哈希并把度量结果记录到内核的度量列表里也可以扩展到 TPM 芯片的 PCR 寄存器中。IMA 和 eBPF LSM 并不是一回事。IMA 更侧重“度量与校验”而 eBPF LSM 更侧重“动态策略执行”。但在实际方案里两者是互补的IMA 负责告诉你这个文件内容是否可信eBPF 负责在访问路径上快速决定要不要放行。本文后面会专门展开这一点。1.4 本项目到底要做什么一句话描述我们写一个 eBPF LSM 程序挂载到file_open钩子上。每当进程打开文件时程序读取文件名去一个哈希表里查黑名单。如果命中就直接返回-EPERM阻止文件打开。这个行为很像一个“文件路径/文件名杀毒”的雏形只不过它只查静态文件名不查内容。把 IMA 加进来之后我们还可以把方案的思路延展成“度量 策略”也就是文件先经过 IMA 完整性判断再由 eBPF 的快速路径做拦截。标题里的 “crappy toy antivirus”本质上是在提醒开发者这是一个教学实验不是生产级杀软。2. 环境准备与版本说明2.1 内核要求要跑本文的示例内核必须开启以下选项CONFIG_BPFyCONFIG_BPF_SYSCALLyCONFIG_BPF_LSMyCONFIG_IMAy如果要用 IMACONFIG_SECURITYy可以用下面命令查看当前内核是否开启grep -E CONFIG_BPF_LSM|CONFIG_IMA|CONFIG_BPF_SYSCALL /boot/config-$(uname -r)如果输出是y或者m基本就能用。版本建议使用 Linux 5.10 以上的内核太老的内核对 BPF CO-RE 支持不完整。本文示例在常见发行版上都能跑具体版本需要根据你的项目实际情况调整重点是演示配置思路。2.2 安装工具链我们需要 clang 来编译 eBPF 字节码需要 libbpf 来提供用户态加载 API需要 bpftool 来生成 vmlinux.h 和 skeleton 头文件。以 Ubuntu/Debian 为例sudo apt update sudo apt install -y clang llvm libbpf-dev linux-tools-common linux-tools-generic如果你的发行版没有直接提供bpftool可以尝试安装linux-tools-$(uname -r)sudo apt install -y linux-tools-$(uname -r)如果仍然没有bpftool需要从内核源码树单独编译或者使用对应发行版的bpftool包。Fedora/RHEL 系列可以用sudo dnf install -y clang llvm libbpf-devel bpftool2.3 确认 LSM 配置中启用了 bpfeBPF LSM 要真正生效除了内核编译选项外还需要内核启动参数把bpf加进 LSM 列表。查看当前 LSM 顺序sudo mount -t securityfs securityfs /sys/kernel/security cat /sys/kernel/security/lsm在 Ubuntu 上通常会输出类似lockdown, capability, yama, apparmor如果列表里没有bpf就需要修改内核启动参数。编辑/etc/default/grub在GRUB_CMDLINE_LINUX中添加lsmlockdown,yama,integrity,apparmor,bpf然后更新并重启sudo update-grub sudo reboot重启后再查看 LSM 列表确保包含bpf即可。这一步比较关键很多朋友 eBPF 程序加载成功但钩子不触发大概率就是这里漏了。3. eBPF LSM 核心原理3.1 LSM 钩子与 eBPF 的结合点LSM 钩子分散在内核的关键路径上例如file_open、inode_create、bprm_check、socket_connect等。BPF LSM 允许我们写一个BPF_PROG_TYPE_LSM类型的程序把它挂到这些钩子上。每个钩子的函数签名不同。比如file_open对应的是int security_file_open(struct file *file)它接收一个struct file *表示即将被打开的文件对象。我们在 eBPF 程序里可以读取这个对象里的字段比如文件名、inode、路径信息然后决定允许还是拒绝。常见的 LSM 钩子如下钩子名称触发时机典型用途file_open打开文件时文件访问控制、恶意文件名拦截inode_create创建新文件时防止释放恶意文件bprm_check执行程序时可执行文件检查socket_connect发起 TCP 连接时网络访问控制path_mkdir创建目录时目录操作审计3.2 为什么用 bpf_probe_read_kernel_streBPF 程序运行在内核上下文但它不能直接随便解引用指针。因为验证器无法证明某个指针指向的内存是否可读直接解引用可能非法访问内核内存。所以读取内核中的字符串要用专门的 helperlong bpf_probe_read_kernel_str(void *dst, __u32 size, const void *unsafe_ptr)它会把内核地址空间的字符串安全地拷到 BPF 栈上的缓冲区里。对文件名校验来说我们会先拿到file-f_path.dentry-d_name.name这个指针再通过 helper 把字符串拷贝到局部变量中之后才能做比较。3.3 返回值的语义LSM 程序返回 0表示允许操作继续。返回负数比如-EPERM表示拒绝操作并且对应系统调用会收到这个错误码。例如在cat /tmp/eicar.txt时如果open系统调用被 LSM 拒绝cat最终会报cat: /tmp/eicar.txt: Operation not permitted这个错误就是-EPERM。需要注意的是LSM 钩子是在系统调用路径上同步执行的所以拒绝操作是立即生效的不需要等用户态反馈。这也是为什么把它理解成“拦截”而不是“审计”。3.4 BPF Map 是策略下发通道eBPF 程序本身不硬编码黑名单而是通过 BPF Map 在用户态和内核态之间共享数据。本文会用BPF_MAP_TYPE_HASHkey文件名字符数组。value一个整数标记1 表示拒绝。用户态程序把恶意文件名写入 mapeBPF 程序在file_open时查这个 map。这种设计让策略和代码分离后续可以很方便地扩展成动态黑名单。4. 完整实战编写一个玩具杀毒模块4.1 项目结构我们创建一个项目目录包含两个源码文件和一个编译产物目录。项目结构如下ebpf-toy-av/ ├── av_lsm.bpf.c ├── av_lsm_user.c ├── av_lsm.skel.h └── vmlinux.h其中av_lsm.bpf.c内核态 eBPF 程序。av_lsm_user.c用户态加载器负责加载、附加、写 map。vmlinux.h由 bpftool 生成的内核类型定义。av_lsm.skel.h由 bpftool 根据 BPF 对象生成的 skeleton 头文件。4.2 编写内核态 eBPF 程序新建av_lsm.bpf.c#include vmlinux.h #include bpf/bpf_helpers.h #include bpf/bpf_tracing.h char LICENSE[] SEC(license) GPL; #define MAX_FILENAME 256 /* * 黑名单哈希表。 * key : 文件名字符数组最多 256 字节 * value : 拒绝标记非 0 表示命中 */ struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1024); __type(key, char[MAX_FILENAME]); __type(value, __u32); } denied_paths SEC(.maps); /* * 在文件打开时触发。 * 如果文件名命中黑名单返回 -EPERM 阻止打开。 */ SEC(lsm/file_open) int BPF_PROG(av_file_open, struct file *file) { if (!file || !file-f_path.dentry) return 0; char filename[MAX_FILENAME] {}; long ret bpf_probe_read_kernel_str(filename, sizeof(filename), (void *)file-f_path.dentry-d_name.name); if (ret 0) return 0; if (bpf_map_lookup_elem(denied_paths, filename)) { bpf_printk(AV blocked open: %s\n, filename); return -1; /* -EPERM */ } return 0; }这段程序的核心逻辑并不复杂检查file和dentry指针是否为空。从file-f_path.dentry-d_name.name安全读取文件名。在denied_pathsmap 里查这个文件名。如果命中打印日志并返回-1阻止文件打开。否则返回 0放行。代码里用-1表示-EPERM是为了避免在 BPF C 环境中处理 errno 头文件问题。Linux 里EPERM的值就是 1所以-1与-EPERM等价。4.3 生成 vmlinux.h 并编译 BPF 对象首先进入项目目录生成 vmlinux.hbpftool btf dump file /sys/kernel/btf/vmlinux format c vmlinux.h然后编译 BPF 字节码clang -O2 -g -Wall -target bpf -c av_lsm.bpf.c -o av_lsm.bpf.o如果一切顺利会生成av_lsm.bpf.o然后生成 skeletonbpftool gen skeleton av_lsm.bpf.o av_lsm.skel.h这个av_lsm.skel.h里会包含一个struct av_lsm_bpf用户态程序通过它来管理 BPF 对象的生命周期。4.4 编写用户态加载器新建av_lsm_user.c#include stdio.h #include string.h #include errno.h #include signal.h #include stdarg.h #include unistd.h #include bpf/libbpf.h #include av_lsm.skel.h #define MAX_FILENAME 256 static volatile sig_atomic_t stop 0; static void sig_handler(int sig) { stop 1; } 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 av_lsm_bpf *skel; int err; int map_fd; __u32 val 1; char key[MAX_FILENAME] eicar.txt; libbpf_set_print(libbpf_print_fn); signal(SIGINT, sig_handler); signal(SIGTERM, sig_handler); skel