
实际 Linux 安全场景里检测恶意文件通常有两种手段用户态扫描和内核态管控。eBPF 加 IMA LSM 的组合把“文件完整性度量”和“访问控制决策”分开可以做一个 ring-0 级玩具杀毒原型。这篇文章会从概念、环境、架构、实现、验证、排错到最佳实践完整复现一个基于 eBPF 的 LSM 拦截程序当用户打开或执行被加入黑名单的文件时内核直接对相关系统调用返回-EPERM连用户态进程都看不到这次请求被正常打开。所有内容均为教学原型目标不是替代商业杀毒软件而是帮助理解 eBPF、IMA、LSM 在内核安全中的实际配合方式。1. 先理解这套“玩具杀毒”的运行位置和核心概念1.1 ring-0 到底指什么ring-0 是 x86 和 x86-64 CPU 的最高特权级。内核态代码运行在 ring-0可以访问内核内存、控制硬件、管理进程普通用户态运行在 ring-3权限受限。杀毒软件如果只运行在用户态它必须先通过系统调用进入内核才能检查其他进程的文件行为恶意程序如果通过内核漏洞获得 ring-0 权限理论上可以绕过大量用户态检测。eBPF 的价值在于它提供了一种相对安全的内核态编程方式。eBPF 程序并不是任意内核模块而是先被 verifier 校验再被 JIT 编译执行的受限字节码。它运行在 ring-0 上下文可以参与文件打开、程序执行、网络连接等关键路径。这样我们不需要写完整内核模块也能在内核态完成一部分安全决策。1.2 eBPF 在内核安全中的定位eBPF 全称 extended Berkeley Packet Filter现在早已不只是网络包过滤工具。它可以在内核中挂载 kprobe、tracepoint、perf event、tp_btf 以及 LSM hook。用于安全防护时eBPF 程序可以拦截文件打开文件映射到进程地址空间执行可执行文件加载内核模块网络连接建立特定系统调用在 Linux 5.7 之后BPF LSM 让开发者可以挂载到内核 LSM 安全钩子上。这意味着 eBPF 程序可以在系统调用接近完成之前得到一次“否决权”。相比用户态杀毒软件eBPF 能更早看到事件并决定是否放行。1.3 IMA 为什么和杀毒有关IMA 是 Linux Integrity Measurement Architecture中文通常叫完整性度量架构。它负责对文件进行完整性度量根据 IMA 策略在文件被访问、执行或被 mmap 时计算文件哈希把结果记录到内存度量列表里也可以保存到文件的security.ima扩展属性中。杀毒场景借鉴的正是“先有度量后有策略”的思路。我们首先要知道一个文件的内容是什么才能判断它是否属于恶意样本。IMA 负责计算文件哈希和完整性状态eBPF LSM 负责在关键检查点做出“允许/拒绝”的最终决定。二者分工不同但可以组合成一套完整链路。1.4 LSM 提供的是检查点LSM 全称 Linux Security Module是一组嵌入内核系统调用路径的钩子。例如打开文件时调用security_file_open执行程序时调用security_bprm_check将文件映射到内存时调用security_mmap_file读取内核模块或固件时调用security_kernel_read_fileLSM 模块可以在这些检查点返回 0 表示允许返回负错误码表示拒绝。BPF LSM 把这种能力开放给 eBPF 程序成为BPF_PROG_TYPE_LSM类型。理解这一点很重要LSM 不是扫描器它只是“执行检查的位置”。真正判断文件是否恶意还需要使用 IMA 的度量结果或者用户态维护的恶意样本黑名单。本文的示例采用黑名单方式把所有判断逻辑简化为路径匹配便于复现。2. 环境准备先让内核具备 BPF LSM 和 IMA 能力2.1 确认内核版本和编译选项BPF LSM 从 Linux 5.7 开始支持建议使用 5.10 以上内核越低版本可用的 helper 和 CO-RE 支持越有限。最好运行在常见的 x86_64 发行版上例如 Ubuntu 22.04、Debian 12、Fedora 38 等。需要内核开启以下配置内核配置项建议值作用CONFIG_BPFy支持 eBPF 基础功能CONFIG_BPF_SYSCALLy支持bpf()系统调用CONFIG_BPF_LSMy允许将 eBPF 挂载到 LSM hookCONFIG_IMAy支持 IMA 完整性度量CONFIG_SECURITYy启用 LSM 基础框架CONFIG_DEBUG_INFO_BTFy提供 vmlinux BTF支持 CO-RECONFIG_KALLSYMSy便于符号解析和调试如果没有开启CONFIG_BPF_LSM加载 BPF LSM 程序时会得到类似unknown attach type或operation not permitted的错误。这时候需要重新编译内核而不是单纯改配置。2.2 验证当前环境是否具备条件先查看内核版本uname -r然后确认 BPF LSM 是否启用。最直接的方法是查看/sys/kernel/btf/vmlinux是否存在以及 bpftool 是否能识别相关 prog typels -l /sys/kernel/btf/vmlinux bpftool feature probe | grep -i lsm如果输出中包含LSM相关特性说明当前内核支持 BPF LSM。再检查 IMA 安全文件系统ls /sys/kernel/security/ima正常情况下可以看到ascii_runtime_measurements、policy、runtime_measurements_count等文件。如果看不到可能是CONFIG_IMA未开启或者securityfs未挂载。2.3 安装编译工具链编写和加载 eBPF 程序需要 clang、llvm-strip、bpftool、libbpf 开发头文件。在 Ubuntu 或 Debian 系统上可以这样做sudo apt update sudo apt install -y clang llvm bpftool libbpf-dev linux-tools-$(uname -r)如果发行版仓库里没有 bpftool也可以从内核源码编译tools/bpf/bpftoolcd /usr/src/linux/tools/bpf/bpftool make sudo make install另外为了使用ima_hash等工具结合 IMA 演示可以安装ima-evm-utilssudo apt install -y ima-evm-utils2.4 IMA 策略的现实情况不同发行版对 IMA 的默认策略不同。有些发行版默认只记录测量值有些关闭了 IMA Appraisal 模式。对于本文的玩具级示例eBPF 拦截本身不依赖 IMA 策略真正使用 IMA 的地方是用户态用ima_hash计算文件完整性哈希并把它作为黑名单样本库的特征。如果要在内核里完整跑通 IMA 文件签名校验需要配置 IMA 策略、密钥和文件扩展属性这属于另一个主题。学习阶段可以先不启用 IMA Appraisal只把 IMA 当成“算哈希”的工具。3. 架构设计用户态扫描、内核态拦截、IMA 度量3.1 整体模块划分这套玩具杀毒由三层组成IMA 度量层负责计算文件的完整性哈希提供“文件内容是否与样本库一致”的依据。用户态策略层负责维护黑名单把恶意文件路径或哈希写入 BPF map。内核态 eBPF LSM负责在file_open等 LSM 检查点执行最终拦截。三层不是每次调用都全量参与。eBPF LSM 在内核态直接查询黑名单 map性能路径很轻用户态只在黑名单发生变化时更新 mapIMA 作为审计和样本哈希来源在离线或后台流程中工作。3.2 一次恶意文件访问的数据流用文本形式描述完整流程用户调用 open(/tmp/evil.txt) - VFS 进入 open 流程 - LSM 调用 security_file_open - BPF LSM 程序开始执行 - bpf_d_path 获取文件完整路径 - 查询 blocked_files map - 如果未命中返回 0允许打开 - 如果命中返回 -EPERM - open() 系统调用失败errno EPERM这个流程的关键是拦截发生在用户态进程真正拿到文件描述符之前。也就是说用户态程序无法再从这次 open 中继续读取文件内容后续的 read、mmap 都无从谈起。3.3 为什么不在 eBPF 里直接读文件内容算哈希读者可能会问既然 eBPF 运行在内核态为什么不直接在file_open钩子里读取文件内容计算 SHA256再和恶意样本库比较真实原因有几点eBPF 程序不是普通内核代码verifier 禁止无限循环、禁止复杂指针运算直接读取 page cache 不是常规允许操作。文件内容可能不在内存中需要触发内核 I/O 路径而 eBPF LSM 在 open 路径中做阻塞式 I/O 风险很大。内核已经提供了 IMA 作为完整性度量子系统重复实现文件哈希既不安全也无必要。因此实用做法是IMA 负责“度量”用户态负责“维护样本库”eBPF LSM 只负责“查表并拦截”。这样分工清晰也符合最小权限原则。4. 内核态 eBPF 拦截程序最小可运行实现4.1 程序要完成的事eBPF 拦截程序需要做三件事挂载到 LSM 的file_open检查点。通过bpf_d_pathhelper 读取被打开文件的完整路径。用路径作为 key 查询黑名单 map命中则返回-EPERM。为了保证 map key 固定map 使用一个 256 字节的字符串数组作为 key。这里不追求内存效率只追求最小可运行。4.2 编写 BPF LSM 程序使用 libbpf vmlinux.h 的方式编写。首先生成 vmlinux.hbpftool btf dump file /sys/kernel/btf/vmlinux format c vmlinux.h然后编写block_file.bpf.c#include vmlinux.h #include bpf/bpf_helpers.h #include bpf/bpf_tracing.h #include linux/limits.h char LICENSE[] SEC(license) GPL; struct path_key { char path[256]; }; struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1024); __type(key, struct path_key); __type(value, __u8); } blocked_files SEC(.maps); SEC(lsm/file_open) int BPF_PROG(block_file_open, struct file *file) { struct path_key key {}; long ret bpf_d_path(file-f_path, key.path, sizeof(key.path)); if (ret 0) { return 0; } if (bpf_map_lookup_elem(blocked_files, key) ! NULL) { bpf_printk(blocked file open: %s\n, key.path); return -EPERM; } return 0; }这段代码的核心逻辑SEC(lsm/file_open)表示挂载到 LSM 的file_openhook。BPF_PROG(block_file_open, struct file *file)是 libbpf 提供的参数展开宏把钩子参数映射成 C 函数参数。bpf_d_path把struct path转换成完整路径字符串结果放入key.path。bpf_map_lookup_elem检查路径是否在黑名单中。命中返回-EPERM未命中返回 0。bpf_d_path在部分 hook 上可能不可用。如果 verifier 报unknown func或 helper 不可用需要确认内核版本或者改用bpf_probe_read_kernel_str从 dentry 的名称字段读取路径。不过file_open在较新内核上通常可以正常使用该 helper。4.3 为什么 map key 要用固定字符串数组BPF map 的 key 类型必须固定不能像用户态那样使用变长字符串。把 key 定义为struct path_key { char path[256]; }后用户态更新 map 时也必须使用相同结构。这样做的好处是查询可以直接比较避免字符串长度不一致导致 map miss。缺点也很明显路径过长会被截断且部署到不同环境时路径前缀可能变化。原型阶段可以接受生产环境最好改为“路径前缀匹配”或“文件哈希匹配”。4.4 编译 eBPF 字节码编译命令clang -O2 -g -Wall -target bpf -c block_file.bpf.c -o block_file.o编译后可以用 bpftool 查看程序信息bpftool prog load block_file.o /sys/fs/bpf/block_file这里只是加载到 pinned 文件真正挂到 LSM 需要用户态进程调用 attach或者使用bpftool prog attach完成。4.5 用 bpftool 快速挂载与卸载sudo bpftool prog load block_file.o /sys/fs/bpf/block_file sudo bpftool prog attach /sys/fs/bpf/block_file /sys/fs/bpf/block_file_attach lsm file_open挂载后可以查看sudo bpftool prog show需要卸载时删除 pinned 文件即可sudo rm /sys/fs/bpf/block_file /sys/fs/bpf/block_file_attach但手工用 bpftool 管理 map 和 attach 并不适合多次迭代因此推荐通过 libbpf 用户态程序来加载。5. 用户态工具维护黑名单并连接 IMA5.1 用户态程序的职责用户态程序主要做四件事加载 eBPF object。挂载 BPF LSM 程序。从命令行接收文件路径或哈希。将路径写入blocked_filesmap。同时为了体现 IMA 的作用可以先用ima_hash计算目标文件的完整性哈希再决定是否加入黑名单。5.2 使用 libbpf skeleton 管理 eBPF 程序用bpftool gen skeleton从编译好的.o生成用户态可以引用的头文件bpftool gen skeleton block_file.o block_file.skel.h然后编写loader.c#include errno.h #include stdio.h #include string.h #include unistd.h #include bpf/libbpf.h #include block_file.skel.h static void bump_memlock_rlimit(void) { struct rlimit rlim { .rlim_cur 128UL 20, .rlim_max 128UL 20, }; setrlimit(RLIMIT_MEMLOCK, rlim); } int main(int argc, char **argv) { struct block_file_bpf *skel; struct path_key key {}; __u8 value 1; int err; if (argc ! 2) { fprintf(stderr, usage: %s blocked_path\n, argv[0]); return 1; } bump_memlock_rlimit(); skel block_file_bpf__open_and_load(); if (!skel) { fprintf(stderr, failed to open and load BPF object\n); return 1; } err block_file_bpf__attach(skel); if (err) { fprintf(stderr, failed to attach BPF program: %d\n, err); goto cleanup; } strncpy(key.path, argv[1], sizeof(key.path) - 1); err bpf_map_update_elem( bpf_map__fd(skel-maps.blocked_files), key, value, BPF_ANY); if (err) { fprintf(stderr, failed to update map: %d\n, err); goto cleanup; } printf(blocked path: %s\n, argv[1]); printf(press CtrlC to exit\n); for (;;) { sleep(10); } cleanup: block_file_bpf__destroy(skel); return err ? 1 : 0; }编译用户态程序时需要链接 libbpf并包含生成的 skeleton 头文件。示例命令只是思路不同环境需要根据头文件路径调整gcc -o loader loader.c -lbpf -lelf -lz这里的关键点block_file_bpf__open_and_load()会加载并校验 BPF 程序。block_file_bpf__attach()根据.bss和SEC(lsm/...)定义自动完成 attach。map 操作使用bpf_map_update_elem参数是用户态文件描述符而不是 map 名称。5.3 结合 IMA 计算文件哈希在更新黑名单前先调用ima_hash得到文件的完整性度量哈希ima_hash /tmp/evil.txt在 Ubuntu 上可能需要 root 权限或者需要securityfs挂载完整。ima_hash计算的结果可以接入用户态脚本作为“是否加入黑名单”的样本特征。更通用的做法是使用sha256sumsha256sum /tmp/evil.txt但sha256sum只是普通用户态哈希不经过 Linux IMA 度量框架。为了显示 IMA LSM 的参与优先使用ima_hash。该命令来自ima-evm-utils它计算的是 IMA 框架的度量哈希通常在内存度量列表和security.ima扩展属性中保持一致。5.4 把 IMA 哈希和路径黑名单组合起来完整流程可以这样设计用户态定期或事件驱动地扫描新文件。对可疑文件调用ima_hash计算哈希。把哈希与恶意样本哈希库比较。如果命中就把文件路径写入 BPF map内核态后续拦截同样的路径。这种方式不要求 eBPF 程序理解 IMA 数据结构简化了原型。更复杂的设计还可以让 eBPF 程序读取文件的 inode 或 xattr 信息然后与 IMA 度量结果比对但这会显著增加复杂度不适合作为入门示例。5.5 启动顺序启动时按以下顺序sudo ./loader /tmp/evil.txt程序会阻塞等待并保持 BPF 程序挂载。此时再尝试打开/tmp/evil.txt会得到Permission denied。需要停止时在另一个终端杀掉 loader 进程或者按 CtrlC。BPF 程序会因为 pinned 文件被删除或没有用户态引用而被释放。注意如果 user 态进程退出前没有 destroy skeleton内核会自动清理程序但为保证干净最好在代码中调用block_file_bpf__destroy(skel)。6. 最小实验用 cat 验证 open 被拒绝6.1 准备测试文件创建一个测试文件并写入内容echo this is a toy virus sample /tmp/evil.txt cat /tmp/evil.txt正常情况下能打印文件内容。接下来把这个文件加入拦截黑名单。6.2 编译、加载并注入黑名单先编译 eBPF 程序clang -O2 -g -Wall -target bpf -c block_file.bpf.c -o block_file.o bpftool gen skeleton block_file.o block_file.skel.h gcc -o loader loader.c -lbpf -lelf -lz然后启动 loadersudo ./loader /tmp/evil.txtloader 输出blocked path: /tmp/evil.txt press CtrlC to exit此时 BPF LSM 程序已经挂载到file_open黑名单 map 中也有/tmp/evil.txt。6.3 验证拦截效果打开另一个终端重复执行cat /tmp/evil.txt预期结果cat: /tmp/evil.txt: Permission denied用 Python 测试也一样open(/tmp/evil.txt, r)预期抛出PermissionError: [Errno 13] Permission denied。这个现象说明拦截发生在用户态进入文件读取之前。即使进程有 root 权限LSM hook 返回-EPERM后open 仍然失败。6.4 从 dmesg 看内核日志BPF 程序里使用了bpf_printk。查看内核 tracesudo cat /sys/kernel/debug/tracing/trace_pipe在另一终端再次执行cat /tmp/evil.txttrace 里会出现类似cat-12345 [001] ..... 12345.678901: bpf_trace_printk: blocked file open: /tmp/evil.txt如果 trace_pipe 没有输出需要先挂载 debugfssudo mount -t debugfs none /sys/kernel/debug6.5 清理实验环境停止 loader 进程sudo pkill loader确认 BPF 程序被释放sudo bpftool prog show | grep block_file正常情况下prog 列表不再包含该程序。如果加载时使用bpftool prog load并 pin 到了/sys/fs/bpf还需要手动删除 pinned 文件sudo rm -f /sys/fs/bpf/block_file /sys/fs/bpf/block_file_attach7. 常见问题排查7.1 eBPF 程序加载失败提示 operation not permitted可能原因当前用户没有 root 权限或缺少CAP_BPF、CAP_SYS_ADMIN。内核未开启CONFIG_BPF_LSM。系统启用了BPF LSM但当前进程的 LSM 策略禁止加载。检查方式id sudo bpftool feature probe | grep -i lsm grep BPF_LSM /boot/config-$(uname -r)处理建议用 root 运行确认内核配置如果CONFIG_BPF_LSM未开启需要重新编译内核。7.2 LSM hook 挂载失败提示 attach failed可能原因attach type 写错。内核版本过旧。程序类型不是BPF_PROG_TYPE_LSM。检查方式bpftool prog show bpftool prog attach help处理建议确保SEC(lsm/file_open)中的 hook 名与内核 LSM hook 一致。不同内核版本的 hook 名可能有差异例如部分版本使用security_file_open对应的 attach 名实际上是file_open。7.3 黑名单 key 不匹配导致拦截不到这是最容易踩的坑。BPF map 的 key 是 256 字节字符串数组如果用户态写入/tmp/evil.txt时剩余字节没有清零内核查询时可能因为 key 内容不一致而 miss。检查方式在 loader 中打印 key.path 的长度。使用bpf_map_get_next_key遍历 map 查看实际 key。处理建议用户态在构造struct path_key后必须使用权memset或结构体初始化确保所有字节为零。例如struct path_key key {}; strncpy(key.path, argv[1], sizeof(key.path) - 1);7.4 root 用户也能打开拦截文件是否是 bug大概率不是 bug而是没有真正命中拦截。确认方式检查 dmesg trace 中是否有blocked file open。确认 eBPF 程序确实 attach 到了file_open。检查路径是否带..、软链接、硬链接等不同形式。LSM hook 的返回值会直接影响 open 结果普通 root 不能直接绕过这个检查点。如果 root 能打开说明 BPF 程序没有命中该路径而不是权限不足。7.5 IMA 扩展属性没有生成IMA 扩展属性security.ima并不会默认出现在所有文件上。只有启用 IMA 策略并且在文件被度量或签名后内核才会写入该 xattr。检查方式getfattr -n security.ima -e hex /tmp/evil.txt如果属性不存在系统可能没有启用 IMA Appraisal。处理建议在实验环境中先不依赖 xattr直接使用ima_hash或sha256sum计算哈希把 IMA 当作一个独立的度量工具使用。7.6 无法判断是不是自己的 BPF 程序挡住了文件排查思路先停掉 loader确认文件可以正常打开。再启动 loader不写 map确认文件可以正常打开。写入黑名单后确认文件无法打开。查看 trace_pipe确认blocked file open日志。按照这个顺序可以逐步锁定问题发生在加载阶段、挂载阶段还是 map 更新阶段。问题现象常见原因检查方式处理建议prog 加载失败缺少 CAP_BPF 或内核未开启 BPF LSMbpftool feature probe使用 root确认内核配置attach 失败attach name 写错或版本过旧查看内核 LSM hook 名称确认file_open名称拦截不到map key 未清零或路径形式不一致遍历 map、打印 key使用全零初始化结构体root 仍能打开程序未 attach 或没命中 map查 trace_pipe确认命中日志IMA 属性不存在未启用 IMA Appraisalgetfattr -n security.ima改用ima_hash工具8. 从玩具到工程局限、最佳实践和扩展方向8.1 玩具原型的局限本文实现的路径黑名单式拦截本质上是一个“文件路径门禁”还不是完整的杀毒引擎。它无法识别内容被篡改后的同名恶意文件也无法对文件内容做深度扫描。真正商业杀毒软件的动态行为分析、静态特征码、启发式扫描、云查杀、内存保护、rookit 对抗等能力在这个原型里都不存在。同时BPF LSM 程序挂载后会对所有符合 hook 的文件打开事件生效。如果阻塞逻辑写得过宽可能导致系统无法启动、无法登录。恶意软件和普通程序运行在同一个内核路径上一个错误的 BPF 程序可能把自己锁在系统之外。实验时最好使用虚拟机或