Operating Systems · Isolation and Namespaces
seccomp-BPF and the Filtering of System Calls
How a BPF program attached to a process inspects syscall numbers and arguments before dispatch, why argument filtering cannot dereference pointers, and what the return actions cost.
Every other isolation mechanism decides what a process can reach. seccomp decides what it can ask for. A filter is a small BPF program, installed once, executed by the kernel on every syscall entry, and returning one of a handful of actions that range from “allow” to “kill the process”. Because the program runs before the syscall’s implementation is selected, it constrains the kernel’s attack surface rather than the process’s view of the machine, and that distinction is why a container with a complete set of namespaces still needs a syscall filter.
The hook and the data it sees
seccomp(SECCOMP_SET_MODE_FILTER, ...) attaches a filter to the calling process. From then on, every syscall entry — including ones issued by the process’s threads, and including ones issued after execve — runs the filter chain. Filters accumulate: a process that installs a second filter runs both, and the most restrictive result wins, so a filter cannot widen what an earlier one allowed.
The program receives a struct seccomp_data containing the syscall number in nr, the architecture in arch, the instruction pointer at the time of the call, and the six arguments in args[6]. Each argument is a 64-bit value. The BPF program has no access to memory: it cannot dereference a pointer argument, cannot read a pathname, and cannot inspect a structure passed by address. A filter can therefore distinguish openat(AT_FDCWD, 0x7fff..., O_RDONLY) from openat(AT_FDCWD, 0x7fff..., O_WRONLY) by the flags argument, but not by the path.
That limitation is structural rather than incidental. The filter runs at syscall entry, before the kernel has validated the pointer or copied anything from user space. Dereferencing at that point would mean the filter operates on memory that may fault, which cannot be done in the atomic context of the entry path, and would create a time-of-check-to-time-of-use gap between the filter’s read and the syscall’s read. The design chose a filter over registers only, and the consequence is that path-based policy must be enforced elsewhere — by the mount namespace and by file permissions.
struct sock_filter allow_reads[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
};
The offsets are fixed by the ABI of struct seccomp_data, and the instruction set is the classic BPF one — the same language used for socket filters, with the same 32-bit accumulator and the same jump range. Extended BPF is not accepted here; the kernel’s eBPF verifier is a different mechanism with different semantics, and the seccomp hook deliberately uses the simpler, older instruction set because its verifier is small enough to be obviously correct.
The return actions and their costs
SECCOMP_RET_KILL_PROCESS terminates the entire process, all threads included, with SIGSYS; SECCOMP_RET_KILL_THREAD kills only the calling thread. The older SECCOMP_RET_KILL name aliases the thread variant for compatibility, which is a source of surprise when a multithreaded program installs a filter expecting the process to die and finds one thread gone.
SECCOMP_RET_TRAP delivers SIGSYS to the thread with si_syscall and si_arch set in the signal info, allowing a handler to emulate or log the call. SECCOMP_RET_ERRNO returns a chosen errno value without executing the syscall, which is the cheapest way to make a call appear to fail. SECCOMP_RET_TRACE hands control to a ptrace tracer, which can inspect registers and either allow the call, change its number, or fail it — the mechanism used by sandboxes that need argument inspection. SECCOMP_RET_USER_NOTIF sends the call to a user-space supervisor through an ioctl interface, adding a context switch per intercepted call. SECCOMP_RET_LOG allows the call and records it. SECCOMP_RET_ALLOW allows it.
The costs differ by orders of magnitude. ALLOW and ERRNO are a few dozen instructions in the entry path. TRAP adds signal delivery and a handler invocation. TRACE requires a tracer to be scheduled and to answer before the call proceeds, so the per-call cost is a pair of context switches. USER_NOTIF similarly requires a round trip to a supervisor process, and the supervisor must respond or the call blocks. A filter that intercepts a hot syscall with TRACE converts a fast path into a scheduling event, which is why production filters use ERRNO for denials and reserve TRACE for calls that need argument rewriting.
The instruction limit and the chain
A filter program is capped at 4096 BPF instructions. The cap bounds the worst-case time the entry path can spend in one filter. The chain as a whole is bounded separately: the total instruction count across every filter a process has installed is capped at roughly 32768, the constant the kernel names MAX_INSNS_PER_PATH, so a process can install many small filters but cannot make the traversal cost grow without limit.
The verifier rejects programs with backward jumps, so a filter cannot loop. It also rejects programs that read outside the seccomp_data structure, and it requires that every path end in a RET instruction. These restrictions make the worst-case execution time a linear function of the program length, which is the property the 4096 cap is there to bound.
Filters are inherited across fork and clone but not shared: the child gets a copy of the chain, and installing a filter in the child does not affect the parent. execve preserves the chain, which is what allows a runtime to install a filter and then exec the workload. The combination with PR_SET_NO_NEW_PRIVS is required for an unprivileged process to install a filter at all: without it, the call fails with EACCES unless the caller has CAP_SYS_ADMIN. The requirement exists because a filter installed by an unprivileged process would otherwise be a way to constrain a setuid binary’s behaviour, and the kernel’s position is that a process may only restrict itself if it has first given up the ability to gain privilege.
What a filter cannot express
Three classes of policy are outside the mechanism, and each has a consequence.
Argument filtering is shallow. A filter can require that mmap be called with PROT_EXEC absent, but it cannot check that the address range being mapped is not already mapped with different permissions, and it cannot inspect the struct passed to ioctl. The practical result is that filters express policy over the syscall number and over small integer flags, and any policy that depends on the contents of a structure must be enforced by the syscall implementation or by a tracer.
There is no state. The BPF program sees one syscall at a time and cannot remember that a previous call happened, so a policy such as “allow open only before execve” cannot be expressed. Stateful policy requires USER_NOTIF with a supervisor that holds the state, at the cost of a context switch per call.
The architecture field is part of the attack surface. On a 64-bit kernel, a 32-bit process can issue the 32-bit variant of a syscall, and a filter that checks nr against the 64-bit table without checking arch will allow the 32-bit equivalent. The kernel sets arch to AUDIT_ARCH_X86_64 or AUDIT_ARCH_I386 accordingly, and a filter that omits the check is bypassable on any system that can execute 32-bit binaries. This is the single most common defect in hand-written filters, and it is why generated filters begin with an architecture comparison that returns KILL on a mismatch.
How it composes with the other layers
seccomp does not replace the namespace and capability mechanisms; it reduces the set of operations they must reason about. A container that has dropped CAP_SYS_ADMIN through Capabilities and the Decomposition of Root Privilege still benefits from a filter that refuses mount outright, because the two mechanisms fail in different ways: a capability check happens inside the syscall and can be reached by a path the runtime did not anticipate, while a filter runs before dispatch and cannot be bypassed by any syscall path. The layers overlap, and the overlap is deliberate — each is assumed to be defeatable in isolation.
A filter’s denial and a capability’s denial look different to the caller, and the difference matters for diagnosis. A missing capability produces EPERM from the syscall’s own permission check, after the syscall has begun. A seccomp ERRNO denial produces EPERM before the syscall’s implementation runs, with no side effects and no audit record unless logging is enabled. The two are indistinguishable from the return value alone, which is why SECCOMP_RET_LOG and the audit subsystem exist: without them, a blocked syscall and a denied one present identically.
The SECCOMP_RET_KILL_PROCESS action interacts badly with pid 1 semantics described in PID Namespaces and the Special Status of PID 1. A filter that kills the process on a prohibited call will kill the container’s init, which tears down the namespace and every process in it. Whether that is the desired outcome depends on the policy: killing the init is a decisive containment action, and it is also a denial-of-service primitive if the prohibited call can be triggered by untrusted input. Filters applied to the init typically use ERRNO for exactly this reason, and reserve KILL for filters applied to the workload after the init has forked.
The final composition point is the boundary between the filter and the kernel’s own attack surface. A filter that permits a syscall permits every code path reachable from it, including the ones with bugs. Reducing the permitted set to the calls a workload actually needs is the only lever seccomp offers over that surface, and it operates by subtraction — the filter cannot make a permitted call safe, only fewer calls permitted.
References
- seccomp(2) — Linux manual page — man7.org
- Seccomp BPF — Linux kernel documentation — Linux kernel documentation
- The BSD Packet Filter: A New Architecture for User-level Packet Capture — Steven McCanne and Van Jacobson, USENIX Winter 1993