Operating Systems · System Calls

Seccomp Filters and the Cost of Interception

How a classic BPF program attached to a thread filters system calls, what it can and cannot inspect, and why tracing every call changes latency by orders of magnitude.

A sandbox that lets a process run arbitrary code has to answer one question before any other: which system calls may that code make. The answer cannot be enforced inside the process, because the process is the thing being distrusted. It has to be enforced at the boundary, by the kernel, on every call, with a decision made before the call takes effect. Seccomp is the mechanism that does this, and its design is shaped by a hard constraint: the check runs on the hot path of every system call, so it must be cheap enough that a program making millions of calls does not notice.

The obvious alternative, and the one that existed first, is to have a separate process watch. That approach is examined below, and the comparison is instructive because the two mechanisms differ by orders of magnitude in cost while providing similar filtering power.

A filter is a program over the registers

Seccomp filters are written in classic BPF, the packet-filter instruction set, not the extended BPF used for tracing and networking. The choice follows from the budget. Classic BPF is small, has no loops, no function calls, no memory access beyond a fixed scratch area, and a verifier that can prove termination by construction. A filter is therefore guaranteed to run in bounded time, which is what makes it acceptable to execute on every system call.

The program’s input is a structure, not a packet: the syscall number, the architecture identifier, the instruction pointer, and the six argument values. The filter reads them with load instructions, compares them with jump instructions, and ends with a return instruction that selects an action. The argument values are raw register contents, which has a consequence explored below: a pointer argument is a number, and the filter cannot see what it points at.

The actions available are a small enumeration. Allow the call, log it and allow it, reject it with an error, hand it to an attached tracer, notify a supervising process, trap to a debugger, kill the calling thread, or kill the whole process. The distinction between killing the thread and killing the whole process matters for sandboxes that use threads: killing one thread leaves the process in a state where the remaining threads continue without it, which is rarely what a policy intends.

/* A filter that permits read, write, exit and exit_group and rejects
   everything else. Each test falls through on a mismatch and jumps
   over the remaining tests on a match, landing on the allow. */
struct sock_filter filter[] = {
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read,       4, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write,      3, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit,       2, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit_group, 1, 0),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};

The jump offsets count instructions to skip, so each test’s true branch has to clear however many tests remain plus the rejection. Getting that arithmetic wrong is the most common way to write a filter that permits everything: an off-by-one lands on the allow instead of the reject, and the filter appears to work until a program calls something it should not.

Filters stack, and the stack is ordered newest first

A thread can install more than one filter. Each new filter is prepended to a list, and every call evaluates the whole list from newest to oldest. The ordering is a security property, not an implementation detail: a library that installs a filter cannot weaken a filter installed by the program that loaded it, because the program’s filter is evaluated in the same pass and its verdict still counts.

The evaluation rule is that the action of highest precedence across every filter in the list is the one applied, and the precedence order runs from killing the process, through killing the thread, trapping, rejecting with an error, notifying a supervisor, handing the call to a tracer and logging, down to allowing the call. The kernel does not stop at the first rejection; it runs every filter and combines the results, so an allow returned by a recently installed filter cannot override a kill returned by an older one. This means the cost of filtering grows with the number of installed filters, and a program that installs a filter per library, a common pattern in container runtimes that build up a policy in layers, pays for every layer on every call.

Filters are inherited across fork and exec, which is what makes the mechanism usable as a sandbox primitive: a process installs a filter, then executes untrusted code, and the code cannot remove the filter because removal is impossible by design. There is no interface to detach a filter. That asymmetry is the whole point; a filter that could be removed would not be a sandbox.

The installation flags decide how far the filter reaches. One flag synchronises the new filter onto every thread of the process, which is what a program that installs its policy after starting threads needs. Without it the filter binds only to the calling thread, and a thread that already existed before installation keeps running unfiltered. A thread created afterwards inherits the filter, so the window is exactly the set of threads alive at installation time. A program that installs a policy and then creates worker threads is safe; one that does the reverse is not, and the resulting gap is silent.

What the filter cannot see

The argument values available to the filter are the raw register contents, which means the filter sees a pointer as a number. It cannot dereference it. A policy of the form “open may be called, but only on paths under /data” cannot be expressed in a seccomp filter, because the path is in user memory and the filter has no way to read it.

This is a fundamental limit rather than a missing feature. Allowing a filter to read user memory would mean running filter code that can fault, which breaks the bounded-time guarantee, and would mean trusting user-controlled data during the decision. The workarounds all move the decision elsewhere: a supervising process that receives a notification and inspects the argument itself, a filesystem layer that enforces the policy, or a capability model where the process simply lacks the authority to open anything else.

What the filter can do is compare argument values against constants, which covers a surprising amount: a flag word, a size limit, a file descriptor number, an operation code. A filter that permits ioctl only for specific request numbers, or mmap only with specific flags, is expressible. A filter that permits read only from specific descriptors works if the descriptors are known in advance, which they are in a supervised process that controls its own file table.

The architecture identifier is part of the input for a specific reason. A 32-bit binary running on a 64-bit kernel can reach more than one entry point, and a filter that checks only the 64-bit numbers leaves the compatibility path open. The kernel documentation notes that the x86-64 and x32 personalities share the same architecture identifier and are distinguished only by a bit in the syscall number, so the architecture check alone does not close the hole; the number has to be tested as well. The numbering difference between the two ABIs is described in 32-bit Compatibility System Calls on a 64-bit Kernel.

The cost of a filter versus the cost of a tracer

The older mechanism for this problem is ptrace. A tracer attaches to a tracee, and every system call in the tracee stops the tracee and wakes the tracer. The tracer reads the registers, decides, and resumes the tracee. Two context switches and two scheduling events per system call, plus the work of the tracer itself.

The cost difference is structural, not a matter of tuning. A seccomp filter runs inside the kernel, in the same context, before the call is dispatched, with no scheduling event at all. Its cost is the cost of the BPF program: a short sequence of instructions for a policy that compares numbers, longer for a layered one, and in both cases far below the price of leaving the kernel. A tracer pays two context switches, each of which is a scheduling event, and it pays them whether or not the decision is interesting.

That difference in kind, a short in-kernel program against two context switches and a scheduling round trip, is why tracing scales badly with call volume while filtering does not. It also explains why the notification action exists: a filter can make the cheap decision for the common case and delegate only the interesting calls to a supervisor, paying the expensive path rarely.

The notification path is not free either. A call that returns a notification action blocks the calling thread until a supervisor responds, so a policy that notifies on a frequently used call converts a filtered call into a round trip. Placement of the notification is therefore a performance decision as much as a security one, and the honest way to describe it is that the filter’s cost is the sum of the common-case check and, for the calls it defers, a context switch.

Where filters go wrong

The failure modes are worth listing because they are the ways sandboxes leak.

A filter that compares only the syscall number and not the architecture can be bypassed on a multi-ABI kernel. A filter that permits an operation because the argument looked acceptable can be bypassed if the argument is a pointer, since the filter never sees the object. A filter that rejects a call by returning an error can break a program in ways that are hard to diagnose, because the error appears at the call site with no indication that a policy produced it; a supervisor that logs the notification is the usual way to make this visible.

The most consequential failure is a policy that is correct but incomplete: a call the author did not know about, which provides the same capability as a call that was blocked. System call interfaces are large, and several operations have more than one entry point. An allowlist built by enumerating the calls a program makes under a test workload is incomplete by construction, and the gap is where a sandbox escapes. This is why container runtimes generally start from a default-deny policy rather than from an observed set.

There is also a cost failure mode. Filters are evaluated on every call, including calls that a program makes in a tight loop. A layered policy from several libraries, each adding a filter, multiplies the per-call cost by the number of layers. The effect is invisible in a functional test and obvious in a profile, which is why the number of installed filters is worth knowing when a sandboxed program performs worse than expected.

The position in the design space

Seccomp occupies a specific point: enforcement inside the kernel, decision over registers only, cost proportional to policy size, no ability to inspect arguments that live in memory. It is cheap enough to apply universally and weak enough that it cannot be the only layer. A sandbox that needs path-based or capability-based policy needs a different mechanism, and the usual arrangement is seccomp for the coarse filter plus namespaces and a capability model for the rest.

The measurement that matters is the one described in What a System Call Actually Costs: the boundary is a few hundred cycles, and a filter adds to that a number that depends on the policy’s shape. A policy that checks one number costs a handful of instructions. A policy that checks six arguments against ranges costs more, but the program is still short compared with the entry path it runs alongside. What is not comparable is a decision that requires leaving the kernel, which is why the mechanisms that avoid it, such as in-kernel filters, shared rings, and the vDSO’s untrapped calls, are the ones that show up in performance-sensitive designs.

References

  1. seccomp(2) — Linux manual page — Linux man-pages project
  2. The Linux kernel documentation — Seccomp BPF — Linux kernel documentation
  3. ptrace(2) — Linux manual page — Linux man-pages project
  4. The `seccomp` filter language: BPF instruction set — Linux kernel source tree, include/uapi/linux/bpf_common.h