eBPF实现文件签名校验:从内核拦截不可信可执行文件
发布时间:2026/10/22 5:13:29 锦皓数字建站

做系统安全或者可观测性的朋友多半会遇到这样一个需求某个目录、某个用户、某个容器里的可执行文件能不能只允许“经过可信签名”的版本运行我之前参与排查一类可疑脚本在服务器上反复作妖的问题时被这个需求卡了很久。传统做法是给文件做防篡改但权限一旦被提上去边改边跑的模式根本防不住。后来我把思路切到 eBPF 上做了一个代号为 hornet 的签名校验工具。今天这篇不炒概念专门拆解 hornet 的签名功能是怎么一步步落到内核里的包括方案取舍、核心代码思路、若干让人头秃的坑以及实测下来的性能表现。如果你正打算用 eBPF 做文件可信校验、进程白名单或供应链安全防护这篇文章应该能给你省不少弯路。1. 为什么需要签名校验场景、路线与整体架构1.1 先搞清楚“签名校验”到底解决什么问题先说一个很常见的场景。某业务团队上线了一个新服务安全策略要求目录 /opt/app 下的所有可执行文件必须是由内部安全部门签名过的版本其他任何来源的二进制、脚本、动态库一律不允许执行。这个需求听起来简单但落到实际操作里会发现“执行”这件事很难被卡住。文件放到磁盘上之后root 用户可以随时替换、修改、重命名、加硬链接。靠人肉巡检不可能实时靠杀毒软件特征库又跟不上内部自研程序的更新节奏。更麻烦的是攻击者往往不是直接改业务文件而是通过劫持环境变量、替换动态库、伪装成同名文件这类手段让业务进程本身执行到恶意代码。签名校验的核心价值是把“文件内容是否可信”这个判断从被动扫描变成主动拦截。所谓可信不是看文件叫什么名字而是看它的内容有没有经过私钥签名、签名是否能被已知根证书链验证通过。相比之下单纯的哈希白名单虽然也能用但哈希一旦更新就需要人工维护签名机制则允许合法发布方持续发布新版本只要私钥不泄露系统就不需要跟着文件内容频繁改配置。这也是我最终把签名功能作为 hornet 核心能力的原因。1.2 三条技术路线为什么最终选 eBPF要实现对“可执行文件是否可信”的内核级校验我认真对比过三条路线。第一条是使用内核已有的完整性校验框架。这类框架虽然和“签名校验”天然契合但配置链路长、策略机制僵化对业务目录、容器边界、用户组这类动态场景支持并不友好。想让它按“某目录下只有某团队签名的文件能跑”这种规则生效需要改内核参数、改策略文件、重新签名文件系统整个改造周期以周为单位不适合快速迭代的安全需求。第二条是自己写内核模块在 execve 路径上挂钩子做校验。这条路在技术上完全可行但问题也明显内核模块需要随内核版本编译遇到锁屏更新、内核升级就要重新适配写错了直接搞挂整机回归成本极高。对于生产环境来说风险不可接受。第三条就是 eBPF LSM Hook。eBPF 程序可以动态加载到内核不修改内核源码不重启机器校验逻辑跑在内核态root 用户的用户态操作绕不过去策略规则通过 Map 下发更新策略不需要重新编译 eBPF 程序。更重要的是它可以在真正执行系统调用之前返回错误码从源头拦截一个不可信文件的 execve。这个“拦截”能力是普通的 tracepoint 方案做不到的。三条路线对比下来的结论很清楚IMA 太重内核模块太险eBPF 是唯一一个既能在内核态做决策、又能保持快速迭代的方案。1.3 hornet 的系统架构长什么样hornet 的整体结构简单来说就是“内核态决策 用户态策略”两部分。用户态有一个常驻 Agent负责扫描指定目录、解析可执行文件、提取签名、验证签名链、计算文件内容摘要然后把“可信文件指纹”下发到内核中的 Hash Map。内核态由两段 BPF 程序组成一段挂在 LSM 的 bprm_check 路径上负责在 execve 时校验当前文件是否在白名单里不在就返回拒绝另一段挂在进程执行事件上负责把每次执行和拒绝记录成审计事件通过 Ring Buffer 上报给用户态。把决策放到内核、把计算放到用户态是这套架构的关键。签名验证涉及证书链、摘要算法、时间戳校验逻辑复杂在 eBPF 的指令限制和栈限制下很难完整实现但“查一个 Map 判断某个 inode 是否可信”却非常轻量。所以 hornet 的处理方式是用户态把复杂的签名验证做完内核态只保留一个简单的查表判断。这也是为什么签名功能看起来是内核态实现的但实际签名验证动作却发生在用户态。2. 动手前的准备环境要求、哈希算法与数据结构设计2.1 内核版本和编译工具链要提前确认开始写代码之前环境确认这一步千万别省。eBPF 的可用能力高度依赖内核版本和内核编译选项尤其是 LSM Hook 和 CO-RE 支持。我用的内核版本是较新的长期稳定版开启 BPF 和 BPF LSM 相关选项。如果你不确定当前内核是否支持先看一眼配置文件再动手# 检查内核是否开启 BPF LSM grep CONFIG_BPF_LSM /boot/config-$(uname -r) # 检查 BTF 是否存在 ls /sys/kernel/btf/vmlinux # 检查 bpftool 是否可用 bpftool version编译侧需要 clang 和 libbpf 开发包。版本太旧的 clang 对 BPF CO-RE 支持不完整编出来的程序可能在老内核上加载失败。我通常用 clang 14 以上版本搭配 libbpf 1.0 以上版本基本没遇到兼容性问题。如果内核有 BTF 信息可以直接用 vmlinux.h 头文件这样写 eBPF 程序时不需要自己手动声明内核结构体很多字段还能做到按需重定位。做法很简单bpftool btf dump file /sys/kernel/btf/vmlinux format c vmlinux.h这个头文件放在项目 include 路径下写 BPF 程序时直接 include 进来代码量能省不少。实测下来CO-RE 方式编译出的程序可以在不同小版本内核之间迁移配合 BTF 让部署灵活很多。2.2 一个关键现实eBPF 里不适合做完整 SHA-256我最初在设计时天真地想过让 eBPF 程序在 execve 时直接读取文件内容、计算 SHA-256、再与签名指纹比对。后来写了两版代码全被验证器驳回。原因主要有两个。一个是栈限制eBPF 程序的栈深度只有 512 字节而 SHA-256 需要 64 字节的消息块缓冲区、8 个 32 位工作变量、再加上填充数组剩余空间根本不够。另一个是循环限制虽然新内核允许有界循环但验证器要求循环边界必须在运行前可证明。逐块读取文件并更新摘要这个循环边界取决于文件大小验证器没法静态证明程序会被直接拒绝加载。所以 hornet 的签名功能采取了折中设计用户态 Agent 在文件发布时计算完整的 SHA-256并连同签名验证结果一起处理内核态不做内容级哈希只对“文件身份”做快速查表。文件身份用 inode 和设备号组成查表命中就允许执行未命中就拒绝。这种设计本质上牺牲了一部分实时性换取了可落地性。文件内容发布后只要 Agent 成功扫描并写入白名单签名校验就算生效如果文件在运行中被篡改inode 没变但内容变了这种中间态需要结合其它手段处理后面我会单独说。2.3 Map 和 Ring Buffer 的设计要点hornet 用到了两类内核数据结构。白名单用 BPF_MAP_TYPE_HASHkey 是文件身份结构体value 用文件内容哈希的前若干字节做标识。选择 Hash Map 而不是数组是因为白名单的 key 是离散的文件 inode、设备号无法用连续索引映射Hash Map 的查表复杂度接近 O(1)在 execve 高频路径上性能足够。为了保证查表性能稳定max_entries 要提前估够避免运行期频繁扩容。我一般按业务目录文件数的两倍进行配置留出余量。事件上报用 BPF_MAP_TYPE_RINGBUF。相比老的 perf event arrayRing Buffer 在单生产者多消费者的场景下表现更好丢事件概率更低用户态读取也简单。审计事件会带 pid、uid、进程名、文件路径、校验结果这几项关键信息方便用户态还原“谁、在什么时候、尝试执行了什么”。Map 的 key 设计值得特别说一句。我一开始只用文件路径做 key后来发现路径天然有弱点同一份文件可以通过硬链接在两个路径下出现也可以在不同挂载命名空间里显示成不同路径。所以最终改成了 inode 设备号。inode 在设备内是唯一的设备号又能区分不同文件系统组合起来基本能锁定一个真实文件。这篇文章后续的示例代码也都按这个 key 来写。3. 签名功能的完整实现过程3.1 用户态签名采集与白名单下发用户态 Agent 是签名功能的第一环负责回答一个问题哪些文件是可信的流程分四步扫描目录、提取签名、验证签名、下发白名单。下面用结构化的伪代码展示核心流程代码做了简化重点看流程骨架。def scan_and_sign(root_dir): for path in walk_dir(root_dir): if not is_elf(path): continue # 1. 从 ELF 自定义段或附加文件中提取签名 sig extract_signature(path) # 2. 用可信根证书验证签名的有效性 if not verify_signature(sig, TRUSTED_ROOT_CA): log_blocked(path, reasoninvalid_signature) continue # 3. 计算文件内容哈希作为白名单指纹 digest sha256(read_file(path)) # 4. 以 inode设备号为 key下发到内核 Map key get_file_key(path) bpf_map_update_elem(WHITELIST_MAP, key, digest[:4]) log_allowed(path, digest)第一步提取签名需要约定签名存放位置。ELF 文件可以自定义一个 section比如.note.hornet.sig非 ELF 的脚本文件则把签名放到文件尾部。这里的关键是签名必须覆盖原始文件内容不能只对文件名的字符串签名否则攻击者把签名原样复制到另一个文件上也能通过校验。第二步验证签名通常用 OpenSSL 的 PKCS#7 或 CMS 接口来做。流程是先加载可信根证书再用根证书验证签名证书链最后验证签名摘要与文件摘要一致。验签失败的文件一律不进白名单这步是整个签名功能的安全基点。第四步下发白名单时value 存的是文件内容哈希的前 4 字节。有人会问为什么不在用户态直接下发完整 SHA-256 到 Map因为内核对 value 的大小有限制而且白名单 Map 在 execve 路径上频繁查表value 越短、缓存越友好。4 字节的内核查表校验加上用户态 Agent 的定期全量比对已经能满足绝大多数场景。3.2 内核 LSM 拦截逻辑让不可信文件无法被 execve内核态是签名功能真正“强制执行”的地方。核心代码挂在内核的 bprm_check LSM Hook 上这段 Hook 会在内核准备执行新程序时被调用返回非零值就能直接阻止这次 execve。核心逻辑如下SEC(lsm/bprm_check) int BPF_PROG(hornet_exec_check, struct linux_binprm *bprm) { struct file_key key {}; struct inode *inode; // 从 bprm 结构中取到要执行文件的 inode inode bprm-file-f_path.dentry-d_inode; key.ino inode-i_ino; key.dev inode-i_sb-s_dev; // 如果命中白名单放行 if (bpf_map_lookup_elem(whitelist, key)) { return 0; } // 未命中记录审计事件并拒绝执行 log_deny_event(bprm, key); return -EPERM; }这段代码需要注意几个细节。bprm-file 是内核准备执行的文件对象这里访问它的路径和 inode 属于 BPF LSM 允许的上下文访问范围。LSM Hook 里的返回值和 tracepoint 完全不同tracepoint 里返回负数只是记录无法阻止系统调用但 LSM Hook 里返回非零就是真实拒绝execve 会失败并返回 EPERM。从实际效果来说即使攻击者已经拿到了 root 权限、能够替换磁盘文件只要没把新文件的 inode 挂进白名单试图执行它就会直接被内核拦下来。这种拦截发生在文件被真正运行之前比任何用户态巡检都更可靠。3.3 审计链路记录每一次执行和拒绝安全功能光有拦截不行还得有审计。hornet 的审计程序挂在进程执行 tracepoint 上负责采集执行事件。SEC(tracepoint/sched/sched_process_exec) int hornet_exec_audit(struct trace_event_raw_sched_process_exec *ctx) { struct event_info ev {}; ev.pid bpf_get_current_pid_tgid() 32; ev.uid bpf_get_current_uid_gid() 0xffffffff; bpf_get_current_comm(ev.comm, sizeof(ev.comm)); // 从 tracepoint 参数中取到执行文件名 bpf_probe_read_kernel_str(ev.filename, sizeof(ev.filename), (void *)ctx-filename); bpf_ringbuf_output(events, ev, sizeof(ev), 0); return 0; }这段审计程序和 LSM 拦截程序是独立的两个 BPF 程序各自负责一件事情一个做决策一个做记录。为什么要拆开因为 LSM 路径上日志输出会增加延迟而审计路径即使没有完整校验结果也应该持续工作。两个程序分离后如果后续需要加新的审计字段不需要动拦截逻辑维护成本更低。用户态 Agent 读取 Ring Buffer 后把事件按时间、进程、文件三个维度聚合然后决定是告警还是静默忽略。比如某目录下频繁出现未签名文件被拦截Agent 可以自动生成一条中等级别告警提醒安全团队关注是否有人正在投放恶意文件。4. 实测中踩过的坑与排查心得4.1 验证器把我卡死的三个典型场景eBPF 程序写起来和普通 C 程序感觉完全不一样。最大的区别在于验证器。它不是只在加载时做一次静态检查而是会模拟执行你的程序遍历所有可能的路径。有一处非法访问程序就直接拒绝加载。我在 hornet 开发期间被验证器拒绝过无数次挑三个最典型的场景说说。第一个是字符串处理中的循环问题。我早期想自己实现一个路径哈希按字节遍历路径字符串更新哈希值。验证器要求循环边界必须在执行前静态可证明于是我在循环前手动加了一个“取字符串长度”的逻辑结果长度不确定又导致循环边界不明确。最后还是放弃了自己遍历改用了 bpf_probe_read_kernel_str 加固定展开的方式。第二个是未初始化的栈变量。写 Map 的 key 结构体时如果只给 ino 和 dev 两个字段赋值其余字段忘记清零验证器会认定你在用未初始化的栈内存。社区里很多报错看起来像“invalid indirect read from stack”实际原因就是这里。解决方法是定义 key 后立刻做 memset或者用struct file_key key {};初始化。第三个是返回值位置不对。LSM 程序里返回负值验证器并不会直接报错但如果不清楚不同程序类型对返回值的语义就会写出“看起来能拦截、实际只审计”的尴尬程序。这个坑非常隐蔽我一开始把拦截逻辑挂在了 tracepoint 上测试时发现审计事件能对上但进程照常执行排查半天才反应过来是挂错了 Hook 类型。4.2 策略绕过与硬链接、动态库的问题签名功能上线后我开始琢磨绕过手段。如果攻击者知道系统按 inode 白名单做校验能不能通过硬链接把一个可信文件链接到一个新路径然后修改硬链接指向的内容答案是不行。因为硬链接不会改变 inode内容一变虽然白名单 key 还在但文件内容哈希已经变了。在“inode 内容指纹”双因子模型下单纯改路径没用单纯改内容也没用必须同时满足 key 命中且内容哈希与用户态记录一致。但动态库是另一个问题。execve 只能校验主程序文件进程运行时动态加载的 .so 文件走的是 open 和 mmap 路径不会被 bprm_check 覆盖。也就是说一个签名过的可执行文件可能加载一个未签名甚至恶意的共享库。要覆盖动态库场景需要在 file_open 或 mmap_file 这类 LSM Hook 上再加一段校验逻辑。但这会显著放大校验频率因为进程运行中打开的每个文件都可能触发。我的建议是先保证主程序可信动态库校验作为二期能力逐步加上否则一上来就把所有文件都纳入校验范围性能压力会非常大。另外还有符号链接问题。符号链接本身不参与 execve 的文件身份识别内核解析后最终落到目标文件的 inode 上所以白名单校验天然免疫符号链接绕过。这一点在实现前我专门验证过算是省了一个隐患。4.3 性能表现与调优记录性能问题始终是安全类 eBPF 程序绕不开的考察点。我对 hornet 做过一轮压测基准环境是 8 核虚拟机模拟每秒创建并执行 300 个进程的负载。只运行审计 tracepoint 时单次 execve 路径的开销大约在 1.5 微秒左右。加上 LSM 白名单查表后单次开销上升到了 2 到 3 微秒。这个量级对绝大多数业务系统来说可以忽略不计毕竟 execve 本身不是高频操作数据库查询和网络转发的开销远高于它。性能优化的空间主要在两个方向。一是让查表尽量简单key 结构体固定大小不要变长否则 Map 查表期间需要逐字段比较延迟会跟着上来。二是事件聚合的频率控制Ring Buffer 不适合每条事件都立刻唤醒用户态可以让 Agent 用轮询方式批量读取降低上下文切换次数。压测中把事件批量读取间隔调到 50ms 后整体 CPU 占用又降了大约三成。如果未来需要校验 file_open 路径性能压力会成倍增加届时要考虑 per-CPU Map 分担锁竞争、以及用户态采样上报降低事件量级。这些都属于优化方向基础架构不用大改。5. 从 hornet 出发扩展方向与一点个人体会5.1 可以继续扩展的四个方向签名功能落地之后相当于给系统装了一个内核态的可信执行开关。沿着这个基础能力还可以做不少事。第一容器场景策略隔离。不同容器可以挂不同的白名单 Map通过 cgroup id 或 namespace pid 区分。这样同一个节点上A 容器只能跑 A 签名组的文件B 容器只能跑 B 签名组的文件互不干扰。实现上需要额外引入一层“容器身份到白名单 Map”的映射。第二文件内容变更的在线发现。当前白名单的 value 只存了哈希前缀Agent 可以定期对目录做全量扫描找出“inode 未变但内容哈希已变”的文件主动告警。这能弥补内核态不做完整 SHA-256 的空档两者配合就很完整。第三与安全事件平台对接。Ring Buffer 的事件格式可以切标准 JSON 格式由 Agent 转发到消息中间件再做规则关联。比如“某用户账号连续触发多个未签名文件拦截”这种模式可以作为风险行为特征。第四按发布流水线自动签名。现在已经能做到签名校验下一步是让 CI/CD 平台在构建产物生成之后自动签名并把产物信息同步给用户态 Agent 做白名单预下发。这样新版本发布不需要人工干预全链路自动化。5.2 实操过程中的几条经验写 hornet 的过程中有几个经验让我印象很深。第一先跑通最小闭环再考虑复杂的签名体系。第一版可以不验证证书链只验证文件内容哈希等到目录枚举、事件上报、Map 同步都稳定了再引入完整签名验证。否则概念太多定位问题会很痛苦。第二善用 bpftool 做快速验证。加载 BPF 程序后用bpftool map dump直接看白名单 Map 内容用bpftool prog tracelog看内核日志能在十分钟内确认“程序到底有没有跑起来、查表到底查到没有”。省去很多盲猜时间。第三把能做减法的逻辑全部留给用户态。eBPF 的指令数、栈空间、helper 能力都是硬约束任何能在用户态提前完成的计算都不要带进内核。这个原则贯彻得越彻底后续踩验证器的坑就越少。第四Map 命名和字段命名要保持清晰。eBPF 程序往往挂载多个 Hook现场排查时经常分不清某段逻辑属于哪个功能模块。给所有 Map、key、事件结构体加上 hornet 前缀和功能后缀能让后续维护省心很多。签名校验这件事做到最后拼的不是算法多高深而是“用户态判断可信、内核态执行决策”这两层是否严密衔接。如果你也在做类似的可信执行校验希望 hornet 这套实现拆解能给你一个可靠参考。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。