如果你在 Linux 上做安全产品最容易被问到的问题是防护模块是不是必须写成内核驱动进 ring-0按照传统思路答案接近“是”。因为你要拦截执行、检查文件、杀掉恶意进程没有内核特权好像就做不了真正意义上的“杀毒”。但 ring-0 驱动这条路对普通开发者太不友好驱动签名、内核版本兼容、loadable module 的限制、调试崩溃每一项都能消耗你大量时间。这篇文章给出的路线要轻得多用 IMA 做文件完整性测量用 eBPF LSM 在内核执行路径bprm_check_security上挂一个“黑名单检查”。最后做出来的东西就是标题里说的 crappy ring-0 toy antivirus。它当然不是一个能拿去打攻防演练的杀毒软件甚至不能扫描病毒。它真正演示的是一套“静态完整性 动态执行阻断”的内核安全组装逻辑。读完你会理解三件事ring-0 工具在系统调用路径上如何生效IMA 和 LSM 各自解决什么问题以及怎么把一个 eBPF LSM 程序编译、加载、测试到 Linux 内核里。如果你正在做 HIDS、零信任执行管控、软件供应链防篡改这篇文章值得看完。1. eBPF LSM 做“杀毒”的价值在哪传统杀毒软件在内核态的常规做法是写一个驱动程序hook 系统调用或者绑定文件系统过滤回调。Windows 上有 minifilterLinux 上有 fanotify、LSM、内核模块。问题是这些方案要么权限要求高要么开发调试成本大要么部署到不同发行版时会被内核模块校验挡住。eBPF 提供的是另一种思路你写一段受限的 C 代码交给内核验证器检查确认不会死循环、不会越界访问、不会非法修改内核内存后再把它挂载到指定的 hook 点。用 eBPF 做安全检查最大的优势不是性能而是“失败不会拖垮内核”。这不代表没有风险但相比直接加载模块eBPF 的失控半径被缩小了很多。那为什么还要用到 LSM因为现代 Linux 已经内置了一套安全钩子框架。每次 execve、open、mmap 等关键操作发生时都会走一遍 LSM 钩子链。过去你只能在这些位置选择 AppArmor、SELinux 这种现成策略而现在 BPF LSM 允许你自己写一小段程序安插进去。IMA 同样属于这个安全链条的一员不过它关心的不是“这个操作合不合法”而是“这个文件是否被改动过、当前内容是什么”。把三者放在一起架构就很清晰IMA 负责“度量”可执行文件被 execve 时计算哈希并记录到内核运行日志eBPF LSM 负责“决策”在 execve 系统调用到达真正执行点之前按哈希或路径特征判断是否放行用户态脚本负责“策略下发”计算被拉黑文件的哈希写入 eBPF map。这个组合正好覆盖了“检测”和“阻断”两个环节。传统杀毒软件的特征码扫描本质也是这个逻辑只是它把特征库维护在厂商服务器上而我们可以把它收敛到内核里的一个小 map 中。2. 核心概念ring-0、LSM、IMA、eBPF LSM2.1 ring-0 与杀毒软件的“特权执念”ring-0 是 CPU 的最高特权级别。在内核态代码可以访问所有内存、I/O 设备也可以控制系统调用行为。杀毒软件之所以想留在 ring-0是因为恶意代码往往会藏到系统调用背后用户态扫描看不到。但 ring-0 也是一把双刃剑一个 bug 就能让整个系统宕机驱动签名和版本兼容又提高了发布门槛。eBPF 程序虽然运行在内核态但它是由内核验证器约束过的“受限代码”。这不是严格意义上的 ring-0 驱动开发却又真正跑在 ring-0 的执行路径上。这个特性让很多安全工具开始从“写内核模块”转向“写 eBPF 程序”。2.2 LSMLinux 安全模块的标准接口LSM 不是一个安全产品而是一套内核安全钩子。它在关键操作发生时调用已经注册的安全回调回调返回允许或拒绝。举例来说file_open文件被打开时bprm_check_security程序被 execve 准备执行时file_permission文件被读写时。SELinux、AppArmor 都是通过 LSM 框架生效的。从内核 5.8 开始eBPF 自身也被注册为一个 LSM 模块开发者可以在lsm/bprm_check_security这种 hook 位置挂载自己的 eBPF 程序。2.3 IMA完整性度量架构IMAIntegrity Measurement Architecture是一套文件完整性测量机制。它能在文件被打开、执行、mmap 时计算哈希并把这些度量结果记录到内核的运行时度量列表中。IMA 支持三种策略模式模式作用是否阻断measure记录文件哈希否appraise校验文件签名或哈希是audit记录安全审计日志否在本文演示中我们主要用 measure 模式来验证“文件被篡改后哈希会变化”。实际生产系统可以配合 IMA 签名在 appraise 模式下拒绝执行未签名文件。2.4 eBPF LSM 的架构位置BPF LSM 本质上是把 LSM 钩子开放给了 eBPF 程序。它的调用链路是execve() - do_execve() - bprm_check_security() // LSM hook - IMA 的测量逻辑如果策略包含 BPRM_CHECK - eBPF LSM 程序决策是否放行 - 放行 / 拒绝这里必须区分清楚IMA 和 eBPF LSM 处在同一条 LSM 链上但职责不一样。IMA 做测量、记录、校验eBPF LSM 做自定义的强制访问控制。我们让 IMA 负责“发现异常痕迹”让 eBPF LSM 负责“执行黑名单阻断”这正是标题所说“toy antivirus”的核心架构。3. 整体架构设计这个项目的目标很明确构建一个能够在内核执行路径上根据文件哈希或路径拒绝特定可执行文件运行的最小防护系统。模块划分如下策略管理员用户态决定哪些文件需要被拉黑计算文件路径的哈希并通过 bpftool 写入 eBPF map。IMA 测量层对BPRM_CHECK事件记录文件哈希作为审计和举证数据。eBPF LSM 阻断层每次bprm_check_security触发时读取正在执行的程序路径计算哈希查询黑名单 map如果命中则返回-EPERM。内核安全执行链把以上两步串联起来。实际效果等价于用户态无法绕过的“文件访问黑名单”。因为检查点位于内核 LSM 路径即使攻击者拿到 shell只要 execve 被拦他也很难执行指定文件。当然如果攻击者已经有 root 权限并能加载 eBPF 程序他可以直接卸载或覆盖我们的 eBPF map这是后话。toy 级设计无法对抗同权限级对手。4. 环境准备与内核特性检查4.1 推荐环境强烈建议在虚拟机中做实验不要在生产环境直接操作。原因有两个实验过程中需要修改内核启动参数一旦配置错误可能导致系统无法启动IMA policy 写错也可能影响系统命令执行。推荐环境Ubuntu 22.04 或更新的 Linux 发行版内核版本 5.15 以上BPF LSM 已经足够稳定安装 clang、llvm、libbpf-dev、bpftool内核开启 CONFIG_BPF_LSM、CONFIG_IMA 相关选项。多数发行版默认内核已经带有相关配置但需要启动参数显式启用 BPF LSM。4.2 检查 LSM 启用状态执行以下命令cat /sys/kernel/security/lsm输出中如果包含bpf说明 BPF LSM 已经启用。如果没有你需要修改 GRUB 启动参数追加lsmlockdown,yama,integrity,apparmor,bpf修改前先备份/etc/default/grub然后运行sudo update-grub sudo reboot如果你使用的是云主机尤其要注意修改 GRUB 参数前确认有 VNC 或带外控制台避免参数错误导致无法登录系统。4.3 检查 IMA 是否可用ls -l /sys/kernel/security/ima cat /sys/kernel/security/ima/ascii_runtime_measurements | tail -n 5如果/sys/kernel/security/ima不存在需要先挂载 securityfssudo mkdir -p /sys/kernel/security sudo mount -t securityfs securityfs /sys/kernel/security如果安全文件系统已经挂载但 IMA 目录仍然不存在说明当前内核没有启用 IMA需要重新编译内核或者换一个默认开启 IMA 的发行版内核。不建议为了一个 toy 项目重新编译内核除非你本身就有内核开发环境。4.4 检查 eBPF 所需权限加载 eBPF LSM 程序需要 root 权限或者当前用户具有CAP_BPF、CAP_SYS_ADMIN能力。普通用户直接执行加载命令会失败Operation not permitted认证资料显示Linux 的新权限体系中 BPF 能力被拆分成了CAP_BPF和CAP_PERFMON但为了减少环境变量干扰本文所有验证都在 root 用户下完成。5. IMA 完整性度量配置与验证5.1 写入 measure 策略IMA 的运行时策略在 securityfs 的/sys/kernel/security/ima/policy文件中写入。我们只记录可执行文件的度量结果echo measure funcBPRM_CHECK | sudo tee /sys/kernel/security/ima/policy除了BPRM_CHECKIMA 还支持FILE_CHECK文件打开、MMAP_CHECK内存映射执行等事件类型。为了演示只启用最小策略。5.2 触发一次度量执行一个普通命令让 IMA 产生度量记录/usr/bin/md5sum /etc/hostname /dev/null sudo tail -n 5 /sys/kernel/security/ima/ascii_runtime_measurements输出中会出现类似下面的字段10 template-hash ima-ng sha256:文件哈希 /usr/bin/md5sum这里我使用了template-hash和文件哈希作为占位符因为不同发行版、不同文件内容得到的哈希值完全不一样。你只需要确认ascii_runtime_measurements末尾增加了新的记录并且里面包含/usr/bin/md5sum路径。5.3 验证文件修改会导致哈希变化如果你尝试手动修改一个文件并在修改前后分别执行会看到 IMA 记录中的哈希值不同。这说明 IMA 已经完成了“静态完整性度量”的职责。注意当前 measure 模式只记录不阻断。即使文件被篡改系统也能执行它。真正阻断工作交给下一节的 eBPF LSM 程序。6. eBPF LSM 执行阻断实现6.1 程序逻辑设计eBPF LSM 程序要做的事情比较朴素在bprm_check_security钩子处读取当前被执行的程序路径对路径计算一个简单的 djb2 哈希查询名为blocked_files的 eBPF map如果路径哈希存在于 map 中返回-EPERM否则放行。这里用 djb2 哈希纯粹是为了演示真实项目请改用更强健的哈希或直接使用路径长度固定前缀匹配。djb2 存在碰撞可能而且这里计算的是路径哈希而不是文件内容哈希。这一点在最后的安全边界中会详细解释。6.2 完整 eBPF C 程序创建文件ima_guard.bpf.c// 文件路径ima_guard.bpf.c #include linux/bpf.h #include linux/types.h #include bpf/bpf_helpers.h #include bpf/bpf_tracing.h #define MAX_PATH 256 #define MAX_ENTRIES 1024 char LICENSE[] SEC(license) GPL; struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, MAX_ENTRIES); __type(key, __u32); __type(value, __u32); } blocked_files SEC(.maps); static __always_inline __u32 djb2_path(const char *path) { __u32 hash 5381; int i; for (i 0; i MAX_PATH; i) { int c path[i]; if (c 0) { break; } hash ((hash 5) hash) c; } return hash; } SEC(lsm/bprm_check_security) int BPF_PROG(guard_bprm_check, struct linux_binprm *bprm) { char filename[MAX_PATH] {}; __u32 key; long ret; ret bpf_probe_read_kernel_str(filename, sizeof(filename), BPF_CORE_READ(bprm, filename)); if (ret 0) { return 0; } key djb2_path(filename); if (bpf_map_lookup_elem(blocked_files, key)) { return -EPERM; } return 0; }这段代码的关键点有三个。第一BPF_CORE_READ(bprm, filename)从struct linux_binprm中读取filename指针然后通过bpf_probe_read_kernel_str安全地把内核态字符串拷贝到栈上。栈上的filename属于 eBPF 程序自身栈空间verifier 可以确认访问边界。第二djb2_path遍历栈上字符串并且循环次数被限制在MAX_PATH以内。这样 verifier 可以验证循环不会死循环程序能够通过加载检查。第三SEC(lsm/bprm_check_security)表明这段程序要挂载到bprm_check_security这个 LSM 钩子。返回0表示允许返回负错误码表示拒绝这里是-EPERM所以用户侧看到的是 “Permission denied”。6.3 编译命令使用 clang 编译 eBPF 目标文件clang -O2 -g -Wall -target bpf -D__TARGET_ARCH_x86 -c ima_guard.bpf.c -o ima_guard.bpf.o如果你的 CPU 是 ARM64把__TARGET_ARCH_x86换成__TARGET_ARCH_arm64并且注意交叉编译环境。编译成功后会生成ima_guard.bpf.o。6.4 加载并挂载 eBPF 程序使用 bpftool 加载sudo bpftool prog load ima_guard.bpf.o /sys/fs/bpf/ima_guard autoattach如果你的 bpftool 版本较旧不支持autoattach或 LSM attach也可以尝试手动挂载sudo bpftool prog load ima_guard.bpf.o /sys/fs/bpf/ima_guard sudo bpftool prog attach name guard_bprm_check type lsm bprm_check_security加载成功后可以查看程序信息sudo bpftool prog show name guard_bprm_check如果这里报错常见原因是内核配置或启动参数没有启用 BPF LSM。检查CONFIG_BPF_LSMy并且/sys/kernel/security/lsm中包含bpf。6.5 查看 eBPF map IDmap 被程序引用但加载后你需要知道它的 ID或者直接用名字操作sudo bpftool map show name blocked_files输出中会显示 map 的 id、类型、key 大小、value 大小和当前元素数量。接下来向 map 写入黑名单。7. 联动测试让一个文件“被杀”7.1 先创建一个测试脚本mkdir -p /tmp/av_demo cat /tmp/av_demo/hello.sh EOF #!/bin/bash echo I am running EOF chmod x /tmp/av_demo/hello.sh先直接执行它确认能够正常运行/tmp/av_demo/hello.sh预期输出I am running7.2 计算路径哈希这里必须注意eBPF 程序读取的是传入 execve 的路径。为了让流程稳定请使用绝对路径执行并且计算绝对路径的哈希。python3 - EOF import struct def djb2(s): h 5381 for c in s.encode(): h ((h 5) h c) 0xFFFFFFFF return h path /tmp/av_demo/hello.sh key djb2(path) print(key bytes:, .join(f{b:02x} for b in struct.pack(I, key))) EOF脚本会输出类似key bytes: 27 3a 9c a2这个字节序列是小端序。bpftool 写入 map 时必须使用相同字节序否则 eBPF 程序计算出的 key 与 map 中保存的 key 不匹配查找不到。7.3 写入 eBPF map假设上面输出的是27 3a 9c a2执行sudo bpftool map update name blocked_files key hex 27 3a 9c a2 value hex 00 00 00 01value 任意写一个非零值即可因为 eBPF 程序只检查这个 key 是否存在。写入后查看sudo bpftool map dump name blocked_files预期能看到一个 key-value 条目。7.4 再次执行目标文件/tmp/av_demo/hello.sh预期输出bash: /tmp/av_demo/hello.sh: Permission denied为什么不是 “No such file” 或 “Syntax error”因为错误发生在内核 execve 路径上shell 还没能把脚本内容读进内存进程就被拒绝了。这正是 LSM hook 生效的典型表现。7.5 验证 IMA 记录执行被拒绝后再查看 IMA 度量记录sudo tail -n 5 /sys/kernel/security/ima/ascii_runtime_measurements这里可能有两种情况。如果 IMA 策略在 eBPF LSM 之前执行日志里会多一条/tmp/av_demo/hello.sh的度量记录如果 eBPF LSM 先返回拒绝IMA 日志可能没有该文件记录。不同内核版本的 LSM 钩子执行顺序不完全一致这并不影响方案有效性因为我们的阻断决策由 eBPF LSM 负责。7.6 删除黑名单恢复执行为了确认是 eBPF map 导致的拒绝而不是文件权限问题删除 map 元素后再次执行sudo bpftool map delete name blocked_files key hex 27 3a 9c a2 /tmp/av_demo/hello.sh此时应该恢复输出I am running到这里一个完整的“静态哈希拉黑 内核执行阻断”链路已经跑通。8. 常见问题与排查思路问题现象可能原因排查方式解决方案bpftool 报 “Operation not permitted”当前用户缺少 CAP_BPF 或 CAP_SYS_ADMINsudo执行检查内核能力和用户组使用 root 用户在测试环境可临时赋予相应 capabilitybpftool prog load 报 “unknown attach type”内核版本过低或 bpftool 版本过旧查看uname -r和bpftool version升级内核和 bpftool至少保证内核 5.8推荐 5.15eBPF 程序加载后执行任何程序都被拒绝LSM hook 挂载异常或 verifier 误判更常见的是黑名单 map 中写入了系统程序路径的哈希先用bpftool map dump name blocked_files查看 map 内容再检查程序逻辑删除错误 map 项测试时先只拉黑一个明确的测试文件/sys/kernel/security/lsm中没有bpf内核启动参数未启用 BPF LSMcat /proc/cmdline查看当前启动参数追加lsmlockdown,yama,integrity,apparmor,bpf后重启IMA 策略写不进/sys/kernel/security/ima/policy内核没有 CONFIG_IMA_WRITE_POLICY或已存在默认不可写策略查看内核 config 和安全 fs 挂载情况使用支持动态写策略的内核或在编译内核时开启 CONFIG_IMA_WRITE_POLICY路径哈希不匹配map 明明有值但拦截不生效字节序或路径不一致比如相对路径和绝对路径都传入过 execve用bpftool map dump查看实际 key用 Python 分别计算相对/绝对路径的哈希统一使用绝对路径执行写入 map 时按照小端字节序填入修改 GRUB 参数后系统启动失败LSM 列表写错或顺序不对通过带外控制台查看启动日志恢复 GRUB 备份参数在虚拟机中先验证配置所有这些排查动作都应该在可控的测试环境完成。涉及内核启动参数和 IMA 策略时记住一个原则先备份再修改。9. 生产级方案演进与安全边界9.1 为什么这里只是“toy”本文实现的方案有几个明显短板第一它拦截的是“路径哈希”不是“文件内容哈希”。攻击者只要把黑名单文件复制成另一个路径拦截就会失效。在真实安全产品中你需要用 IMA 计算出的文件摘要作为 key或者直接把哈希结果与签名校验绑定。第二它拦不住“无文件执行”。如果恶意代码直接把 shellcode 注入到已存在的合法进程中不走 execve 路径这个 eBPF 程序根本看不到。第三它没有做持久化。一旦重启eBPF 程序和 map 内容都会丢失。第四它依赖 eBPF map 的完整性。如果攻击者已经拿到 root 权限并能加载 eBPF 程序他可以清空 map 或卸载我们的程序。对抗同权限级对手需要更深层的信任链保障。9.2 真实项目应该怎么演进如果要在生产环境做类似的防篡改执行管控更推荐的组合是IMA 开启 appraise 模式配合 IMA 签名机制只允许执行被信任签名的文件将 IMA 度量基准哈希存储在 TPM PCR 中实现远程证明eBPF LSM 程序不是维护静态黑名单而是查询一个集中的策略下发结果配合用户态 agent 做实时更新对白名单之外的可执行文件先记录审计日志灰度观察一段时间后再切换为阻断模式对关键文件系统目录启用 dm-verity 或 overlayfs 只读保护从文件系统层降低被篡改的风险。eBPF LSM 最适合的场景是那些“你希望按自定义规则快速拦截特定执行事件”的专项需求。它做不到万能的杀毒但可以让你的防护策略成为内核安全检查链路中的一环。对于刚接触 eBPF 的开发者用这个例子理解 LSM hook 的挂