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 only read, write, _exit, and sigreturn — and SECCOMP_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, the ACTION/DATA split, 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:

  1. SECCOMP_RET_KILL_PROCESS = 0x80000000U — terminates the entire thread group (the whole process) immediately, without executing the syscall. The exit status reports SIGSYS (specifically status & 0x7f == SIGSYS), not SIGKILL, even though the process is force-killed; the documentation notes “The exit status of the task (status & 0x7f) will be SIGSYS, not SIGKILL” (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.
  2. SECCOMP_RET_KILL_THREAD = 0x00000000U (also spelled SECCOMP_RET_KILL, the older name) — terminates only the offending thread, again reporting SIGSYS. The danger of KILL_THREAD over KILL_PROCESS is 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_PROCESS was added precisely to fix this footgun.
  3. SECCOMP_RET_TRAP = 0x00030000U — does not execute the syscall and instead sends a thread-directed SIGSYS signal synchronously to the calling thread. A signal handler can catch it; the siginfo_t carries the syscall number and address, and the low 16 bits of the return value (SECCOMP_RET_DATA) are delivered as si_errno. This lets a program emulate or log-and-recover from a blocked syscall in-process rather than dying.
  4. 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 the errno. The documentation: “the lower 16-bits of the return value being passed to userland as the errno.” This is the gentlest deny — the program sees a normal -EPERM/-ENOSYS and can handle it, exactly what defaultErrnoRet in a Docker profile produces.
  5. SECCOMP_RET_USER_NOTIF = 0x7fc00000U — does not execute the syscall (yet); instead it generates a struct seccomp_notif message on a userspace notification file descriptor (obtained via the NEW_LISTENER flag, 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.
  6. SECCOMP_RET_TRACE = 0x7ff00000U — notifies a ptrace(2)-based tracer (one attached with PTRACE_O_TRACESECCOMP) before the syscall runs, letting the tracer inspect, skip, or alter it. If no tracer is attached, the syscall returns -ENOSYS and is not executed. This predates USER_NOTIF and is the older way to hand control to a supervisor.
  7. SECCOMP_RET_LOG = 0x7ffc0000Uallows the syscall to execute, but logs it first (subject to the action being enabled in actions_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.
  8. 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_PROCESS and LOG in 4.14, USER_NOTIF in 5.0). Reason: these come from the seccomp(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.12 seccomp.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 only KILL_PROCESS sets).
  • 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 (the errno for RET_ERRNO, the si_errno for RET_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 largerALLOW (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 << 0thread-sync. Normally a filter installs only on the calling thread; with TSYNC the 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), the seccomp(2) call returns the thread ID of the offending thread instead of 0/-1, so the caller knows which thread blocked the sync.
  • SECCOMP_FILTER_FLAG_LOG = 1 << 1 — log all filter return actions except SECCOMP_RET_ALLOW (subject to the kernel’s actions_logged sysctl), regardless of whether the individual action is RET_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_ALLOW says “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 of 0, for use with SECCOMP_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 — modifies TSYNC’s error reporting. The original TSYNC returns a positive thread ID on sync failure, which collides ambiguously with a valid fd returned by NEW_LISTENER when both flags are used together. TSYNC_ESRCH makes a sync failure return a clean -ESRCH error instead, removing the ambiguity so TSYNC and NEW_LISTENER can be combined safely. Added in Linux 5.7.
  • SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV = 1 << 5 — for USER_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_ALLOW 4.17, TSYNC_ESRCH 5.7, WAIT_KILLABLE_RECV 5.19) and the precise SSBD-mitigation semantics of SPEC_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 the arch_seccomp_spec_mitigate() path. The flag bit values are verified against v6.12 seccomp.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