Virtualization · Sandboxing
Action Precedence When Seccomp Filters Stack
How the kernel combines verdicts from multiple attached filters, why the winning action is chosen by a documented ranking rather than by numeric order, and how thread synchronization decides which filters exist at all.
Filters compose by intersection, never by override. A thread that has three filters attached runs all three on every system call, and the kernel keeps the most restrictive verdict any of them produced. There is no way for a later filter to widen what an earlier one permitted, and no way to remove one. The composition rule is short enough to state in a sentence, but the details of how a verdict is ranked, which filter’s payload survives, and when a filter becomes visible to sibling threads carry most of the operational consequences.
Every filter runs, and the strictest verdict wins
The kernel evaluates the filter chain from the most recently installed filter outward, but order does not affect the outcome: all filters are executed and the results combined. The combined result is the action of highest precedence present in the set, carrying that action’s own 16-bit data field. A filter that returns SECCOMP_RET_ALLOW therefore cannot rescue a call that another filter denies; it can only fail to add a restriction of its own.
The documented precedence order, from strongest to weakest, is:
| Rank | Action | Numeric value | Effect |
|---|---|---|---|
| 1 | SECCOMP_RET_KILL_PROCESS | 0x80000000 | all threads in the process die |
| 2 | SECCOMP_RET_KILL_THREAD | 0x00000000 | calling thread dies, process survives damaged |
| 3 | SECCOMP_RET_TRAP | 0x00030000 | SIGSYS delivered with si_syscall |
| 4 | SECCOMP_RET_ERRNO | 0x00050000 | syscall fails with the encoded errno |
| 5 | SECCOMP_RET_USER_NOTIF | 0x7fc00000 | supervisor decides |
| 6 | SECCOMP_RET_TRACE | 0x7ff00000 | tracer decides, or ENOSYS if none |
| 7 | SECCOMP_RET_LOG | 0x7ffc0000 | call permitted and audited |
| 8 | SECCOMP_RET_ALLOW | 0x7fff0000 | call permitted |
The numeric column is the reason this table has to be memorized rather than derived. KILL_PROCESS sets the high bit and is therefore numerically larger than ALLOW, so a reimplementation that sorts raw return values as unsigned integers gets the ranking exactly backwards for the one action that matters most. The kernel compares actions through an explicit precedence test, not through the value. Any tool that reduces the ranking to min() or max() on the 32-bit word is wrong in at least one direction, and the wrong direction is the permissive one.
The payload belongs to the winning action only
The low 16 bits of a return value are action-specific data, and only the winning filter’s data is used. Precedence is decided on the action bits alone, so when two filters return the same action, the data from the filter installed most recently is the one the caller sees; the kernel documentation states this explicitly, and it means that a tie is broken by installation order rather than by anything in the programs. Two filters returning SECCOMP_RET_ERRNO with different errnos do not merge in any other way: the selected one supplies its errno, and the other is discarded. This produces a genuinely confusing failure when the selected errno is ENOSYS: the caller sees a “syscall not implemented” result even though a different filter in the chain was trying to say “denied”, and the program’s fallback path may then attempt an older interface.
For SECCOMP_RET_TRAP the data field is exposed to the signal handler as si_errno in the siginfo_t delivered with SIGSYS, which is the only channel by which a filter can pass a small integer to user code. si_syscall carries the number, and si_arch the architecture token, so a handler can distinguish which call was refused without re-parsing the instruction-pointer field.
SECCOMP_RET_LOG has a subtlety that trips up readers of the table: it is a weaker action than ERRNO, so if one filter logs a call and another allows it, the call is permitted and logged. If one logs and another traps, the trap wins and nothing is logged. Logging is thus not a neutral observation layer; it is an action in the ranking, and its position between TRACE and ALLOW means it is overridden by every restrictive verdict above it.
A thread’s filters are inherited, not shared
Filters attach to threads, not processes. seccomp(SECCOMP_SET_MODE_FILTER, 0, &prog) installs a filter on the calling thread only. Any thread created afterwards with clone inherits the chain, because the child’s seccomp state is copied at creation. Threads that already exist keep their own chains untouched.
This produces a window in a multithreaded program where one thread is confined and its siblings are not, and the window is exactly as wide as the time between the call and the synchronization. SECCOMP_FILTER_FLAG_TSYNC closes it: when passed, the kernel installs the new filter on the calling thread and then forces every other thread in the thread group to the same chain, failing with EINVAL if any of them already carries a filter the new one would not be a superset of. The flag is the difference between “this thread is sandboxed” and “this process is sandboxed”, and a runtime that forks workers before installing its policy has to use it or accept the gap.
Threads cannot detach from a chain. SECCOMP_FILTER_FLAG_TSYNC synchronizes forward only; there is no operation that removes a filter from a thread that has one. Combined with the per-thread inheritance rule, the practical consequence is that a filter installed before execve survives the exec, and a filter installed after it does not reach code that ran earlier.
Stacking is how independent policies coexist
The composition rule makes it safe for two components that do not know about each other to each install a filter. A language runtime can restrict itself to the syscalls its JIT needs; an application embedding that runtime can add its own restrictions; neither needs to read the other’s program. The result is the intersection, which is what both intended, and neither can weaken the other.
The cost is diagnosability. When a call fails, the process sees one errno and no indication of which filter produced it. SECCOMP_RET_TRAP helps because a handler can at least see the syscall number, and SECCOMP_RET_LOG plus an audit trail shows which calls reached the logging filter, but neither identifies the specific filter in a chain. In practice the way to locate the responsible filter is to reproduce with the chain truncated, installing filters one at a time, which is tedious precisely because the chain cannot be inspected at runtime.
There is also a real instruction-count cost. Each filter’s program runs on each syscall, so a chain of three 300-instruction filters costs roughly three times one filter’s evaluation on every call, and the evaluation happens in the entry path before the syscall body executes. The budget of 4096 instructions applies per program, not to the chain, so a chain can be arbitrarily long in aggregate. Filters that are mostly redundant, such as a general denylist installed by a runtime alongside a narrow allowlist installed by the application, are paying for comparisons that the other filter already made.
Interaction with the tracer and supervisor actions
SECCOMP_RET_TRACE and SECCOMP_RET_USER_NOTIF do not fit the “stricter wins” model as cleanly as the kill and error actions. TRACE is only effective when a ptracer is attached with PTRACE_O_TRACESECCOMP; without one, the kernel converts the verdict to ENOSYS and continues. So a filter that returns TRACE in a process that is not being traced silently degrades into a denial with a misleading errno, and the ranking never consults the tracer at all because there is none. When a tracer is attached, it can skip the syscall or replace the syscall number entirely, and the seccomp check does not run again after the notification. The kernel documentation states the consequence for sandbox authors directly: a seccomp-based sandbox must not permit ptrace of processes, including other sandboxed processes, because a tracer that is itself under the filter can use this mechanism to do work the filter would otherwise have refused.
USER_NOTIF requires a listener installed through SECCOMP_IOCTL_NOTIF_RECV on a filter file descriptor, covered in detail in Supervising Syscalls with Seccomp User Notification. The supervisor can answer allow, deny with an errno, or return a fabricated success value. If no listener is attached, or the supervising process has gone, the verdict degrades to a failure rather than letting the call proceed. Because USER_NOTIF ranks below ERRNO and above TRACE, a supervisor cannot be used to override a hard denial from another filter; it only gets to decide calls that no stricter filter already refused.
Failure modes at the seams
The EINVAL from TSYNC when a sibling’s chain is not a subset of the new one is the most common integration failure, and its message does not name the offending thread. The remedy is to install all filters before creating any threads, or to install the union in a single program.
A filter whose default action is KILL_PROCESS combined with a supervisor returning USER_NOTIF produces a process that dies on the first call the supervisor does not handle, including calls made by the supervisor’s own error paths if they share the thread group. Keeping the supervisor outside the sandboxed thread group avoids the recursion.
Finally, SECCOMP_RET_KILL_THREAD deserves a warning beyond ranking: killing one thread of a process that holds a lock leaves the remaining threads blocked forever on that lock. The action exists for historical compatibility, and KILL_PROCESS is almost always what a policy author means. The distinction is not about strictness but about whether the surviving process is in a coherent state, and after a KILL_THREAD it usually is not.
References
- seccomp(2) — Linux manual page — man-pages project
- Seccomp filter — Linux kernel documentation — Linux kernel project
- Linux kernel source: kernel/seccomp.c — Linux kernel project