LSM BPF: Enforcing Security Right Inside the Kernel

K
Kai··6 min read

Fourteen articles in, every eBPF program we've written has observed — counting, tracing, measuring. Part V switches roles: eBPF enforces. We open with LSM BPF, probably the most "legitimate" way to enforce, because it attaches to the very security framework the whole Linux kernel relies on.

What LSM is, and why eBPF attaches there

LSM (Linux Security Modules) is the framework of security hooks scattered throughout the kernel: before opening a file, before mounting, before creating a socket, before loading another bpf program... the kernel calls through LSM to ask "is this operation allowed?". This framework is also where SELinux and AppArmor plug in. Each hook is a security_* function in the kernel; a module returns 0 (allow) or a negative errno like -EPERM (deny).

Since kernel 5.7, eBPF has become an LSM: you write an eBPF program that attaches directly to those hooks. Unlike every program type before in this series, the return value of an LSM program decides the fate of the operation — return 0 and it's allowed, return -EPERM and the kernel blocks it. This is the turning point: from observing to controlling.

   Process calls open("/etc/passwd")
            │
            ▼
   kernel: security_file_open(file)   ◄── LSM hook
            │  asks each active LSM
            ├── apparmor:  allow
            ├── yama:      allow
            └── bpf:       our eBPF program ──► return 0  (allow)
                                              └► return -EPERM (BLOCK)
            │
            ▼ if every LSM allows
   continue opening the file

Writing an LSM program that blocks opening a file

Block opening any file named exactly lsm-secret. The hook is file_open:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>

SEC("lsm/file_open")
int BPF_PROG(deny_open, struct file *file, int ret)
{
    if (ret != 0)                    // a prior LSM already decided -> respect it
        return ret;

    char name[16] = {};
    const unsigned char *p = BPF_CORE_READ(file, f_path.dentry, d_name.name);
    bpf_probe_read_kernel_str(name, sizeof(name), p);   // read the file name (CO-RE)

    const char want[] = "lsm-secret";
    for (int i = 0; i < sizeof(want) - 1; i++)
        if (name[i] != want[i])
            return 0;        // not the target -> allow
    return -1;                       // -EPERM: BLOCK
}

A few core points. SEC("lsm/file_open") attaches to the LSM hook file_open. The BPF_PROG macro (from libbpf) expands the hook's signature — the file parameter is a struct file *, and ret is the verdict prior LSMs returned. The convention is to respect the prior decision: if ret != 0 (someone already blocked) just return ret, don't "re-open" what's already been denied. The file name is read via BPF_CORE_READ(file, f_path.dentry, d_name.name) — again CO-RE (Article 5), following file → path → dentry → name. Return -1 (-EPERM) to block.

First try: loads, attaches, but blocks nothing

Compile and load like any eBPF program, then test:

clang -O2 -g -target bpf -I. -c lsmtest.bpf.c -o lsmtest.bpf.o
sudo bpftool prog loadall lsmtest.bpf.o /sys/fs/bpf/lsmtest autoattach
echo secret > /tmp/lsm-secret
cat /tmp/lsm-secret
444: lsm  name deny_open  tag 51ed662ae7c57739  gpl    <- load + attach OK
secret                                                 <- but STILL readable!

The program loaded successfully, shows up as type lsm, attached to the hook — but cat can still read the file. It blocks nothing. This is not a bug in the code. Look at the list of active LSMs:

cat /sys/kernel/security/lsm
lockdown,capability,landlock,yama,apparmor,ima,evm

No bpf. Per the kernel documentation, LSM BPF needs kernel ≥5.7 with CONFIG_BPF_LSM=y and bpf must be in the list of active LSMs — via CONFIG_LSM="...,bpf" at kernel build time, or the boot parameter lsm=...,bpf. Checking the config, CONFIG_BPF_LSM=y is present, but bpf wasn't activated at boot. So the kernel lets you attach the LSM program, but doesn't call it at the hooks — bpf's LSM hooks aren't registered. Attached but not enforcing.

Enabling bpf in the LSM list

Add bpf to the boot parameter. Edit /etc/default/grub, keep the existing LSMs and add bpf:

# /etc/default/grub
GRUB_CMDLINE_LINUX="lsm=landlock,lockdown,yama,integrity,apparmor,bpf"
sudo update-grub && sudo reboot

After the reboot, the LSM list has bpf:

cat /sys/kernel/security/lsm
lockdown,capability,landlock,yama,apparmor,bpf,ima,evm

This is a real system-level operation (changing a boot param + rebooting a node of the cluster). A reboot keeps the machine's IP (unlike stop/start), and installed tooling is still there; only /tmp is wiped, so you have to recompile. After the reboot the cluster recovers on its own (Cilium is back to exactly 140 eBPF programs as before).

Second try: blocks for real

The same program, compiled and attached again — now it enforces:

-- BEFORE attach --
secret                                       <- opens normally
444: lsm  name deny_open  tag 51ed662ae7c57739  gpl
-- AFTER attach: cat /tmp/lsm-secret --
cat: /tmp/lsm-secret: Operation not permitted    <- BLOCKED (EPERM)
-- other file --
ok                                           <- a file with a different name: still opens
-- open via python (different path) --
PermissionError: [Errno 1] Operation not permitted: '/tmp/lsm-secret'

cat gets Operation not permitted — exactly the -EPERM the program returns. A file with a different name (lsm-other) still opens normally, confirming the filter hits the right target. Most importantly: python3 opening the same file is also blocked with the same errno — because the block lives at the LSM hook in the kernel, not in cat or any tool. Every path to open(), regardless of language or program, goes through security_file_open and is blocked. One enforcement point at a kernel hook, and every entry into open() is subject to it.

See where it's attached:

sudo bpftool link show
prog_type lsm  attach_type lsm_mac  target_btf_id 123130

attach_type lsm_mac — MAC is Mandatory Access Control, exactly the security domain LSM serves; target_btf_id points to the file_open hook in the kernel's BTF.

🧹 Cleanup

Removing it is mandatory — an LSM program still attached keeps enforcing:

sudo rm -rf /sys/fs/bpf/lsmtest      # unpin -> link freed -> detached

After unpinning, bpftool prog show no longer lists the lsm program, and cat /tmp/lsm-secret is readable again immediately — instant removal. (If you want to return the node to its fully original state, also remove bpf from lsm= in grub and reboot; but leaving bpf in the list with no program attached is harmless.) The source (lsmtest.bpf.c, the build/attach commands) is at github.com/nghiadaulau/ebpf-from-scratch, directory 14-lsm-bpf.

Wrap-up

LSM BPF takes eBPF from observing to enforcing: it attaches to the Linux Security Modules framework — the very security_* hooks SELinux/AppArmor use — and the return value decides the operation: 0 allows, -EPERM blocks. We wrote deny_open (SEC("lsm/file_open"), the BPF_PROG macro, reading the file name with BPF_CORE_READ, returning -1 to block). The real lesson: the first time the program loaded and attached but blocked nothing — because enforcement only happens when bpf is in the list of active LSMs (/sys/kernel/security/lsm), which needs CONFIG_BPF_LSM=y and bpf in CONFIG_LSM/the lsm= boot parameter. After adding bpf to grub and rebooting, the same program blocks for real: cat and python3 both get Operation not permitted, while a file with a different name still opens — because the block point is at the kernel hook (attach_type lsm_mac), and every entry into open() is subject to it. Respecting the prior LSM verdict (if (ret != 0) return ret) is a mandatory convention.

Article 15 looks at a different filtering mechanism, one that predates eBPF and runs inside every container you've ever run: seccomp-bpf — filtering syscalls with classic BPF (cBPF), the foundation of the Docker/Kubernetes sandbox, and it differs from eBPF in ways well worth knowing.