本文地址:https://www.ebpf.top/post/signed-bpf

1. 引子:谁有权往内核里放 BPF 代码?

eBPF 让我们不改内核、不装模块就能往内核里放代码,但决定谁能放的,一直只是"你是不是 root",而不是"代码是谁构建的、有没有被改过"。内核模块十多年前就有签名机制,eBPF 一直缺这一环。

Linux 6.18 补上了它。KP Singh 的“Signed BPF programs”补丁集给 BPF_PROG_LOAD 加了三个参数:签名、签名长度、到哪个 keyring 找证书。内核先验签,验不过直接拒绝;不带签名的程序照旧加载,“是否必须签名"留给 LSM 决定。

这套补丁最巧的地方是签什么。BPF 程序进内核前会被 libbpf 大量改写,直接签程序签不住,所以它签的是一个由 bpftool 生成、指令固定的小型"装载器"程序。业务程序放在一个专属冻结的 map 里,装载器指令内嵌这个 map 的哈希;内核验装载器签名,装载器核对 map 哈希后再把业务程序装进去。签名覆盖装载器,装载器守住数据,业务程序就这样被"间接签名"了。

本文把这套机制从头跑一遍:一把可信钥匙、一把不可信钥匙,看内核如何放行前者、拒绝后者,再聊一个很有代表性的坑。

2. 签名 BPF 是怎么回事

把它想成收快递:包裹上贴着封条,写着"由 A 公司加封”;你手里有一份信任名单。名单里没有 A,拒收;有 A 但封条被撕过,也拒收。

对应到 eBPF:

  • 封条是签名:构建时用私钥对装载器做 PKCS#7 签名,嵌进骨架头文件。
  • 信任名单是内核 keyring:把签名证书放进去,等于把"A 公司"写进名单。
  • 收货检查发生在 BPF_PROG_LOAD:内核到指定 keyring 找匹配证书,找不到返回 -ENOKEY,签名对不上拒绝加载。

对使用者来说,这些都被 bpftool 封装好了,只需要记住 bpftool gen skeleton -L -S 多了一个 -S。

3. 实验:一把可信钥匙,一把不可信钥匙

实验环境是 multipass 里的 Ubuntu 26.04 虚拟机,内核 7.2,bpftool 7.7(内嵌 libbpf 1.7)。

用两把钥匙做对照,才能说明内核拒绝的是"钥匙不对",而不是"签名机制没生效":

  • Key A:证书注册进 keyring,代表"可信来源"。
  • Key B:不注册,代表"来路不明"。

第 0 步:准备头文件

动手之前先解决环境问题。签名功能给 bpf_attr 追加了签名指针、签名长度、keyring 编号三个字段,用户态要能填这三个字段,就需要新版内核头文件;生成的骨架又依赖 libbpf 1.7 以上的 skel_internal.h。发行版仓库往往跟不上(Ubuntu 26.04 的 libbpf-dev 还是 1.6.3),最省事的办法是从与运行内核同版本的源码树里各装一份:

1
2
3
4
sudo apt-get install -y keyutils clang bpftool
cd ~/linux-7.2
make headers_install INSTALL_HDR_PATH=$HOME/k72-headers
make -C tools/lib/bpf -j16 DESTDIR=$HOME/libbpf-7.2 prefix= install

keyutils 提供 keyctl 命令,后面操作 keyring 全靠它。

第 1 步:一个最小的 BPF 程序

我们需要一个"有没有跑起来一眼可见"的程序。fentry 是挂在内核函数入口的钩子,__do_sys_getpid 是 getpid() 系统调用的实现,任何进程调 getpid() 都会触发它。程序里用一个全局变量 prog_A 决定打印 A 还是 B,这样同一份 BPF 代码可以被两把钥匙分别签名,运行时又能区分出是哪个 loader 装进来的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/* hello.bpf.c */
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

int prog_A;

SEC("fentry/__do_sys_getpid")
int BPF_PROG(hello)
{
    bpf_printk("I loaded prog '%c'\n", prog_A ? 'A' : 'B');
    return 0;
}

char _license[] SEC("license") = "GPL v2";
1
2
3
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 \
    -I$HOME/libbpf-7.2/include -I$HOME/k72-headers/include \
    -c hello.bpf.c -o hello.bpf.o

产物 hello.bpf.o 是普通的 ELF 目标文件,到这一步还没有任何签名。

第 2 步:生成两套自签证书

有了程序,接下来准备钥匙。签名用的是标准的非对称密码学——私钥签、公钥验。内核 keyring 接受的是 X.509 证书(公钥加身份信息),所以我们用 openssl 生成自签证书。配置里的 subjectKeyIdentifier=hash 值得留意:它给证书生成一个唯一标识(SKID),签名里会带上这个标识,内核验签时就是靠它到 keyring 里找对应的证书,而不是靠你给 key 起的名字。这一点后面会再遇到。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
mkdir -p ~/bpf-keys-A ~/bpf-keys-B
cat > ~/bpf-keys-A/x509.genkey <<'EOF'
[ req ]
default_bits = 2048
distinguished_name = req_distinguished_name
prompt = no
string_mask = utf8only
x509_extensions = myexts
[ req_distinguished_name ]
CN = Example BPF Signing Key A
[ myexts ]
basicConstraints=critical,CA:FALSE
keyUsage=digitalSignature
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid
EOF
sed 's/Key A/Key B/' ~/bpf-keys-A/x509.genkey > ~/bpf-keys-B/x509.genkey

for k in A B; do
  openssl req -new -nodes -utf8 -sha256 -days 36500 -batch -x509 \
      -config ~/bpf-keys-$k/x509.genkey \
      -outform PEM -out ~/bpf-keys-$k/signing_key.pem \
      -keyout ~/bpf-keys-$k/signing_key.pem
  openssl x509 -in ~/bpf-keys-$k/signing_key.pem \
      -out ~/bpf-keys-$k/signing_key.der -outform der
done

每套钥匙得到两个文件,分工明确:

  • signing_key.pem:私钥加证书,只给 bpftool 签名用,绝不离开构建机。
  • signing_key.der:只有证书,是要放进内核 keyring 的那一份。

私钥从头到尾不进内核,也不会出现在生成的任何头文件或二进制里。

第 3 步:把 A 的证书注册进 session keyring

证书生成好了,要让内核认识它。Linux 内核自带一套密钥管理服务,把密钥组织成一个个 keyring,有编进内核的 builtin keyring、UEFI 固件带来的 platform keyring,也有每个登录会话一个的 session keyring(@s)。这一步我们把 A 的证书放进当前 session keyring,相当于把"A 公司"写进信任名单。内核解析 DER 证书后保存公钥和 SKID,随时等着被查找。

1
2
keyctl padd asymmetric example_bpf_key_A @s < ~/bpf-keys-A/signing_key.der
keyctl list @s

B 的证书故意不注册。keyctl list @s 能看到刚加的 key,类型是 asymmetric。

第 4 步:生成签名骨架

现在到了整个流程的核心。bpftool gen skeleton -L 生成 light skeleton,-S 让它顺手签名。在这一条命令背后,bpftool 做了三件事:一是用 libbpf 把 hello.bpf.o 打包成一个"元数据 map",里面是真正的 hello 程序和它需要的一切;二是生成一个很小的装载器 BPF 程序,它的任务是在内核里读这个 map、创建资源、把 hello 装进去;三是用 -k 的私钥对装载器指令做 PKCS#7 签名,并把 -i 证书的 SKID 写进签名,告诉内核"用这张证书来验我"。三样东西一起嵌进生成的头文件。

1
2
3
4
5
$ bpftool gen skeleton -L -S -k ~/bpf-keys-A/signing_key.pem \
    -i ~/bpf-keys-A/signing_key.der hello.bpf.o name hello_signed_A > hello_signed_A.lskel.h

$ bpftool gen skeleton -L -S -k ~/bpf-keys-B/signing_key.pem \
    -i ~/bpf-keys-B/signing_key.der hello.bpf.o name hello_signed_B > hello_signed_B.lskel.h

两个头文件的 BPF 程序完全相同,唯一区别是签名——一个是 A 签的,一个是 B 签的。

第 5 步:两个几乎一样的 loader

最后写一个把骨架装进内核的 loader。它就是普通的用户态 C 程序,include 签名骨架,调用骨架提供的 open / load / attach 三个函数。真正起作用的一行是 skel->keyring_id = KEY_SPEC_SESSION_KEYRING:它告诉内核"验签时到我这个进程的 session keyring 里找证书"。load 时骨架内部会创建元数据 map、冻结它、然后带着签名调 BPF_PROG_LOAD,内核在这一刻完成验签。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* loader_A.c —— loader_B.c 只是把 A 换成 B、prog_A 设为 0 */
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
#include <linux/keyctl.h>
#include "hello_signed_A.lskel.h"

int main(void)
{
    struct hello_signed_A *skel = hello_signed_A__open();
    if (!skel) { perror("open"); return 1; }

    skel->bss->prog_A = 1;
    skel->keyring_id = KEY_SPEC_SESSION_KEYRING;   /* 告诉内核:到我的 session keyring 找证书 */

    int err = hello_signed_A__load(skel);
    printf("load A = %d%s\n", err, err == -ENOKEY ? " (ENOKEY)" : "");
    if (err) goto out;

    err = hello_signed_A__attach(skel);
    printf("attach A = %d\n", err);
    if (err) goto out;

    pause();
out:
    hello_signed_A__destroy(skel);
    return err ? 1 : 0;
}
1
2
$ cc -O2 -g -I$HOME/libbpf-7.2/include -I$HOME/k72-headers/include loader_A.c -o loader_A
$ cc -O2 -g -I$HOME/libbpf-7.2/include -I$HOME/k72-headers/include loader_B.c -o loader_B

注意 loader 不需要链接 libbpf。骨架头文件是自包含的,直接封装了 bpf() 系统调用——这正是 light skeleton “轻"的含义,也是它能被签名的原因之一:装载逻辑固定、不依赖运行时库。

顺便说一句,skel->bss->prog_A = 1 这类变量初值是在加载时通过上下文传给装载器的,不在签名范围内。签名保护的是"代码”,不是数据段的初值。

验证结果

一个终端看 trace,另一个终端跑 loader:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 终端 1
sudo mount -t tracefs tracefs /sys/kernel/debug/tracing
sudo cat /sys/kernel/debug/tracing/trace_pipe

# 终端 2
$ sudo ./loader_A
load A = 0
attach A = 0
# 终端 1 开始持续打印:bpf_trace_printk: I loaded prog 'A'

$ sudo ./loader_B
load B = -126 (ENOKEY)

A 顺利加载并挂上钩子,系统里每一次 getpid() 都会留下一行打印。B 在 load 这一步就被内核拦下,-126 就是 ENOKEY:内核拿着 B 签名里的证书标识去 session keyring 里找,没找到。

两个 loader 的代码只差一个头文件,内核的放行与拒绝完全取决于"签名用的钥匙在不在名单里"。这就是签名机制的全部含义。

4. 插曲:明明注册了 A,为什么还是 ENOKEY?

为了方便反复验证,我把"注册证书 + 运行 loader"包成了一个 run.sh 脚本。有趣的部分来了:同一台机器,换一个终端再跑 run.sh A,居然也被拒了。keyctl list @s 一看,keyring 里明明有一把叫 example_bpf_key_A 的 key。

我最初怀疑是 session keyring 的归属问题(PAM 登录、sudo 切换 uid 之类),复现了四种登录方式,全部通过,方向不对。

回头缩小范围:BPF 加载路径上 -ENOKEY 只有一个出口,就是"keyring 里找不到与签名证书匹配的 key"。既然同一个二进制在别处能过,那 keyring 里那把 key 一定不是同一张证书。

/proc/keys 帮了大忙。它是内核暴露的只读视图,列出当前进程能看见的所有 key,每一行末尾会带上证书 SKID 的后 4 个字节:

1
2
3
4
5
$ sudo grep example_bpf_key_A /proc/keys
1b9b4efd I--Q---  2 perm 39010000  0  0 asymmetri example_bpf_key_A: X509.rsa c2502a53 []

$ openssl x509 -in ~/bpf-keys-A/signing_key.der -inform der -noout -ext subjectKeyIdentifier
    6B:78:55:26:...:E3:97:93:12      # loader_A 用的证书,尾巴是 E3:97:93:12

c2502a53 对不上 e3979312。再一查,c2502a53 匹配的是 /root/bpf-keys-A/signing_key.der:keyring 里那张证书来自 /root,而 loader 是用 /home/ubuntu 下的钥匙签的。

原因很生活化:之前在 sudo -i 的 root shell 里手敲过一遍命令,~ 展开成了 /root,于是多出一套同名证书并被注册进了 keyring。旧版 run.sh 又有"同名 key 已存在就跳过注册"的逻辑,结果 keyring 里是 root 那张证书,loader 却是用户那张钥匙签的。

这本质上就是实验里的"B 场景",内核的拒绝是正确的。 它顺带证明了第 2 步埋下的那个原理:内核匹配证书看的是签名里的 SKID,不看你给 key 起的名字。同名 key 不等于同一张证书。

修复也简单:run.sh 改成每次用 keyctl session - 起一个全新的 session keyring,注册与加载在同一个干净环境里完成,并打印实际使用的证书指纹:

1
2
3
4
5
6
7
8
# 在全新的匿名 session keyring 中重新执行自己
if [[ -z ${BPF_FRESH_SESSION:-} ]]; then
    export BPF_FRESH_SESSION=1
    exec keyctl session - "$0" "$@"
fi
keyctl padd asymmetric example_bpf_key_A @s < "$cert_a"
openssl x509 -in "$cert_a" -inform der -noout -ext subjectKeyIdentifier
exec ./loader_A

5. 几个容易踩的坑

注册 key 和加载程序必须在同一个 session keyring 里。 session keyring 随进程树继承,新登录、keyctl session 才会产生新的。sudo 默认不换 keyring,这既是便利,也是上面事故的前提。

session keyring 是"自证自签"。 谁能加载 BPF,谁就能往自己的 session keyring 塞证书,再用对应私钥签程序。它适合演示和过渡期,不构成生产级信任。生产上应该用内核内建 keyring 或 secondary keyring,再配合 LSM 策略拒绝未签名或来自用户 keyring 的程序。这个问题社区已经在解决,见下一节。

调试会 pause() 的程序,用 timeout N stdbuf -o0。 否则进程被杀时 stdio 缓冲区丢失,会误以为程序没输出。

6. 总结:现在能做什么,还差什么

这次实验验证了三件事:签名 BPF 的完整闭环可以跑通;内核只认 keyring 里的证书,钥匙不对就是 -ENOKEY,没有降级、没有例外;排查 keyring 问题时,/proc/keys 加证书指纹比对,比猜权限有效得多。

6.18 这套方案解决了"能不能签",但还留着两个空档,实验里都碰到了:

  • 信任锚在用户手里。 session keyring 归调用进程所有,谁能加载 BPF 就能往里塞证书。签名证明了"代码没被改过",却没回答"这把钥匙值不值得信"。
  • 内核只验了装载器,没验它装的东西。 元数据的哈希由装载器运行时自查,LSM 准入时看不到载荷是否可信。

2026 年的补丁正在逐个填补:

  • 7 月,Borkmann,已进 bpf-next:内核在加载时直接对"装载器指令 ‖ 元数据"整体验签,并在 LSM 准入前完成。裁决只剩两种:未签名,或全部验证通过。
  • 8 月 21 日,Borkmann,v2 评审中:新增专用 BPF keyring(VERIFY_USE_BPF_KEYRING),启动早期注入密钥后锁定;设置 bpf.keyring_unsealed 后,任何调用方自选的用户 keyring 一律 -EPERM。同时让 bpftool 支持 ML-DSA 后量子签名,证明机制不绑定算法。
  • 策略层:KP Singh 推动把签名裁决接入 IPE,微软 Hornet LSM 也在同一方向迭代。焦点已从"怎么签"转到"用哪套策略表达加载规则"。

方向很清楚:签名覆盖从装载器扩到全部载荷,信任锚从用户 keyring 收紧到启动时锁定的专用 keyring,策略收敛到 IPE 等内建框架。三步落地后,“只有签过名的 eBPF 才能进内核"会像模块签名一样成为一项开箱即用的配置。

而今天就能动手的部分很简单:一条 bpftool gen skeleton -L -S,加一次 keyctl padd,你的 eBPF 程序就有了"谁签的"这个身份。

延伸阅读

若还想顺着读,可看这几篇:

  1. 一文全面了解 LSM BPF (含实战,强烈建议收藏)
  2. Cloudflare 基于 BPF LSM 热修高危提权 Copy Fail
  3. libbpf 开发 BPF 程序技巧进阶