seccomp Filter Modes and Return Actions
seccomp has exactly two operating modes and a fixed, precedence-ordered set of return actions, and getting both right is the whole of writing a correct filter. The two modes are
SECCOMP_SET_MODE_STRICT— a hardcoded, no-configuration mode that permits onlyread,write,_exit, andsigreturn— andSECCOMP_SET_MODE_FILTER— the flexible mode where you install a classic-BPF program that votes per syscall. In filter mode the program returns one of eight return actions, which the kernel ranks by a strict precedence:SECCOMP_RET_KILL_PROCESS>KILL_THREAD>TRAP>ERRNO>USER_NOTIF>TRACE>LOG>ALLOW. When filters are stacked, every filter runs and the most restrictive action wins — and the clever encoding that makes this a single integer comparison is the most subtle and most worth understanding mechanism in seccomp (seccomp.h, v6.12). This note is the reference for the modes, the action constants and their exact bit values, theACTION/DATAsplit, and the installation flags. The threat-model/why of seccomp is in Seccomp and seccomp-BPF; the entry-path mechanics are in seccomp and Syscall Filtering.
The Two Modes
seccomp predates seccomp-BPF. The original 2005-era mode — now called strict mode — was a blunt instrument: once a thread entered it, the kernel permitted it to make only four system calls, and any other syscall killed it. The modern, configurable mode — filter mode — was added in Linux 3.5 (2012) and is what almost everyone means by “seccomp” today. Both are selected through the same operation namespace, either via the legacy prctl(PR_SET_SECCOMP, mode, ...) or the richer seccomp(2) syscall.
The mode constants, from seccomp.h at v6.12 (source), come in two namespaces that are easy to confuse. The internal mode values (what the kernel records as the thread’s current mode) are SECCOMP_MODE_DISABLED = 0, SECCOMP_MODE_STRICT = 1, SECCOMP_MODE_FILTER = 2. The operation values passed to the seccomp(2) syscall are a different set: SECCOMP_SET_MODE_STRICT = 0, SECCOMP_SET_MODE_FILTER = 1, SECCOMP_GET_ACTION_AVAIL = 2, SECCOMP_GET_NOTIF_SIZES = 3. The two STRICT/FILTER numbers are not the same between the two namespaces (mode STRICT is 1 but operation SET_MODE_STRICT is 0), which is a recurring source of confusion when reading the kernel source — keep “the mode the task is in” separate from “the operation you asked for.”
SECCOMP_SET_MODE_STRICT
Strict mode takes no argument and admits no configuration. After prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT) (or seccomp(SECCOMP_SET_MODE_STRICT, 0, NULL)), the calling thread may issue only read(2), write(2), _exit(2) (note: _exit, not exit_group(2)), and sigreturn(2); the man page states it exactly: “The only system calls that the calling thread is permitted to make are read(2), write(2), _exit(2) (but not exit_group(2)), and sigreturn(2)” (seccomp(2)). In the kernel this is a literal hardcoded array — static const int mode1_syscalls[] = { __NR_seccomp_read, __NR_seccomp_write, __NR_seccomp_exit, __NR_seccomp_sigreturn, -1 }; (seccomp.c, v6.12) — and any syscall not in it kills the task with SIGKILL. The crucial usability detail is that read/write are permitted only on already-open file descriptors: there is no open, no socket, no mmap, so the thread cannot acquire new descriptors or memory. Strict mode is therefore usable only for a narrow pattern: a process sets up its fds and memory, reads untrusted input, computes over it (pure CPU-and-already-mapped-memory work — a bytecode interpreter, a number cruncher), writes results back, and exits. It is rarely used in practice precisely because real programs need at least mmap/brk/futex/rt_sigreturn; everything beyond the toy case needs filter mode.
SECCOMP_SET_MODE_FILTER
Filter mode is the general mechanism. You build a classic-BPF program — an array of struct sock_filter instructions wrapped in a struct sock_fprog — and install it. The kernel runs that program on every syscall, passing it the read-only struct seccomp_data record (syscall number, arch, instruction pointer, six argument registers — layout detailed in seccomp and Syscall Filtering), and the program returns a 32-bit value whose high bits encode the action and whose low bits carry data. Unlike strict mode, filter mode is fully programmable, stackable, and inherited — and it is the subject of the rest of this note.
flowchart TB CALL["seccomp(operation, flags, args)"] CALL --> OP{operation} OP -->|"SET_MODE_STRICT (0)"| STRICT["mode = STRICT (1)<br/>only read/write/_exit/sigreturn<br/>else SIGKILL"] OP -->|"SET_MODE_FILTER (1)"| FILTER["mode = FILTER (2)<br/>install cBPF prog<br/>run on every syscall"] OP -->|"GET_ACTION_AVAIL (2)"| AVAIL["test if kernel<br/>supports a RET action"] OP -->|"GET_NOTIF_SIZES (3)"| SIZES["sizes of user-notif<br/>structs for USER_NOTIF"]
The seccomp operation namespace. What it shows: the same seccomp(2) entry point selects between the two install modes and two introspection operations; strict mode is a fixed policy with no argument, filter mode installs a cBPF program. The insight to take: GET_ACTION_AVAIL exists because return actions were added over many kernel versions — a portable program tests whether the kernel supports, say, USER_NOTIF before relying on it, rather than assuming.
The Return Actions, in Precedence Order
A filter returns a u32. The kernel splits it into an action field and a data field, and the action determines the syscall’s fate. The eight actions, in strict precedence order (most restrictive first — the order the kernel uses when stacked filters disagree), with their exact constants from seccomp.h at v6.12:
SECCOMP_RET_KILL_PROCESS=0x80000000U— terminates the entire thread group (the whole process) immediately, without executing the syscall. The exit status reportsSIGSYS(specificallystatus & 0x7f == SIGSYS), notSIGKILL, even though the process is force-killed; the documentation notes “The exit status of the task (status & 0x7f) will beSIGSYS, notSIGKILL” (seccomp_filter.rst). Added in Linux 4.14. This is the action you want for a hard sandbox: a single forbidden syscall takes down the whole process, leaving no surviving thread.SECCOMP_RET_KILL_THREAD=0x00000000U(also spelledSECCOMP_RET_KILL, the older name) — terminates only the offending thread, again reportingSIGSYS. The danger ofKILL_THREADoverKILL_PROCESSis that killing one thread of a multithreaded program can leave it in a corrupt, half-dead state (holding locks, mid-transaction) rather than cleanly dead;KILL_PROCESSwas added precisely to fix this footgun.SECCOMP_RET_TRAP=0x00030000U— does not execute the syscall and instead sends a thread-directedSIGSYSsignal synchronously to the calling thread. A signal handler can catch it; thesiginfo_tcarries the syscall number and address, and the low 16 bits of the return value (SECCOMP_RET_DATA) are delivered assi_errno. This lets a program emulate or log-and-recover from a blocked syscall in-process rather than dying.SECCOMP_RET_ERRNO=0x00050000U— does not execute the syscall; instead the kernel makes the syscall return an error, using the low 16 bits of the filter’s return value as theerrno. The documentation: “the lower 16-bits of the return value being passed to userland as theerrno.” This is the gentlest deny — the program sees a normal-EPERM/-ENOSYSand can handle it, exactly whatdefaultErrnoRetin a Docker profile produces.SECCOMP_RET_USER_NOTIF=0x7fc00000U— does not execute the syscall (yet); instead it generates astruct seccomp_notifmessage on a userspace notification file descriptor (obtained via theNEW_LISTENERflag, below) so a supervisor process decides what to do. If no listener is attached, the syscall fails with-ENOSYS. This is the deep-inspection / syscall-emulation escape hatch — its full mechanics are in seccomp User Notification. Added in Linux 5.0.SECCOMP_RET_TRACE=0x7ff00000U— notifies aptrace(2)-based tracer (one attached withPTRACE_O_TRACESECCOMP) before the syscall runs, letting the tracer inspect, skip, or alter it. If no tracer is attached, the syscall returns-ENOSYSand is not executed. This predatesUSER_NOTIFand is the older way to hand control to a supervisor.SECCOMP_RET_LOG=0x7ffc0000U— allows the syscall to execute, but logs it first (subject to the action being enabled inactions_logged). This is the development-mode action: run the program, let everything through, but record which syscalls were used so you can build a tight allowlist. Added in Linux 4.14.SECCOMP_RET_ALLOW=0x7fff0000U— executes the syscall normally. This is the permissive baseline and the default starting value the kernel assumes before any filter runs.
Uncertain
Verify: the exact “added in” kernel versions cited above (
KILL_PROCESSandLOGin 4.14,USER_NOTIFin 5.0). Reason: these come from theseccomp(2)man page’s per-action version annotations rather than a v6.12 changelog, and man-page version tags occasionally lag or round. To resolve: cross-check against the kernel’s git history (git log -S SECCOMP_RET_KILL_PROCESS) for the introducing commit’s first release tag. The constant values themselves are verified against v6.12seccomp.h.
The ACTION / DATA Split and Why Precedence Is One Integer Compare
The return value is a single u32 carrying two things at once, separated by three masks defined in seccomp.h (v6.12):
SECCOMP_RET_ACTION_FULL=0xffff0000U— the full 16 high bits, used to identify the action (including the top bit, which onlyKILL_PROCESSsets).SECCOMP_RET_ACTION=0x7fff0000U— the action bits excluding the top bit (bits 16–30).SECCOMP_RET_DATA=0x0000ffffU— the low 16 bits, free for the filter to use as ancillary data (theerrnoforRET_ERRNO, thesi_errnoforRET_TRAP).
The split lets one filter return both “what to do” (action) and “with what value” (data) in a single 32-bit result. But the genuinely clever part is how the kernel ranks actions when filters are stacked. Look at the action constants again, sorted: KILL_PROCESS is 0x80000000, KILL_THREAD is 0x00000000, then TRAP 0x00030000, ERRNO 0x00050000, USER_NOTIF 0x7fc00000, TRACE 0x7ff00000, LOG 0x7ffc0000, ALLOW 0x7fff0000. Notice that as an action gets more permissive, its numeric value gets larger — ALLOW (0x7fff0000) is the largest of the “normal” values. So “most restrictive wins” should be “smallest value wins”… except KILL_PROCESS is 0x80000000, which as an unsigned number is the largest of all, not the smallest. How is the most-restrictive-wins ordering still a single integer compare?
The trick is the signed cast. The kernel’s comparison macro is:
#define ACTION_ONLY(ret) ((s32)((ret) & (SECCOMP_RET_ACTION_FULL)))It masks with SECCOMP_RET_ACTION_FULL (the full 0xffff0000, including the top bit) and then casts to signed s32. Under that signed interpretation, KILL_PROCESS = 0x80000000 becomes the most-negative 32-bit integer (INT_MIN), which is smaller than every other action’s value. Now the ordering is consistent: the most restrictive action (KILL_PROCESS) is the smallest signed value, KILL_THREAD (0x0) is next, and the permissive actions are large positive numbers, with ALLOW the largest. The whole precedence table collapses to “pick the minimum signed ACTION_ONLY value across all filters.” The stacking loop in seccomp_run_filters() does exactly that (seccomp.c, v6.12):
u32 ret = SECCOMP_RET_ALLOW; /* start permissive */
...
for (; f; f = f->prev) { /* walk every stacked filter */
u32 cur_ret = bpf_prog_run_pin_on_cpu(f->prog, sd);
if (ACTION_ONLY(cur_ret) < ACTION_ONLY(ret)) {
ret = cur_ret; /* keep the more-restrictive one */
*match = f;
}
}Line by line: ret starts at SECCOMP_RET_ALLOW, the most permissive (largest signed) value. The loop runs every filter in the stack (it never short-circuits — all filters always execute, which matters because a filter may have side effects via its data field). For each filter’s result cur_ret, if its signed ACTION_ONLY value is smaller (more restrictive) than the running minimum, it replaces it. After the loop, ret holds the single most-restrictive action any filter voted for. This is why the documentation says: “the return value for the evaluation of a given system call will always use the highest precedent value. (For example, SECCOMP_RET_KILL_PROCESS will always take precedence.)”
Two consequences fall out of this design. First, stacked filters can only ever narrow — adding a filter can lower the minimum but never raise it, so a child’s filter can never re-permit what a parent denied. This is the foundation of seccomp’s one-way, irreversible confinement. Second, when two filters return the same action but different data, the documentation specifies that “only the SECCOMP_RET_DATA from the most recently installed filter will be returned” — the precedence is purely on the action bits, and ties on action are broken by recency for the data bits.
flowchart LR F1["Filter 1 (parent):<br/>open → ERRNO (0x00050000)"] --> CMP F2["Filter 2 (child):<br/>open → ALLOW (0x7fff0000)"] --> CMP CMP{"min signed<br/>ACTION_ONLY"} CMP --> WIN["ERRNO wins<br/>(0x00050000 < 0x7fff0000)<br/>child cannot re-permit"]
Stacked-filter precedence on a single syscall. What it shows: with a parent that denies open via ERRNO and a child that allows it, the kernel compares the signed ACTION_ONLY values and the smaller (more restrictive) ERRNO wins. The insight to take: because the comparison takes the minimum, a later filter can only make the policy stricter — confinement is monotonic and irreversible, which is exactly the property that makes seccomp trustworthy.
The Filter Installation Flags
When installing a filter through the seccomp(2) syscall (not the older prctl path, which takes no flags), the flags argument is a bitmask of SECCOMP_FILTER_FLAG_* values, defined in seccomp.h at v6.12:
SECCOMP_FILTER_FLAG_TSYNC=1 << 0— thread-sync. Normally a filter installs only on the calling thread; withTSYNCthe kernel synchronizes the filter onto all threads in the process, so the whole process shares one filter tree. This solves a real attack: without it, a multithreaded program could leave one thread un-filtered. If synchronization fails (because another thread already has a divergent filter, or is in strict mode), theseccomp(2)call returns the thread ID of the offending thread instead of0/-1, so the caller knows which thread blocked the sync.SECCOMP_FILTER_FLAG_LOG=1 << 1— log all filter return actions exceptSECCOMP_RET_ALLOW(subject to the kernel’sactions_loggedsysctl), regardless of whether the individual action isRET_LOG. Used to observe what a filter is denying without changing its behavior.SECCOMP_FILTER_FLAG_SPEC_ALLOW=1 << 2— opt out of the automatic Speculative Store Bypass (SSB) mitigation that the kernel otherwise applies to seccomp-confined tasks. By default, installing a seccomp filter also enables the SSBD CPU mitigation (a Spectre-v4 defense) because seccomp tasks are assumed to run untrusted code;SPEC_ALLOWsays “I don’t need that mitigation,” trading a side-channel defense for performance. Added in Linux 4.17.SECCOMP_FILTER_FLAG_NEW_LISTENER=1 << 3— after installing the filter, return a userspace notification file descriptor instead of0, for use withSECCOMP_RET_USER_NOTIF. Only one listener may exist per filter; the fd is how a supervisor receives notifications. See seccomp User Notification.SECCOMP_FILTER_FLAG_TSYNC_ESRCH=1 << 4— modifiesTSYNC’s error reporting. The originalTSYNCreturns a positive thread ID on sync failure, which collides ambiguously with a valid fd returned byNEW_LISTENERwhen both flags are used together.TSYNC_ESRCHmakes a sync failure return a clean-ESRCHerror instead, removing the ambiguity soTSYNCandNEW_LISTENERcan be combined safely. Added in Linux 5.7.SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV=1 << 5— forUSER_NOTIF: when a notified syscall is blocked waiting for the supervisor, this flag makes the blocked task ignore non-fatal signals until the supervisor has received the notification, so a signal cannot prematurely interrupt the request before the supervisor even sees it. Improves robustness of the notification protocol against signal races. Added in Linux 5.19.
Uncertain
Verify: the “added in” kernel versions for the flags (
SPEC_ALLOW4.17,TSYNC_ESRCH5.7,WAIT_KILLABLE_RECV5.19) and the precise SSBD-mitigation semantics ofSPEC_ALLOW. Reason: version tags are from the man page and secondary sources, not a v6.12 changelog; the SSBD coupling is described in prose but not re-derived from the scheduler/mitigation code here. To resolve: check each flag’s introducing commit in git history and read thearch_seccomp_spec_mitigate()path. The flag bit values are verified against v6.12seccomp.h.
A Worked Filter (cBPF)
A minimal hand-written cBPF filter shows how the modes, actions, and data fit together. This one allows read/write/exit_group and refuses everything else with EPERM:
struct sock_filter filter[] = {
/* Load seccomp_data.nr (the syscall number) into the accumulator */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
/* If nr == __NR_read, jump to ALLOW; else fall through */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read, 3, 0),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 2, 0),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit_group, 1, 0),
/* Default: deny with errno EPERM (low 16 bits = 1) */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)),
/* ALLOW target */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = { .len = ARRAY_SIZE(filter), .filter = filter };
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); /* precondition */
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, &prog);Walking the load-bearing lines: the first instruction loads seccomp_data.nr — note it reads the number, a scalar, never a pointer (the pointer limitation is in Seccomp and seccomp-BPF). The three BPF_JEQ comparisons check the number against allowed syscalls, jumping forward to the SECCOMP_RET_ALLOW return on a match. The default return combines the action SECCOMP_RET_ERRNO with data EPERM masked to the low 16 bits via SECCOMP_RET_DATA — this is the ACTION/DATA split in action, packing “deny” and “with EPERM” into one value. The PR_SET_NO_NEW_PRIVS call is the mandatory precondition for an unprivileged install, and SECCOMP_FILTER_FLAG_TSYNC applies the filter to all threads. In practice almost nobody writes this by hand; libseccomp generates equivalent (and optimized, binary-search-over-syscall-numbers) cBPF — see Writing a seccomp Filter.
Failure Modes and Gotchas
KILL_THREAD corrupting a multithreaded process. Choosing KILL_THREAD (the historical default of prctl-installed filters and libseccomp’s SCMP_ACT_KILL on old versions) for a multithreaded program means one bad syscall kills a single thread and leaves the rest running over a corrupted, lock-holding carcass. Prefer KILL_PROCESS for hard sandboxes; it was added specifically to avoid this.
Forgetting GET_ACTION_AVAIL. A filter that returns USER_NOTIF or LOG on a kernel too old to support it behaves unexpectedly — the kernel treats an unknown action as the most-restrictive available kill (KILL_PROCESS on ≥4.14, else KILL_THREAD). Portable code probes with seccomp(SECCOMP_GET_ACTION_AVAIL, 0, &action) first.
Confusing the two STRICT/FILTER numberings. As noted, SECCOMP_MODE_STRICT = 1 but SECCOMP_SET_MODE_STRICT = 0. Passing the wrong namespace’s constant to the wrong API silently does the wrong thing.
RET_TRAP without a handler is fatal. SECCOMP_RET_TRAP delivers SIGSYS; if the program has not installed a SIGSYS handler, the default disposition terminates the process. RET_TRAP is only useful when you intend to catch it.
See Also
- Seccomp and seccomp-BPF — sibling; the threat model, allowlist-vs-denylist design, the pointer-dereference limitation, real profiles
- seccomp and Syscall Filtering — sibling; where in the syscall-entry path the filter runs and the
struct seccomp_datalayout - seccomp User Notification — the
SECCOMP_RET_USER_NOTIFaction andNEW_LISTENER/WAIT_KILLABLE_RECVflags in full - Writing a seccomp Filter — practical authoring with libseccomp and raw cBPF
- no_new_privs and Privilege Escalation Control — the precondition for installing a filter unprivileged
- Linux Security MOC — parent; seccomp is §E