11 — LSM hooks and cgroups: where policy attaches
Two mechanisms let policy run inside kernel paths without patching them: LSM hooks and cgroup-attached BPF. Where does each sit, what does each know, and how does “attached to the parent” become “runs for the child”?
Requirements: the guest from chapter 7, with bpf in /sys/kernel/security/lsm. If it’s missing, add it to lsm= on the kernel command line.
The LSM path
Step 1: find the hooks. Throughout the kernel, security_<hook>() calls sit at decision points. lsm_hook_defs.h has 268 LSM_HOOK lines at v6.12 (source). Examples: security_socket_connect in __sys_connect_file, security_bprm_check in search_binary_handler, and security_inode_permission in inode_permission.
Step 2: follow the dispatch. Older write-ups describe walking a list called security_hook_heads. In 6.12 that’s gone. static_calls_table holds one slot per hook per LSM, and call_int_hook unrolls direct static calls, guarded by static keys. It stops as soon as a return differs from the default (security.c, LWN).
Step 3: see how BPF fits in. The BPF LSM registers empty functions, bpf_lsm_<hook>. A program attaches with BPF_LSM_MAC, as modify-return for non-void hooks and fexit for void ones (trampoline.c). So your program runs where SELinux would, with the same arguments and the authority to veto.
Order matters. If an earlier LSM denies, your BPF program never runs.
The cgroup path
Step 4: the data structures. A cgroup is a node in a tree at /sys/fs/cgroup. Each task points to a css_set, which holds per-controller state.
Step 5: moving a task. Writing a pid to cgroup.procs reaches cgroup_attach_task, which fires the tracepoint of the same name, and calls cgroup_migrate and cgroup_migrate_execute. css_set_move_task swaps the task’s css_set, and each controller’s attach runs. On fork, cgroup_css_set_fork picks the set, and cgroup_post_fork links the task (cgroup.c).
Step 6: controllers hook their own paths. mem_cgroup_charge runs during page faults (chapter 5), cpu.weight affects the scheduler (chapter 4), and pids.max applies at copy_process.
The cgroup BPF path
Step 7: attaching. Each cgroup has bpf.progs[type] and bpf.effective[type] (kernel/bpf/cgroup.c). cgroup_bpf_attach calls __cgroup_bpf_attach, which adds the program and calls update_effective_progs.
Step 8: inheritance. For this cgroup and every descendant, compute_effective_progs rebuilds the effective list from ancestors’ programs, under BPF_F_ALLOW_MULTI rules (cgroup.c).
Step 9: sockets remember. A socket gets its cgroup at creation (cgroup_sk_alloc). That’s why a socket keeps its cgroup even if its task later moves, and why chapter 7’s hook runs for every process in the subtree without any per-process attach.
Hands-on: deny a connection with BPF LSM
-
Create
lsm_connect.bpf.c:#include "vmlinux.h" #include <bpf/bpf_helpers.h> #include <bpf/bpf_tracing.h> #include <bpf/bpf_core_read.h> #include <bpf/bpf_endian.h> SEC("lsm/socket_connect") int BPF_PROG(deny25, struct socket *sock, struct sockaddr *address, int addrlen, int ret) { struct sockaddr_in sin = {}; if (ret) return ret; if (addrlen < (int)sizeof(sin)) return 0; if (bpf_core_read(&sin, sizeof(sin), address)) return 0; if (sin.sin_family != 2 || sin.sin_port != bpf_htons(25)) return 0; if (bpf_get_current_cgroup_id() != TARGET_CGID) return 0; return -1; /* -EPERM */ } char LICENSE[] SEC("license") = "GPL";The
if (ret) return ret;line keeps an earlier program’s verdict. -
Get the cgroup id, and confirm it by reading the
cgroupbuiltin in bpftrace from a shell in that cgroup:ID=$(stat -c %i /sys/fs/cgroup/lab) -
Build and load it:
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -DTARGET_CGID=${ID}ULL -c lsm_connect.bpf.c -o lsm.o bpftool prog load lsm.o /sys/fs/bpf/lsmdeny autoattach -
Trace it, join the cgroup, and connect:
bpftrace -e 'kretprobe:security_socket_connect { @[retval]=count(); }' & echo $$ > /sys/fs/cgroup/lab/cgroup.procs nc -z -w2 10.0.0.2 25 -
Expected:
ncshould fail with permission denied, a-1should appear in the retval counts, andtcp_v4_connectshould never be reached. -
Compare it with a
cgroup/connect4program returning 0. The failure now comes aftersecurity_socket_connectsucceeds.
Hands-on: prove inheritance
-
Attach a connect4 program to the parent with
multi, then create a child:mkdir /sys/fs/cgroup/lab/child bpftool cgroup show /sys/fs/cgroup/lab/child effective -
Trace the recomputation while you attach:
bpftrace -e 'kprobe:update_effective_progs,kprobe:compute_effective_progs { @[probe]=count(); }' -
Expected:
compute_effective_progsshould fire once per descendant.
Hands-on: watch a migration
Graph cgroup_attach_task with function_graph, using the steps from chapter 1, and move a pid. Look for: cgroup_migrate, cgroup_migrate_execute, css_set_move_task and any controller attach.
If it fails
- Nothing is blocked:
bpfisn’t active in the LSM list. This is the most common failure. - The process isn’t denied: it may not be in the cgroup you expected.
cgroup_bpf_attachwon’t probe: it may be inlined. Use__cgroup_bpf_attach.
The takeaway
Policy belongs at the hook, identity belongs to the object. LSM hooks give policy a fixed, audited seat in each path; cgroups give every process (and every socket it creates) a durable identity that policy can key on without being told. Together they let you say “processes under this node may not do that” in a way the processes can’t influence. Any system that needs tenant-scoped policy wants the same two things: a stable identity attached to the object at creation, and hooks at the decision points, not identity passed in request headers and checked wherever someone remembered to.