资讯详情

资讯详情

eBPF程序可信加载:签名机制原理、内核校验与工程落地实践

eBPF 这几年在可观测性、安全、性能优化领域几乎成了标配技术但很多团队把 eBPF 程序加载进内核之后往往忽略了一个最关键的问题怎么证明这份 eBPF 程序是可信的我在实际项目中维护过基于 eBPF 的安全监控框架 hornet其中最让我头疼的就是签名功能的实现——既要保证只加载经过授权的程序又不能给内核引入太多额外的校验开销。这篇文章把这些踩坑经验整理出来把 eBPF 签名功能的原理、架构、实现细节和部署要点完整捋一遍希望能给正在做类似加固工作的同学一些参考。1. 为什么签名机制对 eBPF 程序是一道绕不开的门槛1.1 eBPF 是双刃剑能力越大风险越大在理解签名机制之前我们要先正视一个事实eBPF 程序一旦通过内核验证器就运行在内核上下文里可以访问网络流量、系统调用参数、文件操作等海量敏感数据。它的能力相当于一个小型内核模块但加载门槛远低于内核模块。在安全领域这是一把双刃剑——监控攻击行为的同时如果加载链路本身被滥用一个攻击者在拿到 root 权限或者 CAP_BPF/CAP_SYS_ADMIN 能力后完全可以加载一个恶意 eBPF 程序把系统的网络流量截获并篡改掉或者绕过现有的安全策略。我在一个生产环境做安全加固时测试过只要具备必要的 Capability就能通过 bpf() 系统调用直接往内核里塞一个 hook 到网络栈的程序。内核验证器只检查这个程序是否安全——不崩溃、不越界、有限循环——但它不检查这个程序是否来自可信的开发者。这是验证器职责边界的明确设计但恰恰也是签名机制存在的理由。1.2 hornet 签名机制要解决的问题hornet 是一个以 eBPF 为基础的安全审计与威胁检测框架它需要加载多个 eBPF 程序来监听各种事件。在多租户环境、生产集群或者合规要求严格的场景下需要解决三个问题第一来源可信问题。加载进内核的 eBPF 程序必须来自经过认证的构建产物而不是被篡改过的二进制。第二加载权限剥离问题。在实际部署中我们不希望给每个业务进程都赋予加载 eBPF 的完整权限因为权限一旦分散被滥用的面就太大了。比较合理的设计是只让一个受信任的加载器进程拥有加载权限其他业务进程想使用 eBPF 功能必须通过受信任的加载器而加载器只加载有合法签名的程序。第三可审计问题。当系统里存在多个团队、多个部门都可以提交 eBPF 程序时需要能追踪到这份程序是谁签发的、什么时间签发的、是否还在有效期。这不仅仅是安全问题也是运维审计问题。1.3 签名机制能给系统带来什么实际价值从防御角度来说签名机制相当于给 eBPF 加载流程加了一道独立的门禁即使攻击者拥有内核相关能力没有匹配的私钥也无法加载任意代码。签名机制配合 LSM 策略还能控制哪些进程有权限发起加载形成纵深防御。从运维角度来说签名让 eBPF 程序的发布流程规范化代码提交后经过构建、测试、签名、分发每一个环节都有记录线上出问题时可以通过签名信息定位到具体的构建批次。我当时推动 hornet 引入签名机制起因是一次内部安全演练。攻防对抗中红队尝试在目标节点上加载一个自定义 eBPF 程序来 hook 敏感的系统调用如果当时没有签名校验这个尝试直接就成功了。所以我可以很直接地说任何将 eBPF 作为安全能力基座的系统都应该考虑给加载链路加签名这不是可选项而是必要项。2. 签名功能的技术选型与整体架构2.1 对称加密还是非对称加密这是一个权衡问题签名机制的第一件事是定算法。对称加密如 HMAC实现简单、性能高但它在分发密钥时天然有短板——生产环境通常存在多个节点假如每个节点都要预置同一个对称密钥一旦任意一台机器被攻破密钥泄露整个系统的加载链就全部失守。非对称加密如 RSA、ECDSA、Ed25519则把公钥和私钥分开公钥可以安全地内置到每个节点私钥只保存在受信任的构建中心。在 hornet 项目中我选择了Ed25519。理由有三点签名和验签速度都很快尤其适合内核侧校验这种性能敏感的场景签名长度短只有 64 字节便于放在元数据里传递密钥生成和签名接口在主流语言里都有成熟的库便于接入现有构建系统。RSA 在签名场景下其实也没问题但密钥太长、签名太长对内核侧的存储和校验都不是很友好。ECDSA 的复杂度主要在处理随机数和 DER 编码上没有 Ed25519 来得干净。2.2 整体架构的分层设计签名功能在 hornet 里不是孤立的一个模块而是横跨构建、分发、加载和内核校验四层第一层是构建层。eBPF 源码经过 clang 编译得到 ELF 文件构建系统计算出需要签名的哈希用私钥对这个哈希做签名生成签名文件。这一步通常发生在 CI 流水线里。第二层是加载层。hornet 的用户态加载器在调用 bpf() 系统调用之前先校验签名是否匹配如果签名不合法直接拒绝加载。这一层是兜底校验防止带病程序进入内核。第三层是内核校验层。单靠用户态校验是不够的因为攻击者可以绕过加载器直接用 libbpf 甚至裸 bpf() 系统调用加载任意程序。所以内核侧必须有一个独立于用户态的验证点通过 LSM hook 对 bpf() 系统调用进行检查核验程序哈希、签名和调用者身份。这样即使绕过加载器也会在内核入口被拦下来。第四层是策略层。签名校验通过之后还需要根据配置文件中的策略决定是否允许加载。比如在 enforce 模式下签名不对就直接拒绝在 permissive 模式下签名不对只记日志放行方便灰度验证。2.3 为什么要同时做用户态和内核态校验有人可能会问既然内核态已经做了校验用户态还要校验一遍不是多此一举吗这里有个现实原因内核态 LSM hook 在验证签名时需要拿到原始程序文件、签名数据、公钥等上下文信息。但如果攻击者已经绕过了加载器他提交给 bpf() 系统调用的程序数据可能非常规格式内核态在解析时会有不少限制。用户态加载器则可以读取完整原始文件、解析 ELF、计算哈希、执行签名验证拥有的信息更全面。另一个原因是失败的代价不同。用户态校验失败进程退出没有任何内核副作用内核态校验失败可能会在系统日志里留下大量告警。把用户态校验作为第一道防线把绝大多数不合规的加载拦截在早期阶段减轻内核侧的负担同时能给出更友好的错误提示。所以我的建议是不要把签名功能的信任完全押在某一个校验点用户态和内核态相互配合才是稳妥的做法。3. 核心实现从计算哈希到内核态校验3.1 签名对象到底对什么签名签名机制里最容易出错的就是签名的对象选什么。如果对整个 ELF 文件做签名那么在构建过程中任何无关元数据的变化——比如编译环境的时间戳、文件权限位、注释信息——都会导致签名失效让部署变成一场灾难。我在前面提到过这个坑我踩过不止一次。更合理的方案是对关键 sections 的哈希做签名。一个 eBPF ELF 文件里有多个 section真正会被加载到内核的是.text、.maps以及各种事件相关的 section。构建期把这些关键 section 的原始字节提取出来拼接后计算 SHA-256 哈希得到程序摘要 digest。然后对 digest 做 Ed25519 签名得到签名数据 signature。这样做的好处是构建可复现性大大提升只要代码逻辑没变哈希就不容易变签名数据很小可以很方便地嵌入配置或者随文件分发加载和校验时不用重新解析整个 ELF只需要在用户态提取同样的 section 字节计算摘要再验签。代码层面的计算大概长这样Python 伪代码实际实现中可以用任何语言import hashlib import ed25519 def compute_program_digest(elf_path, target_sections): digest_ctx hashlib.sha256() # 按顺序读取关键 section 的原始内容 for section_name in target_sections: content extract_section(elf_path, section_name) digest_ctx.update(content) return digest_ctx.digest() def sign_digest(digest, private_key): return ed25519.signature(digest, private_key)3.2 签名文件的组织格式签名信息我建议采用单独的.sig文件来存放避免修改原始 ELF 文件的结构。对于某个编译产物例如hornet_trace.bpf.o对应的签名文件是hornet_trace.bpf.o.sig。文件内部可以用 JSON 或者 protobuf 组织但考虑到简单性和可维护性我用的是 JSON{ format_version: 1, program: hornet_trace, hash_algorithm: sha256, signature_algorithm: ed25519, digest: 3f9a..., signature: 681d..., signer: build-botinternal, timestamp: 2025-01-15T09:30:00Z, permissions: [USE_BPF, TRACE_NETWORK] }这个文件里的digest是明文字节的前缀展示实际校验时可直接从签名文件读取signature是 Ed25519 签名结果。permissions字段则记录该程序允许使用的能力范围加载器会结合策略判断当前调用方是否有权请求这些能力。3.3 用户态加载器的签名校验流程用户态加载器是整个签名体系里最容易控制、也最容易出问题的地方。他对 ELF 文件进行解析、提取、摘要计算和验签全部在普通用户态的上下文里进行调试起来比较方便。实际流程可以分成五步加载器收到一个加载请求文件路径指向待加载的 ELF 程序加载器读取对应的.sig签名文件解析格式版本、哈希算法、签名值、声明权限等字段加载器从 ELF 文件中提取同样顺序的关键 section 内容按签名文件声明的算法目前是 SHA-256计算摘要用内置的公钥对签名值和摘要执行验签验签通过后加载器再进行 bpf() 系统调用把程序加载进内核。这一步的伪代码如下int verify_and_load(const char *elf_path, const char *sig_path) { struct signature_meta meta parse_sig_file(sig_path); uint8_t digest[SHA256_SIZE]; // 1. 从 ELF 中提取关键段并计算摘要 compute_digest_from_sections(elf_path, meta.sections, digest); // 2. 验签 if (ed25519_verify(meta.signature, digest, public_key) ! 0) { log_error(signature check failed: %s, elf_path); return -EPERM; } // 3. 验签通过后调用 libbpf 正常加载 return bpf_prog_load(elf_path); }这里的重点是compute_digest_from_sections必须和构建期使用的 section 列表及顺序严格一致否则验证结果必然不通过。3.4 内核态校验点的插入方案用户态校验只挡住正常调用者挡不住有意的绕过尝试。真正必须的校验点是内核态。我在 hornet 里采用的是LSM hook方式挂载在bpf安全 hook 上拦截BPF_PROG_LOAD操作。当内核收到一次 BPF_PROG_LOAD 请求时LSM 回调被触发传入参数包括加载命令类型、prog 属性、以及当前进程的凭据。在回调函数里我们需要完成检查加载的命令是不是BPF_PROG_LOAD不是就直接放行其他 bpf 操作不受签名影响从union bpf_attr中提取用户态传入的prog_name与程序字节指针从额外的验证上下文比如通过map预置或从btf中读取获取该程序对应的签名摘要重新计算运行时的程序字节哈希并与签名摘要比对如果通过记录一条审计日志不通过根据策略返回-EPERM或-EACCES。需要说明的是内核态要拿到签名数据有多种实现路径。一是用 bpf 程序自身携带的 metadata二是通过专用 map 预置签名摘要三是在 bump 系统调用参数之外通过额外的辅助字段传递签名。我实际用的方案是将程序摘要预置在一个受保护的只读 map 中Lsm 回调里去读这个 map。这种实现的示意逻辑如下static int bpf_lsm_hook(int cmd, union bpf_attr *attr, unsigned int size) { if (cmd ! BPF_PROG_LOAD) return 0; const char *prog_name extract_prog_name(attr); struct prog_digest_entry *entry; entry lookup_sig_map(prog_name); if (!entry) return enforce ? -EPERM : 0; // 找不到签名记录 uint8_t hash[SHA256_SIZE]; compute_bytecode_hash(attr, hash); if (memcmp(hash, entry-digest, SHA256_SIZE) ! 0) { log_audit(signature mismatch: %s, prog_name); return enforce ? -EPERM : 0; } return 0; }当然如果你正在使用某个发行版的标准内核不带自定义 LSM那么可以退一步的做法是把用户态加载器设为可信组件通过文件权限和 capability 管理设置白名单再借助签名校验实现加载前拦截。这种方案的内核态深度弱一些但落地成本低很多适合早期阶段。3.5 enforce 模式与 permissive 模式的实现不管是用户态校验还是内核态校验都需要支持两种模式enforce 模式签名校验失败直接拒绝加载适用于生产环境permissive 模式签名校验失败时记录日志但放行适用于灰度测试期和排查历史版本。两种模式的差异只在一个策略判断上但要特别注意permissive 模式下审计日志里必须记录签名校验失败但放行的标记否则后期回溯时无法区分正常加载和带病加载。我建议把模板日志字段固定为actionallow_unsigned|deny_unsigned配合加载者进程 PID、程序名、文件哈希这样在排查问题时一眼能看到问题出在哪。4. 实操过程从生成密钥对到生产环境全量校验4.1 初始化密钥体系签名功能上线前首先得有一套密钥对。在 hornet 的部署里我建议分三级密钥主私钥存储在离线环境或专门的密钥管理系统内只在签发新公钥或者做证书轮换时使用构建签名私钥CI 流水线用来给 eBPF 程序签名权限限定为只能签名、不能派生其他密钥节点公钥部署到各个业务节点的公钥用来验签不包含私密信息。我用 ed25519 工具生成密钥和签名文件的操作大概是这样# 生成私钥文件权限 600 openssl genpkey -algorithm ed25519 -out hornet_signing_private.pem # 从私钥导出公钥文件 openssl pkey -in hornet_signing_private.pem -pubout -out hornet_signing_public.pem生产实践中私钥不要直接保存在 CI 配置的明文环境变量里。推荐的做法是把私钥放在密钥管理系统的对象存储中CI 在构建阶段通过短时令牌获取使用后立即销毁。4.2 给现有 eBPF 程序加上签名如果你已经有一批编译好的 eBPF ELF 文件不需要重编译只需要在 CI 里增加一个签名步骤。假设产物是hornet_trace.bpf.o签名脚本接收这个文件和私钥路径作为参数# 1. 用工具从 ELF 中提取关键 section 拼接并计算 SHA-256 sha256sum hornet_trace.bpf.o /tmp/digest.txt # 2. 对摘要内容做签名 openssl pkeyutl -sign -inkey hornet_signing_private.pem \ -rawin -digest sha256 \ -in /tmp/digest.txt -out hornet_trace.bpf.o.sig实际操作中你会发现直接用sha256sum对整个文件签名不是最理想的但如果你不能控制编译工具链输出的可复现性它可以作为兜底方案。如果能控制构建流程推荐用前面提到的方式对 section 提取拼接后生成摘要这样 ELF 中的注释时间戳变化不会导致签名失效。4.3 把签名校验集成进 CI 流水线在 CI 里加签名不只是加一步命令还需要把签名做成产物的一部分。完整的发布流水线应该是代码打包编译生成.bpf.o测试阶段运行用户态加载器的--verify-only模式验证签名流程本身是否正常对产物执行签名生成.sig文件把.bpf.o和.sig打包成同一发布产物上传到制品库部署脚本下载产物后自动校验.sig和.bpf.o的匹配关系不匹配直接中止部署。第 2 步很容易被跳过但它其实很有价值。我遇到过一次 CI 里签名工具版本与加载器验签逻辑不一致导致线上加载全部失败的情况。提前在测试阶段跑一遍--verify-only就能提前暴露这类兼容性问题。4.4 线上灰度从 permissive 到 enforce签名功能上线不能一上来就全量 enforce特别是存量环境。我当时的灰度策略是分三步第一步是旁路模式线上加载器先按 permissive 模式运行对每个待加载 eBPF 程序计算签名并校验但无论结果如何都放行。这个阶段把校验日志与实际的加载日志做比对确认没有出现应该放行却验签失败的情况。第二步是过滤模式只对新增的、来自新发布流程的程序启用 enforce 校验存量老程序继续放行。第三步是全量 enforce所有加载都必须验签通过。这一步需要确保所有产物都已经重新签名且公钥已经同步到全部节点。这期间最常遇到的问题就是老程序没有签名文件。处理办法是提前对存量产物批量补充签名并保留旧公钥的混合验证期——我给出一个双公钥验证周期新老公钥并存新程序用新公钥签旧程序用旧公钥验等所有程序都重新签名后再移除旧公钥。这个周期需要至少覆盖两轮完整发布。5. 常见问题与排查清单5.1 加载失败与签名不匹配最典型的报错就是签名校验失败但日志本身可能不会告诉你具体是哪一段哈希对不上。建议在加载器日志里同时记录期望摘要和实际摘要两者不一样时可以直接定位到变更源。现象可能原因解决办法同一份产物本地验签通过线上失败线上节点公钥与 CI 签名私钥不匹配核对两边的公钥指纹重新同步公钥构建日志显示签名成功加载日志显示签名文件缺失制品打包时漏掉了.sig文件检查发布产物的完整文件列表修改过 ELF 的 section 顺序后签名失效签名对象的 section 顺序与校验时不一致统一 section 列表配置全部程序都无法加载内核态 LSM 的 map 未初始化或签名 map 名称错误查看内核日志与 map 加载顺序5.2 密钥轮换时踩过的坑密钥轮换是签名功能上线后最容易被忽略但最容易出问题的地方。我经历过一次轮换事故新公钥已经部署到节点但老公钥被立即移除了此时线上还有一批老程序没有重新签名结果重启容器时所有老 eBPF 程序全部加载失败整个监控链路中断了十几分钟。教训就是轮换必须设置重叠期。新公钥先加入信任列表但老公钥继续保留等所有存量的签名文件都用新私钥重新签发并且部署完成之后才把老公钥从信任列表中移除。这个重叠期的时间取决于你发布全量的周期我建议至少是两轮完整发布周期。5.3 性能开销实测签名功能对性能的影响可能是大家最关心的问题。从实测数据来看:用户态验签一段典型的 eBPF 程序哈希计算加 Ed25519 验签耗时在 0.1ms 到 0.5ms 之间考虑到只是加载时的一次性开销几乎可以忽略不计内核态 LSM 回调每个 BPF_PROG_LOAD 请求增加一次哈希比对在加载阶段只执行一次不是数据路径上的操作所以不会影响 eBPF 程序运行时的吞吐签名 map 查询每个加载请求多一次 map 查找也是微秒级别。真正值得关注的开销是构建环节多出的签名步骤和分发环节多出的签名校验步骤这些属于流程性耗时不涉及运行时性能。我这里测过完整构建流水线签名步骤只增加了大约 3 秒左右完全可接受。所以结论也很直接签名功能对运行时的性能影响微乎其微不应该成为你不上签名的借口。6. 一点实操建议最后再分享几个围绕签名功能的小建议。日志比签名本身更重要。签名校验失败的时候系统必须记录完整上下文加载者 UID、PID、程序名、期望摘要、实际摘要、签名文件路径。否则你只能看到签名失败四个字却无法追溯是哪个进程、哪个包导致的。不要自己造签名协议。先用 Ed25519 和标准哈希不要尝试自定义摘要算法也不要为了让签名文件小一点而压缩它。标准格式意味着你可以用现成的库、现有的工具链去验证和维护而自定义格式只会增加心智负担和出错概率。考虑给签名文件加一个失效时间字段。内部分发 eBPF 程序的周期可能很长如果没有失效时间一个多年前签发的程序理论上可以永远加载。给签名加时间戳并让加载器检查时间窗能有效遏制私钥泄露后攻击者的横向移动窗口。签名不是银弹但没有签名eBPF 的加载链路就是一个漏洞百出的门。我在 hornet 项目里补上这一环之后明显感到整个发布流程变得可控了出了问题也能快速定位是哪个环节不匹配。或许下一步可以在签名校验的基础上叠加容器镜像签名和可执行文件签名让整个可信链完整闭合。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →