☰
eBPF实现文件签名校验:从内核拦截不可信可执行文件
2026/10/22 5:13:20 网站建设 项目流程

做系统安全或者可观测性的朋友,多半会遇到这样一个需求:某个目录、某个用户、某个容器里的可执行文件,能不能只允许“经过可信签名”的版本运行?我之前参与排查一类可疑脚本在服务器上反复作妖的问题时,被这个需求卡了很久。传统做法是给文件做防篡改,但权限一旦被提上去,边改边跑的模式根本防不住。后来我把思路切到 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_HASH,key 是文件身份结构体,value 用文件内容哈希的前若干字节做标识。选择 Hash Map 而不是数组,是因为白名单的 key 是离散的文件 inode、设备号,无法用连续索引映射;Hash Map 的查表复杂度接近 O(1),在 execve 高频路径上性能足够。为了保证查表性能稳定,max_entries 要提前估够,避免运行期频繁扩容。我一般按业务目录文件数的两倍进行配置,留出余量。

事件上报用 BPF_MAP_TYPE_RINGBUF。相比老的 perf event array,Ring 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, reason="invalid_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 这套实现拆解能给你一个可靠参考。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询