BPF execution provides safety by virtue of program verification, but for a long time in userspace we have also cared about program provenance. Signing extends the trust model: it lets the kernel enforce that only programs signed by an approved key can be loaded.
BPF runs in the kernel, so “who gets to load code” matters. The signing support in recent kernels(v6.18+) lets you sign BPF programs and verify those signatures at load time using the keyring. This post is a quick hands‑on walk through that experiment.
1. Environment Setup
This experiment was done on 6.18 UEK-next kernel on Oracle Linux 10:
$ uname -r
6.18.0-2.el10ueknext.x86_64
Latest kernel headers are needed:
# Clone the uek-next branch
$ git clone https://github.com/oracle/linux-uek.git --depth=1 --branch ueknext/latest --single-branch
$ cd linux-uek
# Install headers to a separate path, so it doesn't corrupt any headers installed on the system
$ make headers_install INSTALL_HDR_PATH=/tmp/ueknext-headers
Also, latest libbpf and bpftool from https://github.com/libbpf/bpftool is used for this experiment:
$ bpftool -V
bpftool v7.7.0
using libbpf v1.7
features: llvm, skeletons
This is needed as signing BPF programs is only present in latest versions
2. Simple BPF program
We’ll use a minimal fentry program that prints a message when __do_sys_getpid is called.
/* LICENSE: GPLv2 */
#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";
Let us build this program:
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -c hello.bpf.c -o hello.bpf.o
GCC BPF toolchain (optional) – An alternative way of compiling the bpf program.
You can also build the BPF object with GCC’s BPF backend (bpf-unknown-none-gcc) instead of clang and still generate a signed skeleton with bpftool. The signed program loads and attaches normally.
$ sudo dnf --enablerepo=ol10_developer install gcc-bpf-unknown-none
$ bpf-unknown-none-gcc -O2 -g -D__TARGET_ARCH_x86 -c hello.bpf.c -o hello.bpf.o \
-I/tmp/ueknext-headers/include/ -I/usr/include/
3. Creating a signing key
We generate two self‑signed X.509 certificates up front:
- Key A is the trusted key we will register in the keyring.
- Key B is not registered and is used to show that untrusted signatures fail.
Why two keys ? Key A is the trusted: signing key we register in the keyring. Key B is the untrusted one, this is to prove that the kernel only accepts the signatures that match a registered key
This makes the experiment explicit: only the key placed in the keyring is accepted.
# Create Key directories
$ mkdir -p ~/bpf-keys-A ~/bpf-keys-B
# Write the OpenSSL config for Key A
$ 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
# Copy the config for Key B and update the CN
$ cp ~/bpf-keys-A/x509.genkey ~/bpf-keys-B/x509.genkey
$ sed -i 's/Key A/Key B/' ~/bpf-keys-B/x509.genkey
Generate Key A (trusted)
$ openssl req -new -nodes -utf8 -sha256 -days 36500 \
-batch -x509 -config ~/bpf-keys-A/x509.genkey \
-outform PEM -out ~/bpf-keys-A/signing_key.pem \
-keyout ~/bpf-keys-A/signing_key.pem
$ openssl x509 -in ~/bpf-keys-A/signing_key.pem \
-out ~/bpf-keys-A/signing_key.der -outform der
Generate Key B (untrusted)
$ openssl req -new -nodes -utf8 -sha256 -days 36500 \
-batch -x509 -config ~/bpf-keys-B/x509.genkey \
-outform PEM -out ~/bpf-keys-B/signing_key.pem \
-keyout ~/bpf-keys-B/signing_key.pem
$ openssl x509 -in ~/bpf-keys-B/signing_key.pem \
-out ~/bpf-keys-B/signing_key.der -outform der
4. Register key A in the session keyring
We register the verification certificate into the session keyring, create a new keyring, link the key to the new keyring created:
$ keyctl padd asymmetric example_bpf_key_A @s < ~/bpf-keys-A/signing_key.der
# Create a new keyring
$ keyctl newring example_bpf_keyring_A @s
# Get the key id, keyring id and then link them
$ key_id=$(keyctl search @s asymmetric example_bpf_key_A)
$ keyring_id=$(keyctl search @s keyring example_bpf_keyring_A)
$ keyctl link $key_id $keyring_id
# View the setup using keyctl
$ keyctl list @s
Now Key A can be used to verify while loading a userspace program which uses a signed skeleton.
5. Generate a light-weight skeleton with this new example key A and key B
Let us ask bpftool to generate a signed skeleton for us and we can do that using:
$ 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
The skeleton header provides the small helper API we use to open, load, and attach the BPF program, along with the data structures it needs. In the signed case, the skeleton also carries the signature metadata, so the loader can pass it to the kernel for verification during load.
6. Load a trusted signed program (A) and reject an untrusted one (B)
We use two loaders: one for the trusted key (A) and one for the untrusted key (B). The only difference is the signed skeleton they include. The loaders point the kernel at the session keyring using KEY_SPEC_SESSION_KEYRING.
| loader_A.c | loader_B.c |
|---|---|
| |
Let us build them against the latest kernel-headers installed in Step 1
$ cc -O2 -g loader_A.c -o loader_A -I/tmp/ueknext-headers/include
$ cc -O2 -g loader_B.c -o loader_B -I/tmp/ueknext-headers/include
Now let us try to do some final steps:
- Mount debugfs for observing the trace (in terminal 1)
$ sudo mount -t tracefs tracefs /sys/kernel/debug/tracing
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
- Run the loader in terminal 2
$ sudo ./loader_A
load A = 0
attach A = 0
(this is paused, please monitor terminal 1, you would be seeing something like this )
(sd-exec-strv)-18880 [008] ....1 1366.403616: bpf_trace_printk: I loaded prog 'A'
<...>-18881 [011] ....1 1366.403868: bpf_trace_printk: I loaded prog 'A'
<...>-18881 [011] ....1 1366.406241: bpf_trace_printk: I loaded prog 'A'
Ctrl + C on terminal 2 to stop.
The trusted program (A) loads fine, and the bpf_trace_printk() is called.
- Let’s try loader_B
$ sudo ./loader_B
load B = -126
The untrusted one (B) is rejected, -126 is ENOKEY — the kernel could not find a matching trusted key in the keyring for the signature on program B.
The only functional difference between the loaders is the embedded signature metadata in the signed skeleton. That makes the trust decision entirely dependent on the key that signed the object and whether its certificate exists in the keyring.
7. Conclusion
This hands‑on shows the full loop: sign, register the key, load, and see trust enforced by the kernel. The kernel accepts only signatures that match a key in the keyring, so trust is enforced independently of verification.
Thanks to Alan Maguire for his great help and guidance.