Virtualization · Sandboxing
Enforcing Sandbox Policy at BPF LSM Hooks
How an eBPF program attached to a kernel LSM hook can decide a security question with access to kernel objects, and why the hook it chooses determines what it can express.
The three mechanisms discussed so far in this cluster decide at different distances from the operation. A seccomp filter sees a syscall number and six words. Landlock sees a resolved inode and an access right. An eBPF program attached to a Linux Security Module hook sees the kernel objects the hook was called with (the inode, the dentry, the struct file, the cred, the socket, the task) and can read their fields, look up state in maps, and return a verdict. That is a different order of information, and it costs a different order of complexity.
Where the hooks are and what they carry
LSM hooks are calls inserted at security-relevant points in kernel code: security_inode_permission, security_file_open, security_bprm_check, security_socket_connect, security_task_kill, security_mmap_file, security_path_mknod, and several hundred others across the subsystems that have been annotated. Each hook has a fixed prototype, and the prototype determines what the program can inspect.
lsm/file_open receives a struct file *. From it a program can read f_inode, the file’s mode, its owner, and the mount it belongs to. lsm/inode_permission receives an inode, a dentry, and a mask of requested access. lsm/bprm_check_security runs at execve before the new program image is committed, receiving the linux_binprm, which is the point at which an executable’s hash, path, and credentials are all available and the exec can still be refused.
The set of attachable hooks is not the full LSM hook list. The kernel marks which hooks may be instrumented from eBPF, and that set grows between kernel versions. A program attached to a hook the running kernel does not export fails to load, which is a hard failure rather than a silent no-op, and it is the reason portable policies probe the kernel’s BTF for the hook before attaching. Loading such a program is a privileged operation, gated on the BPF and perfmon capabilities on kernels that split those out of CAP_SYS_ADMIN.
The verifier is the constraint that shapes the program
An eBPF program is not compiled C. It is loaded as bytecode, and the kernel’s verifier proves a set of properties before the program runs: every memory access is within a region the program is allowed to touch, every pointer is of a known type, every branch terminates, and no loop runs unbounded unless the verifier can prove a bound. The verifier walks all reachable paths, so a program whose complexity grows with its number of branches takes time proportional to that number to load. The instruction limit for privileged loads is large, on the order of a million instructions after verification, but the practical constraint is the verifier’s path explosion, not the limit.
Pointers from the hook context are typed. A struct file * obtained from lsm/file_open may be read, but writing through it is rejected; a struct dentry * may be passed to helpers that accept it. The verifier tracks the type through the program and refuses a call whose argument type does not match the helper’s signature. That is what makes it safe to expose kernel structures to a program that is not part of the kernel build, and it is also why the program cannot do arbitrary things: the helper set is closed, and the types are enforced.
The helper set available at LSM hooks is restricted compared with the networking program types. A program cannot call bpf_probe_read_kernel to read an arbitrary kernel address, cannot send packets, and cannot spawn work. It can read the fields of its context, call bpf_map_lookup_elem and bpf_map_update_elem on maps it owns, call bpf_get_current_pid_tgid and bpf_get_current_uid_gid, and return an int verdict. Reading a string from userspace, such as a pathname or an argument buffer, is possible through the bpf_d_path helper on some hooks, which resolves a struct path to a pathname string into a buffer the program supplies.
A policy program
A program attached to lsm/file_open that refuses opens of files with the world-writable bit set outside a permitted prefix:
SEC("lsm/file_open")
int BPF_PROG(check_open, struct file *file, int ret)
{
struct inode *inode;
umode_t mode;
if (ret) /* an earlier program already refused */
return ret;
inode = BPF_CORE_READ(file, f_inode);
mode = BPF_CORE_READ(inode, i_mode);
if (!(mode & S_IWOTH))
return 0; /* not world-writable: no opinion */
struct inode_key key = {
.ino = BPF_CORE_READ(inode, i_ino),
.dev = BPF_CORE_READ(inode, i_sb, s_dev),
};
if (bpf_map_lookup_elem(&allowed_inodes, &key))
return 0; /* explicitly exempted */
return -EACCES;
}
The BPF_CORE_READ macro reads a nested field through the CO-RE relocation machinery, which resolves each field access against the running kernel’s BTF at load time. That is what allows the same compiled object to run on kernels whose structure layouts differ, and it is the reason a program that reads s_dev through i_sb does not need to know the offset of s_dev.
The return value is an errno-style negative value or zero. A negative return from an LSM hook becomes the failure of the operation being attempted; zero means the hook has no objection and the remaining hooks and the ordinary permission checks continue. A BPF LSM program receives the accumulated result of the programs attached before it in a trailing ret argument and is expected to propagate a non-zero value unchanged, which is how several programs on one hook compose into a single verdict. There is no “allow” that overrides another LSM: the hooks compose by the same intersection rule as seccomp filters, with the first refusal winning. An eBPF LSM program cannot grant access that SELinux, AppArmor, or the traditional discretionary checks would deny.
Why this is a different kind of decision from a seccomp filter
The distinction is not about expressiveness in the abstract but about which question each can answer. A seccomp filter answers “may this thread issue this syscall with these scalar arguments”, as bounded by the fixed record it receives. An LSM program answers “may this subject perform this operation on this object”, where the subject’s credentials and the object’s identity are both readable.
For sandboxing, that means an LSM program can express policy that no syscall filter can: refuse execve of any binary whose inode is not in a set the sandbox prepared; refuse mmap with PROT_EXEC on a file that is writable; refuse connect to an address outside a prefix; refuse a signal to a process outside the sandbox’s PID namespace by comparing the target task’s namespace. Each of these requires the object, and the object is only available at the hook.
It also means the policy is evaluated in the kernel’s own code path with the kernel’s own locks held, and a bug in the program is a bug in the kernel’s security path. The verifier prevents memory unsafety, but it does not prevent a program from returning the wrong verdict because it misread a field, and it does not prevent a program from being too slow. The complexity budget for a hook that runs on every file_open is very different from the budget for a hook that runs on bprm_check.
Composition, ordering, and the stacking question
LSM hooks are called in a fixed order determined by the registration order of the security modules, and eBPF programs attached to a hook run within the BPF LSM’s slot. A program returning a denial stops the operation at that point; whether the remaining modules’ hooks run depends on whether the denial is returned as an errno or as a “would deny” for audit-only purposes.
Multiple BPF programs can attach to the same hook. They run in attachment order, and each receives the accumulated result of the ones before it in its trailing ret argument. A program that follows the convention of returning immediately on a non-zero ret therefore declines to add its own verdict once another program has refused, which produces the same intersection semantics as stacked seccomp filters. The ordering is observable: a program that wants to log every attempt must attach before the program that denies, or ignore the incoming ret, because otherwise it can be made to return early on exactly the calls worth recording.
Attachment happens through the bpf syscall, and libbpf wraps it as bpf_program__attach_lsm, which returns a link object. Destroying that link detaches the program, so unlike a seccomp filter, an LSM program can be removed at runtime. That reversibility is convenient for policy updates and is a genuine difference in guarantee: an LSM program is a policy that can be changed by anything holding the link, so it is not a monotonic confinement primitive in the way a seccomp filter is.
When the mechanism is the wrong choice
The cost of an eBPF LSM policy is a program to write, a verifier to satisfy, a BTF dependency on the target kernel, and a hook set that varies by version. For a policy that only needs to refuse a handful of syscall numbers, a seccomp filter is smaller, faster to evaluate, and does not depend on kernel introspection. For a policy about paths, Landlock enforces on resolved objects with no program at all, as described in Landlock Rulesets: Path Hierarchies and Access Masks, and the ruleset is a data structure rather than code.
The mechanism earns its complexity when the decision genuinely needs an object the cheaper mechanisms cannot see, or needs state across calls, such as a map counting opens per inode, a set of permitted executables built at sandbox startup, or a comparison of namespaces between two tasks. Those are the cases where the hook’s context is the reason to be there, and where the verifier’s constraints are the price of getting kernel objects into a policy that lives outside the kernel tree.
References
- LSM BPF Programs — Linux kernel documentation — Linux kernel project
- Linux kernel documentation — eBPF verifier — Linux kernel project
- Linux kernel source: security/security.c — Linux kernel project