seccomp and Syscall Filtering

seccomp (SECure COMPuting) is the kernel mechanism that lets a thread voluntarily and irreversibly narrow the set of system calls it — and every process descended from it — may issue. In its modern form the thread installs a classic Berkeley Packet Filter (cBPF) program which the kernel runs on every system call the thread makes, at a fixed point on the syscall-entry path. The program is handed a 64-byte, kernel-built, read-only record — struct seccomp_data, carrying the syscall number, the calling convention, the userspace instruction pointer and the six argument registers — and must return a 32-bit verdict whose top 16 bits select one of eight actions and whose bottom 16 bits carry a payload (seccomp_filter.rst, v6.12). Everything surprising about seccomp follows from two facts established below: the filter runs before the syscall is dispatched, and it can see only registers, never the memory they point at.

What this note covers, and what its siblings cover

seccomp is split across five notes in this vault, deliberately. This one is the mechanism note, written from the syscall-interface side: it answers where the filter runs, what exactly it is handed, what language it may be written in, how several filters combine into one verdict, and what it costs. It is the reciprocal of its near-titled sibling.

NoteOwnsThe question it answers
This noteseccomp and Syscall FilteringThe kernel-side mechanismWhere on the entry path does the filter run, what does it see, how do stacked filters resolve, what is cached?
Seccomp and seccomp-BPFThreat model, policy design, real profilesWhy would I confine something, and what policy should I write? Who deploys seccomp and how.
seccomp Filter Modes and Return ActionsThe SECCOMP_RET_* / SECCOMP_SET_MODE_* constant referenceWhich verdict do I return, and what are its exact bits?
seccomp User NotificationThe supervisor protocolHow do I build a SECCOMP_RET_USER_NOTIF supervisor and survive its races?
Writing a seccomp FilterAuthoringHow do I emit the cBPF, by hand or through libseccomp?

Where the two near-titled notes genuinely overlap — above all the pointer-dereference limitation — both state it in full on purpose, because a reader who lands on either one and gets it wrong writes an unsafe filter. Everything about who deploys seccomp (Docker’s default profile, systemd, OpenSSH, browsers, gVisor) belongs to the sibling and is not re-derived here.

Version pin. All source below is read at Linux v6.12. Verified against kernel.org’s release index on 2026-09-04: v6.12 is a longterm series, not end-of-life, with v6.12.108 released 2026-09-02; mainline had by then moved on to 7.3-rc1, with 7.2 the current stable series (torvalds/linux tags feed). Every “since Linux X” claim below is dated by fetching the relevant file at successive release tags and recording where the identifier appears; the bracketing tags are named in the text so the dating can be re-checked. Anything I could not pin at v6.12 is flagged.

Mental Model — A Checkpoint Stapled to the Entry Road

Think of the system-call boundary as a single road every privileged request must drive down: userspace executes a trap instruction, the CPU switches to kernel mode, and a short piece of architecture-specific assembly hands control to C code that will eventually index the system-call table and call a handler. seccomp is a checkpoint welded onto that road, positioned after the trap and before the table lookup. Every syscall the thread issues drives past it. There is no side road: vDSO calls that never trap are the one exception, and they are an exception precisely because they never reach the road at all.

The checkpoint runs a small sandboxed program — the cBPF filter — over a fixed-size record built from the trapping thread’s registers, and that program votes. The vote is one of eight actions. When several filters have been installed, all of them run and the kernel keeps the most restrictive vote. The whole design is a deliberate refusal of expressiveness: the filter cannot loop, cannot call into the kernel, cannot allocate, and cannot read memory, which is why it is safe to let an unprivileged process install one on the hottest path in the kernel.

flowchart TB
  USER["userspace<br/>syscall instruction<br/>(nr in a register, 6 args in registers)"]
  USER -->|"trap to kernel mode"| ARCH["arch entry asm →<br/>C entry layer"]
  ARCH --> GATE{"syscall_work bitmask<br/>has any bit set?"}
  GATE -->|"no (the common case)"| DISPATCH
  GATE -->|"yes"| WORK["syscall_trace_enter()"]
  subgraph WORK2["entry work items, in fixed order"]
    SUD["1. Syscall User Dispatch"]
    PTRACE["2. ptrace entry stop / SYSEMU"]
    SECCOMP["3. __secure_computing()"]
    SUD --> PTRACE --> SECCOMP
  end
  WORK --> WORK2
  SECCOMP -->|"run cBPF over<br/>struct seccomp_data"| FILTER["seccomp_run_filters():<br/>min() over the filter stack"]
  FILTER -->|"ALLOW / LOG"| DISPATCH["sys_call_table[nr] → handler"]
  FILTER -->|"ERRNO"| FAKE["set return reg = -errno,<br/>skip the syscall"]
  FILTER -->|"TRAP"| SIG["syscall_rollback() then<br/>force SIGSYS to this thread"]
  FILTER -->|"KILL_THREAD / KILL_PROCESS"| KILL["mode := DEAD, then<br/>do_exit(SIGSYS) or coredump"]
  FILTER -->|"USER_NOTIF"| SUP["block in the kernel;<br/>wake a supervisor on a listener fd"]
  FILTER -->|"TRACE"| TR["PTRACE_EVENT_SECCOMP stop,<br/>then re-run the filters"]

The seccomp checkpoint on the syscall-entry path. What it shows: the filter is reached only when the thread’s syscall_work bitmask is non-zero, is the third work item checked (after Syscall User Dispatch and after ptrace’s entry stop), and routes the syscall to one of six fates before the system-call table is ever indexed. The insight to take: seccomp is a gate, not a sandbox — it decides allow/fake/kill/delegate per syscall at the one chokepoint every privileged request must cross, which is exactly why one small filter governs a thread’s entire kernel surface, and equally why it can say nothing about what an allowed syscall then does.

Where It Runs — The Entry Path, Step by Step

The mechanically important question is where seccomp hooks, because that placement determines everything the filter can and cannot know.

On architectures that use the generic entry layer (CONFIG_GENERIC_ENTRY), the per-syscall slow-path work is funnelled through syscall_trace_enter() in kernel/entry/common.c. That function is reached only from syscall_enter_from_user_mode_work(), which first tests a single word (entry-common.h, v6.12):

static __always_inline long syscall_enter_from_user_mode_work(struct pt_regs *regs, long syscall)
{
	unsigned long work = READ_ONCE(current_thread_info()->syscall_work);
 
	if (work & SYSCALL_WORK_ENTER)
		syscall = syscall_trace_enter(regs, syscall, work);
 
	return syscall;
}

That test is the whole cost of seccomp for a thread that has no filter: one load of a word already resident in the thread-info cache line, and one mask-and-branch. syscall_work is a small per-thread bitmask, distinct from the _TIF_* thread-info flags that drive the exit-to-user loop (see Thread Info Flags and Syscall Exit Work), and seccomp owns bit 0 of it — SYSCALL_WORK_BIT_SECCOMP is the first entry of enum syscall_work_bit (thread_info.h, v6.12). The six bits that make up SYSCALL_WORK_ENTER are seccomp, the syscall tracepoint, ptrace’s TRACE and EMU stops, audit, and Syscall User Dispatch.

When at least one of those bits is set, syscall_trace_enter() runs the enabled work items in a fixed order (kernel/entry/common.c, v6.12):

long syscall_trace_enter(struct pt_regs *regs, long syscall, unsigned long work)
{
	long ret = 0;
 
	/*
	 * Handle Syscall User Dispatch.  This must comes first, since
	 * the ABI here can be something that doesn't make sense for
	 * other syscall_work features.
	 */
	if (work & SYSCALL_WORK_SYSCALL_USER_DISPATCH) {
		if (syscall_user_dispatch(regs))
			return -1L;
	}
 
	/* Handle ptrace */
	if (work & (SYSCALL_WORK_SYSCALL_TRACE | SYSCALL_WORK_SYSCALL_EMU)) {
		ret = ptrace_report_syscall_entry(regs);
		if (ret || (work & SYSCALL_WORK_SYSCALL_EMU))
			return -1L;
	}
 
	/* Do seccomp after ptrace, to catch any tracer changes. */
	if (work & SYSCALL_WORK_SECCOMP) {
		ret = __secure_computing(NULL);
		if (ret == -1L)
			return ret;
	}
 
	/* Either of the above might have changed the syscall number */
	syscall = syscall_get_nr(current, regs);
 
	if (unlikely(work & SYSCALL_WORK_SYSCALL_TRACEPOINT)) {
		trace_sys_enter(regs, syscall);
		syscall = syscall_get_nr(current, regs);
	}
 
	syscall_enter_audit(regs, syscall);
 
	return ret ? : syscall;
}

Three ordering decisions in that function are load-bearing, and each is a comment the kernel authors wrote down rather than something to be inferred:

  1. Syscall User Dispatch runs first, “since the ABI here can be something that doesn’t make sense for other syscall_work features.” Syscall User Dispatch intercepts a thread’s own syscalls by instruction-pointer range so an emulator such as Wine can serve Windows syscalls; those register conventions are meaningless to ptrace and to seccomp, so they are diverted before either sees them. Syscall User Dispatch is explicitly not a security mechanism — the process flips its own selector byte in userspace without entering the kernel — which is why it can be allowed to pre-empt the security gate.
  2. seccomp runs after ptrace, on purpose: “Do seccomp after ptrace, to catch any tracer changes.” A debugger stopped at the ptrace entry stop may rewrite the syscall number or arguments; the filter must judge what the thread will actually execute, not what it originally asked for. So seccomp evaluates last among the two, over the possibly-rewritten registers.
  3. The tracepoint and audit run after seccomp, so [[Syscall Tracepoints sys_enter and sys_exit|sys_enter]] observes calls that survived the filter. The function re-reads syscall_get_nr() after each stage precisely because any of them may have changed it.

__secure_computing() returns -1L to mean skip this syscall. The caller propagates that as the syscall number, and the architecture dispatcher treats it as “not a syscall” — on x86-64, do_syscall_64() checks nr != -1 before falling back to __x64_sys_ni_syscall(), so a skipped call never touches the system-call table at all (see The Generic Syscall Entry and Exit Layer and The System Call Table).

sequenceDiagram
    autonumber
    participant U as Userspace thread
    participant A as Arch entry (do_syscall_64)
    participant E as syscall_trace_enter()
    participant S as __secure_computing()
    participant F as cBPF filter stack
    participant K as sys_call_table handler

    U->>A: syscall instruction (nr, 6 args in regs)
    A->>A: add_random_kstack_offset()
    A->>E: syscall_enter_from_user_mode(regs, nr)
    Note over E: work = thread_info->syscall_work<br/>if (work & SYSCALL_WORK_ENTER) == 0<br/>skip everything below
    E->>E: 1. Syscall User Dispatch?
    E->>E: 2. ptrace entry stop? (may rewrite nr/args)
    E->>S: 3. __secure_computing(NULL)
    S->>S: populate_seccomp_data(&sd_local)<br/>reads nr, arch, IP, args from pt_regs
    S->>F: seccomp_run_filters(sd, &match)
    F->>F: cache hit? -> ALLOW immediately
    F->>F: else run every filter, keep min(action)
    F-->>S: 32-bit verdict
    alt verdict == ALLOW or LOG
        S-->>E: 0
        E-->>A: nr (possibly rewritten)
        A->>K: dispatch handler
        K-->>U: return value
    else verdict == ERRNO
        S->>S: syscall_set_return_value(-errno)
        S-->>E: -1L
        E-->>A: -1
        A-->>U: syscall "failed" — handler never ran
    else verdict == KILL_*
        S->>S: seccomp.mode := SECCOMP_MODE_DEAD
        S-->>U: SIGSYS / do_exit(SIGSYS)
    end

One system call traversing the entry path into the filter and back. What it shows: the exact sequence from trap to verdict, including the single-word syscall_work test that gates the whole slow path, the point at which struct seccomp_data is materialised from pt_regs, and the three distinct ways control leaves the filter. The insight to take: an ERRNO denial and an ALLOW differ only in whether step 10 ever happens — the syscall handler is simply never entered, so a denied call has no kernel-side side effects, which is why SECCOMP_RET_ERRNO is safe to use as a compatibility shim and not merely as a refusal.

Not every architecture uses the generic entry layer

CONFIG_GENERIC_ENTRY is opt-in per architecture, and at v6.12 several major ports have not converted. Checking select GENERIC_ENTRY in each arch/*/Kconfig at that tag gives:

ArchitectureGeneric entry at v6.12?Where seccomp is called fromseccomp-BPF supported since
x86 (32 and 64-bit)yeskernel/entry/common.cLinux 3.5
s390yeskernel/entry/common.cLinux 3.8
RISC-Vyeskernel/entry/common.c
LoongArchyeskernel/entry/common.c
arm64noarch/arm64/kernel/ptrace.cLinux 3.19
arm (32-bit)noarch entry codeLinux 3.8
powerpcnoarch entry codeLinux 4.3
MIPSnoarch entry codeLinux 3.16
PA-RISCnoarch entry codeLinux 4.6

Which ports use the generic entry layer, and where the seccomp hook therefore lives. What it shows: the “since Linux X” column is the architecture-support list published in seccomp(2); the generic-entry column is read directly from each arch/*/Kconfig at v6.12. The insight to take: if you are reading entry-path code to answer a seccomp question, check first which of the two layers your architecture is on — the answer is in arch/<name>/Kconfig, one grep away, and reading kernel/entry/common.c for an arm64 question will mislead you.

The non-converted ports duplicate the same ordering by hand. arm64’s version reads (arch/arm64/kernel/ptrace.c, v6.12):

int syscall_trace_enter(struct pt_regs *regs)
{
	unsigned long flags = read_thread_flags();
 
	if (flags & (_TIF_SYSCALL_EMU | _TIF_SYSCALL_TRACE)) {
		report_syscall(regs, PTRACE_SYSCALL_ENTER);
		if (flags & _TIF_SYSCALL_EMU)
			return NO_SYSCALL;
	}
 
	/* Do the secure computing after ptrace; failures should be fast. */
	if (secure_computing() == -1)
		return NO_SYSCALL;
 
	if (test_thread_flag(TIF_SYSCALL_TRACEPOINT))
		trace_sys_enter(regs, regs->syscallno);
 
	audit_syscall_entry(regs->syscallno, regs->orig_x0, regs->regs[1],
			    regs->regs[2], regs->regs[3]);
 
	return regs->syscallno;
}

Same order — ptrace, then seccomp, then tracepoint, then audit — expressed over _TIF_* flags rather than the syscall_work bitmask, and calling the wrapper secure_computing() rather than __secure_computing() directly. The wrapper is the trivial one from include/linux/seccomp.h: if (unlikely(test_syscall_work(SECCOMP))) return __secure_computing(NULL);. The generic entry layer inlines that test into its own bitmask check, which is the only real difference.

__secure_computing() — the mode switch

The entry point itself is a three-way switch on the thread’s mode, with one pre-check (kernel/seccomp.c, v6.12):

int __secure_computing(const struct seccomp_data *sd)
{
	int mode = current->seccomp.mode;
	int this_syscall;
 
	if (IS_ENABLED(CONFIG_CHECKPOINT_RESTORE) &&
	    unlikely(current->ptrace & PT_SUSPEND_SECCOMP))
		return 0;
 
	this_syscall = sd ? sd->nr :
		syscall_get_nr(current, current_pt_regs());
 
	switch (mode) {
	case SECCOMP_MODE_STRICT:
		__secure_computing_strict(this_syscall);  /* may call do_exit */
		return 0;
	case SECCOMP_MODE_FILTER:
		return __seccomp_filter(this_syscall, sd, false);
	/* Surviving SECCOMP_RET_KILL_* must be proactively impossible. */
	case SECCOMP_MODE_DEAD:
		WARN_ON_ONCE(1);
		do_exit(SIGKILL);
		return -1;
	default:
		BUG();
	}
}

The PT_SUSPEND_SECCOMP pre-check is the deliberate off switch used by checkpoint/restore tooling; its capability gating is analysed from the security angle in Seccomp and seccomp-BPF. SECCOMP_MODE_DEAD is discussed next.

Two Modes, and the One-Way Mode Machine

current->seccomp.mode is one of four values. Three are in the userspace ABI; the fourth is deliberately hidden.

/* include/uapi/linux/seccomp.h, v6.12 */
#define SECCOMP_MODE_DISABLED	0 /* seccomp is not in use. */
#define SECCOMP_MODE_STRICT	1 /* uses hard-coded filter. */
#define SECCOMP_MODE_FILTER	2 /* uses user-supplied filter. */
 
/* kernel/seccomp.c, v6.12 */
/* Not exposed in headers: strictly internal use only. */
#define SECCOMP_MODE_DEAD	(SECCOMP_MODE_FILTER + 1)

SECCOMP_MODE_STRICT — “mode 1” — is the original seccomp, merged in Linux 2.6.12 and written by Andrea Arcangeli to support CPUShare, a side business that would have let people rent out spare CPU time to strangers (Corbet, LWN, May 2009). The threat model was literally “run this stranger’s number-crunching code on my machine,” and the answer was a hard-coded allowlist of four calls. The implementation is a literal linear walk of a four-element array:

static const int mode1_syscalls[] = {
	__NR_seccomp_read, __NR_seccomp_write, __NR_seccomp_exit, __NR_seccomp_sigreturn,
	-1, /* negative terminated */
};
 
static void __secure_computing_strict(int this_syscall)
{
	const int *allowed_syscalls = mode1_syscalls;
#ifdef CONFIG_COMPAT
	if (in_compat_syscall())
		allowed_syscalls = get_compat_mode1_syscalls();
#endif
	do {
		if (*allowed_syscalls == this_syscall)
			return;
	} while (*++allowed_syscalls != -1);
 
	current->seccomp.mode = SECCOMP_MODE_DEAD;
	seccomp_log(this_syscall, SIGKILL, SECCOMP_RET_KILL_THREAD, true);
	do_exit(SIGKILL);
}

The four names resolve per architecture through asm-generic/seccomp.h, which defaults them to __NR_read, __NR_write, __NR_exit and __NR_rt_sigreturn and lets an architecture override — x86-32 substitutes the legacy __NR_sigreturn, and both x86-64 and arm64 supply a separate compat table so a 32-bit task under mode 1 is checked against 32-bit numbers.

Mode 1 was, by the kernel community’s own assessment, close to useless in practice. Corbet’s 2009 write-up records that when a security hole was found in the seccomp code in early 2009, “Linus wondered whether it was being used at all. It seems likely that there were, in fact, no users at that time.” A thread that may only read, write, _exit and sigreturn cannot allocate memory, cannot open a file, and cannot even ask the time; every other operation must be marshalled to a helper process over a pre-opened pipe. Google’s Chrome sandbox developers did exactly that and reported the result as “slow and rather awkward.” Mode 1 survives today for ABI compatibility, and because its four-line implementation costs nothing to keep.

SECCOMP_MODE_FILTER — “mode 2”, merged in Linux 3.5 — is the real mechanism and the subject of the rest of this note. It is reached through either prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prog) or, since Linux 3.17, the dedicated seccomp(SECCOMP_SET_MODE_FILTER, flags, prog) system call, which the man page describes as providing “a superset of the functionality provided by the prctl(2) PR_SET_SECCOMP operation (which does not support flags).”

SECCOMP_MODE_DEAD is the interesting one. It is not in the uapi header — the comment says “strictly internal use only” — and it exists to make one specific bug loud. When a filter returns a KILL action, __seccomp_filter() sets the mode to DEAD before killing the task. If any code path then manages to reach __secure_computing() again on a task that should already be dead, the switch hits case SECCOMP_MODE_DEAD:, fires WARN_ON_ONCE(1), and do_exit(SIGKILL)s unconditionally — “Surviving SECCOMP_RET_KILL_* must be proactively impossible,” as the in-tree comment puts it. Dating it by tag: SECCOMP_MODE_DEAD appears zero times in kernel/seccomp.c at v5.16 and four times at v5.17, so it landed in Linux 5.17. Before that, a task that somehow survived a kill action would have fallen through to default: BUG().

stateDiagram-v2
    [*] --> DISABLED : every task starts here<br/>(mode 0, filter == NULL)

    DISABLED --> STRICT : prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT)<br/>or seccomp(SECCOMP_SET_MODE_STRICT)
    DISABLED --> FILTER : seccomp(SECCOMP_SET_MODE_FILTER, ...)<br/>requires no_new_privs or CAP_SYS_ADMIN

    FILTER --> FILTER : install another filter<br/>(pushed onto the stack — can only tighten)

    STRICT --> DEAD : any syscall outside the<br/>4-call hard-coded list
    FILTER --> DEAD : a filter returns<br/>KILL_THREAD or KILL_PROCESS

    DEAD --> [*] : do_exit(SIGSYS) or coredump

    note right of DISABLED
      seccomp_may_assign_mode() refuses any
      transition once mode is non-zero and the
      requested mode differs, yielding -EINVAL.
      There is no path back to DISABLED.
    end note

    note right of FILTER
      SECCOMP_MODE_DEAD is not in the uapi
      header. Reaching __secure_computing()
      in DEAD is a kernel bug: WARN_ON_ONCE
      then unconditional do_exit(SIGKILL).
    end note

The seccomp mode machine, and why it has no edges pointing left. What it shows: the only transitions are DISABLED into one of the two live modes, FILTER into itself (stacking), and either live mode into DEAD. The insight to take: the guard is one function, seccomp_may_assign_mode(), which returns false if current->seccomp.mode is already non-zero and differs from the requested mode — so a thread cannot switch from strict to filter mode, cannot disable seccomp, and cannot ever return to an unconfined state. This irreversibility is what makes seccomp a trust boundary rather than a configuration setting: the code you jump into after installing a filter cannot undo it, no matter how thoroughly it is compromised.

What the Filter Sees — struct seccomp_data

The cBPF program does not run over a network packet. It runs over a 64-byte record the kernel builds fresh from the trapping thread’s saved register state (include/uapi/linux/seccomp.h, v6.12):

/**
 * struct seccomp_data - the format the BPF program executes over.
 * @nr: the system call number
 * @arch: indicates system call convention as an AUDIT_ARCH_* value
 *        as defined in <linux/audit.h>.
 * @instruction_pointer: at the time of the system call.
 * @args: up to 6 system call arguments always stored as 64-bit values
 *        regardless of the architecture.
 */
struct seccomp_data {
	int nr;
	__u32 arch;
	__u64 instruction_pointer;
	__u64 args[6];
};

It is populated by one function, which is worth reading because it shows exactly which architecture hooks seccomp depends on:

static void populate_seccomp_data(struct seccomp_data *sd)
{
	struct task_struct *task = current;
	struct pt_regs *regs = task_pt_regs(task);
	unsigned long args[6];
 
	sd->nr = syscall_get_nr(task, regs);
	sd->arch = syscall_get_arch(task);
	syscall_get_arguments(task, regs, args);
	sd->args[0] = args[0];
	/* ... args[1] through args[5] ... */
	sd->instruction_pointer = KSTK_EIP(task);
}

Those four accessors — syscall_get_nr(), syscall_get_arch(), syscall_get_arguments(), plus syscall_rollback() and syscall_set_return_value() used later — are precisely the list arch/Kconfig demands before an architecture may select CONFIG_HAVE_ARCH_SECCOMP_FILTER. The record lives on the kernel stack of the trapping thread (struct seccomp_data sd_local; inside __seccomp_filter()) and is discarded when the call returns; nothing is allocated.


packet-beta
0-31: "nr (int) — the system call number, signed"
32-63: "arch (__u32) — AUDIT_ARCH_* calling convention"
64-127: "instruction_pointer (__u64) — userspace IP at the trap"
128-191: "args[0] (__u64)"
192-255: "args[1] (__u64)"
256-319: "args[2] (__u64)"
320-383: "args[3] (__u64)"
384-447: "args[4] (__u64)"
448-511: "args[5] (__u64)"

struct seccomp_data, drawn at bit accuracy on a 64-bit grid. What it shows: the entire universe the filter can address — 512 bits, nine fields, no padding, no pointers followed. nr and arch share the first 64-bit word, so arch sits at byte offset 4 and args[n] at byte offset 16 + 8n. The insight to take: a BPF_LD | BPF_W | BPF_ABS load with k = 0 reads nr; k = 4 reads arch; k = 16 reads the low half of args[0] and k = 20 its high half. Every offset a legal seccomp filter may name is in this picture, and there are exactly sixteen of them.

Why the `` directive above

The vault’s house style (Drawing Wire Formats with Mermaid Packet Diagrams) says to set bitsPerRow to the structure’s natural word size rather than leaving it at the 32-bit default; struct seccomp_data is a 64-bit-word structure, so 64 is right. That note carries an open uncertainty flag about whether in-fence init directives survive Obsidian’s sanitiser. If the directive is ignored the diagram still renders correctly — each 64-bit field simply splits across two 32-bit rows with the same label — and the byte-offset table below is the authoritative fallback either way.

FieldByte offsetSizeBPF_ABS offsets a filter may loadWhat it holds
nr040The system call number, as an int. On x86-64 this is regs->orig_ax, raw — including __X32_SYSCALL_BIT if set.
arch444An AUDIT_ARCH_* value naming the calling convention, e.g. AUDIT_ARCH_X86_64 = 0xC000003E, AUDIT_ARCH_I386 = 0x40000003, AUDIT_ARCH_AARCH64 = 0xC00000B7.
instruction_pointer888, 12The userspace instruction pointer at the trap, from KSTK_EIP().
args[0]args[5]16, 24, 32, 40, 48, 568 each16,2056,60The six argument registers, always widened to 64 bits regardless of the architecture or the ABI in use.

The complete addressable surface of a seccomp filter, as byte offsets. What it shows: each field, where it starts, and the exact 32-bit-aligned k values a BPF_LD|BPF_W|BPF_ABS instruction may carry. The insight to take: cBPF loads are 32 bits wide, so every 64-bit field must be read as two separate loadsk and k+4 — and compared separately. This is why hand-written filters that check a 64-bit pointer or flag argument are twice as long as one naively expects, and why forgetting the high half is a classic filter bug: comparing only args[0]’s low word lets an attacker set the high word to anything.

The AUDIT_ARCH_* values are not arbitrary. They are the ELF machine number OR’d with two flag bits defined in include/uapi/linux/audit.h: __AUDIT_ARCH_64BIT (0x80000000) and __AUDIT_ARCH_LE (0x40000000). So AUDIT_ARCH_X86_64 is EM_X86_64 | 64-bit | little-endian, and AUDIT_ARCH_I386 is EM_386 | little-endian with the 64-bit flag clear. A filter can therefore test “is this a 64-bit convention?” with a single BPF_JSET against 0x80000000 — though in practice you should compare the whole word against one expected constant, because a partial test is a partial policy.

Two argument subtleties from the seccomp(2) man page that only make sense once you know the record is register-derived:

  • Arguments are widened before the filter and truncated after it. “Arguments are often silently truncated before being processed, but after the seccomp check. For example, this happens if the i386 ABI is used on an x86-64 kernel: although the kernel will normally not look beyond the 32 lowest bits of the arguments, the values of the full 64-bit registers will be present in the seccomp data.” The filter sees register bits the syscall handler will later ignore. Writing a rule against those upper bits is writing a rule about something the kernel does not act on.
  • The instruction pointer is real but fragile as a policy input. The man page suggests pairing it with /proc/pid/maps to decide “which mapping made this call,” then immediately warns that “probably, it is wise to lock down the mmap(2) and mprotect(2) system calls to prevent the program from subverting such checks.” The address is trustworthy; the mapping layout it indexes into is only trustworthy if the confined program cannot change it.

The Pointer-Dereference Limitation — The Single Most Important Fact

Everything a seccomp filter can decide, it decides from the 512 bits above. There is no instruction in the accepted cBPF subset that loads from an arbitrary address, and the seccomp-specific verifier physically rewrites the only load instruction that could be abused into a bounds-checked read of the seccomp_data buffer:

case BPF_LD | BPF_W | BPF_ABS:
	ftest->code = BPF_LDX | BPF_W | BPF_ABS;
	/* 32-bit aligned and not out of bounds. */
	if (k >= sizeof(struct seccomp_data) || k & 3)
		return -EINVAL;
	continue;

Two conditions, checked at load time, once, for every instruction in the program: the offset must be less than sizeof(struct seccomp_data) (64) and must be 4-byte aligned. Fail either and seccomp() returns -EINVAL — the seccomp(2) man page lists exactly this: “operation included BPF_ABS, but the specified offset was not aligned to a 32-bit boundary or exceeded sizeof(struct seccomp_data).” The kernel documentation states the consequence flatly: “BPF programs may not dereference pointers which constrains all filters to solely evaluating the system call arguments directly.”

The consequence is the single fact that explains most seccomp questions. If a syscall argument is a pointer — openat(int dirfd, const char *pathname, ...), connect(int fd, const struct sockaddr *addr, ...), execve(const char *filename, ...), mount(const char *source, const char *target, ...) — the filter sees a 64-bit number, not the bytes it addresses. You can filter on openat’s flags (a scalar in args[2]). You cannot filter on its pathname. “Allow this process to open only files under /srv” is not expressible in seccomp, at all, on any kernel version.

This is a design decision, not an oversight

The reason is a time-of-check-to-time-of-use (TOCTOU) race, and the kernel documentation names it as one of seccomp’s headline properties: “BPF makes it impossible for users of seccomp to fall prey to time-of-check-time-of-use (TOCTOU) attacks that are common in system call interposition frameworks.”

sequenceDiagram
    autonumber
    participant T1 as Attacker thread A
    participant T2 as Attacker thread B<br/>(same address space)
    participant I as A hypothetical<br/>pointer-reading filter
    participant K as Kernel syscall handler

    Note over T1,T2: Both threads share one mm — B can write<br/>any byte A passes a pointer to.
    T1->>T1: buf = "/tmp/harmless"
    T1->>I: openat(AT_FDCWD, buf, O_RDONLY)
    I->>I: read *buf from userspace memory
    I->>I: "/tmp/harmless" — policy says ALLOW
    T2->>T2: memcpy(buf, "/etc/shadow", 12)
    I->>K: proceed with the syscall
    K->>K: copy_from_user(buf) — reads "/etc/shadow"
    K-->>T1: fd for /etc/shadow
    Note over I,K: The check and the use read the same address<br/>at two different times. The window between<br/>steps 4 and 8 is the entire vulnerability.

The race that seccomp’s design forecloses. What it shows: an interposition framework that reads a pointer argument out of the target’s memory has, by construction, a window between its check and the kernel’s use, and any thread sharing the address space can write into it. The insight to take: the fix is not “check faster” — the window cannot be closed from outside the kernel, because the memory belongs to the attacker. seccomp closes it by refusing to look: the filter judges only values that are already copied into kernel-owned storage (sd_local on the kernel stack, built from pt_regs), which no userspace thread can alter. The limitation and the security property are the same fact seen from two sides.

The same reasoning is why the documentation’s advice to a supervisor — the one component that genuinely may read the target’s memory — is so emphatic about ordering: “all arguments being read from the tracee’s memory should be read into the tracer’s memory before any policy decisions are made. This allows for an atomic decision on syscall arguments.” Copy first, then decide, never decide-then-copy. The SECCOMP_RET_USER_NOTIF section below shows how thoroughly that is still not enough.

Classic BPF, Not eBPF — What the Verifier Accepts

seccomp is one of the last consumers of classic BPF (cBPF), the 1992 Berkeley Packet Filter instruction set, rather than extended BPF (eBPF), the modern in-kernel virtual machine described in The bpf() Syscall. This is a standing, deliberate choice, and it is worth separating two things that are easy to conflate: the language you may express a filter in, and the engine that executes it.

The language: a two-register accumulator machine with 16 scratch words

A cBPF program is an array of struct sock_filter, each 8 bytes: a 16-bit opcode, two 8-bit jump offsets, and a 32-bit immediate. The machine has an accumulator A, an index register X, and 16 words of scratch memory (BPF_MEMWORDS). It has no stack, no function calls, no helpers, and no maps.

Every seccomp filter passes two verification passes at install time, in this order:

flowchart TB
  U["userspace: struct sock_fprog<br/>{ len, filter[] }"]
  U -->|"copy_from_user"| P["seccomp_prepare_user_filter()<br/>(compat_sock_fprog if in_compat_syscall)"]
  P --> L{"len == 0 or<br/>len > BPF_MAXINSNS (4096)?"}
  L -->|"yes"| E1["-EINVAL"]
  L -->|"no"| NNP{"task_no_new_privs(current)<br/>or ns_capable(CAP_SYS_ADMIN)?"}
  NNP -->|"no"| E2["-EACCES"]
  NNP -->|"yes"| C1["bpf_check_classic()<br/>generic cBPF structural verifier"]
  C1 -->|"fail"| E3["-EINVAL"]
  C1 --> C2["seccomp_check_filter()<br/>seccomp-specific opcode allowlist<br/>+ rewrite of BPF_ABS loads"]
  C2 -->|"fail"| E3
  C2 --> J{"bpf_jit_compile()<br/>classic JIT available?"}
  J -->|"yes, jited = 1"| RUN["native code"]
  J -->|"no (every modern arch)"| M["bpf_migrate_filter():<br/>translate cBPF -> eBPF insns"]
  M --> SR["bpf_prog_select_runtime():<br/>eBPF interpreter or eBPF JIT"]
  SR --> RUN
  RUN --> ATT["seccomp_attach_filter():<br/>length budget, TSYNC, cache prep,<br/>push onto filter->prev chain"]

What actually happens to a filter between seccomp() and the first syscall it judges. What it shows: two verification passes, then a translation step, then attachment. bpf_check_classic() is the same structural verifier socket filters use; seccomp_check_filter() is passed to it as the trans callback and is where seccomp’s extra restrictions live (net/core/filter.c, v6.12). The insight to take: the -EACCES for a missing no_new_privs is raised in seccomp_prepare_filter() before either verifier runs, so a program that gets EACCES has learned nothing about whether its filter is well-formed — fix the prctl first, then debug the cBPF.

bpf_check_classic() enforces the structural properties that make a filter safe to run on the syscall fast path:

  • Every jump is forward and in range. A BPF_JMP | BPF_JA with k >= flen - pc - 1 is rejected; a conditional jump whose jt or jf would land at or past the end is rejected. Combined, this means the control-flow graph is a directed acyclic graph: a cBPF program cannot loop, so it always terminates in at most len steps.
  • The last instruction must be a BPF_RET. Falling off the end is not a thing.
  • Division and modulo by a zero immediate are rejected, and shift amounts of 32 or more are rejected.
  • Scratch memory accesses must be within BPF_MEMWORDS, and check_load_and_stores() verifies that no scratch word is read before it is written.

The upshot is that the kernel can guarantee a bound on the filter’s execution time from its length alone — at most 4096 instructions per filter — with no runtime accounting, no preemption points, and no possibility of a filter hanging a CPU. That guarantee is what makes it acceptable to let an unprivileged, untrusted process install a program on every syscall.

seccomp_check_filter() then narrows the instruction set to an explicit allowlist. Anything not in the following table is -EINVAL:

ClassAccepted opcodesNotes
Load (absolute)BPF_LD | BPF_W | BPF_ABSRewritten to BPF_LDX | BPF_W | BPF_ABS; k must be < 64 and 4-byte aligned. This is the only way to read seccomp_data.
Load (length)BPF_LD | BPF_W | BPF_LEN, BPF_LDX | BPF_W | BPF_LENRewritten to an immediate load of sizeof(struct seccomp_data), i.e. the constant 64.
Load (immediate)BPF_LD | BPF_IMM, BPF_LDX | BPF_IMMLoad a constant into A or X.
Scratch memoryBPF_LD | BPF_MEM, BPF_LDX | BPF_MEM, BPF_ST, BPF_STX16 words, index < BPF_MEMWORDS.
ArithmeticBPF_ALU with ADD, SUB, MUL, DIV, AND, OR, XOR, LSH, RSH (each in |BPF_K and |BPF_X forms), plus BPF_NEGNote the absentee: BPF_MOD is not on the list, though bpf_check_classic() knows about it.
JumpsBPF_JMP | BPF_JA, and JEQ, JGE, JGT, JSET in |BPF_K and |BPF_X formsThere is no JLT/JLE/JNE: you express those by swapping jt and jf. There is no signed comparison at all.
Register movesBPF_MISC | BPF_TAX, BPF_MISC | BPF_TXACopy A to X and back.
ReturnBPF_RET | BPF_K, BPF_RET | BPF_AThe verdict.

The complete cBPF instruction subset a seccomp filter may use, read from seccomp_check_filter() at v6.12. What it shows: roughly thirty opcodes, no memory access outside a 64-byte buffer and 16 scratch words, no signed comparison, no modulo. The insight to take: the absence of signed comparison is why filters test nr with unsigned JGE/JGT against X32_SYSCALL_BIT rather than checking for a negative number, and the absence of BPF_MOD is a genuine (if rarely load-bearing) gap between what bpf_check_classic() will pass and what seccomp will accept.

Three further restrictions come from the seccomp(2) man page rather than from a single line of code, and each catches people:

  • “The BPF_H and BPF_B size modifiers are not supported: all operations must load and store (4-byte) words (BPF_W).” You cannot load a single byte of seccomp_data.
  • “To access the contents of the seccomp_data buffer, use the BPF_ABS addressing mode modifier.” Indirect loads (BPF_IND) are not in the allowlist.
  • “The BPF_LEN addressing mode modifier yields an immediate mode operand whose value is the size of the seccomp_data buffer.” That is, BPF_LD|BPF_W|BPF_LEN is a constant 64 — a vestigial hook that exists so the same assembler that writes socket filters produces something meaningful here.

The engine: cBPF is a source language; eBPF is the runtime

Here is the part that surprises people who assume “classic BPF” means “slow interpreter.” bpf_prepare_filter() first tries the legacy classic JIT, and if that is unavailable — which it is on every modern architecture — calls bpf_migrate_filter(), which translates the cBPF program into eBPF instructions and hands the result to bpf_prog_select_runtime():

	/* Probe if we can JIT compile the filter and if so, do
	 * the compilation of the filter.
	 */
	bpf_jit_compile(fp);
 
	/* JIT compiler couldn't process this filter, so do the eBPF translation
	 * for the optimized interpreter.
	 */
	if (!fp->jited)
		fp = bpf_migrate_filter(fp);

So a seccomp filter is written in classic BPF, verified as classic BPF, then converted to eBPF and quite possibly JIT-compiled to native code before it ever judges a syscall. The restriction is on the language you may hand the kernel, not on the speed of what runs. seccomp_run_filters() invokes it through bpf_prog_run_pin_on_cpu(), which brackets the run in migrate_disable()/migrate_enable().

Why eBPF filters were never merged

Adding eBPF as a source language for seccomp has been proposed repeatedly. The most complete attempt is YiFei Zhu’s twelve-patch series of May 2021 (archived on LWN), which implemented eBPF filters, an LSM hook (seccomp_extended) to gate the advanced features, a bpf_probe_read_user path for reading target memory, and per-task storage for stateful filters. Its own benchmark of 1,000,000 getpid() calls on an Intel i7-9700K put eBPF filters at 80,316 µs against 3,403,667 µs for the user-notification path — a 42× difference on bare metal — which is the honest case for the feature. It was not merged. Corbet’s summary of the objections (LWN, October 2020) lists three:

  1. The eBPF maintainers were “concerned that use of eBPF in seccomp() could constrain the future development of eBPF itself” — seccomp’s ABI stability requirements would freeze parts of a fast-moving subsystem.
  2. Security-oriented developers worried “about the extra capabilities and attack surface provided by eBPF; it would not be hard to introduce new vulnerabilities by putting seccomp() and eBPF together.”
  3. Most decisively: “seccomp() filters can be loaded by unprivileged processes, and giving unprivileged code the ability to load eBPF programs is an idea that has fallen on hard times.” An unprivileged process may install a seccomp filter — that is the entire point of no_new_privs — and the eBPF verifier is a far larger, far more attacker-interesting piece of code than the thirty-opcode switch statement above.

The patch series itself concedes the point that matters most for this note: “eBPF does not solve the TOCTOU problem of user notifier, so users should not use this to enforce a policy based on memory contents.” Even with helpers that can read target memory, the race described in the previous section survives. Expressiveness was never the binding constraint.

Uncertain

Verify: that no eBPF-based seccomp filter interface has been merged in a kernel newer than v6.12. Reason: I confirmed absence at v6.12 by grepping kernel/seccomp.c and include/linux/bpf_types.h for BPF_PROG_TYPE_SECCOMP, bpf_prog_get and SECCOMP_SET_MODE_EBPF (zero hits in each), and the most recent proposal I read is dated May 2021 — but I did not check the 7.x mainline series. To resolve: grep include/uapi/linux/seccomp.h at the current mainline tag for a new SECCOMP_SET_MODE_* constant. #uncertain

The Verdict — Return Actions and How Precedence Is Resolved

A filter returns a 32-bit word. The kernel splits it with two masks:

#define SECCOMP_RET_ACTION_FULL	0xffff0000U   /* the action, including the sign bit */
#define SECCOMP_RET_ACTION	0x7fff0000U   /* the action, ignoring the sign bit */
#define SECCOMP_RET_DATA	0x0000ffffU   /* 16 bits of action-specific payload */

The upper 16 bits select one of eight actions; the lower 16 are a payload whose meaning depends on the action (an errno for ERRNO, a cookie for TRAP and TRACE, ignored otherwise). The constants are not arbitrary numbers — the header explains the encoding:

 * The upper 16-bits are ordered from least permissive values to most,
 * as a signed value (so 0x8000000 is negative).
 *
 * The ordering ensures that a min_t() over composed return values always
 * selects the least permissive choice.

That single sentence is the whole precedence mechanism. The actions are laid out so that a signed integer comparison is the precedence order. SECCOMP_RET_KILL_PROCESS is 0x80000000U, which reinterpreted as s32 is INT_MIN — more negative than anything else — so it always wins. SECCOMP_RET_ALLOW is 0x7fff0000U, the largest positive value in the set, so it always loses. Resolving a stack of filters therefore needs no policy table, no priority list and no special cases: it is min().

ActionValueAs s32Payload (low 16 bits)Effect
SECCOMP_RET_KILL_PROCESS0x80000000−2147483648ignoredWhole thread group dies. Syscall not run. Wait status is SIGSYS. Since Linux 4.14.
SECCOMP_RET_KILL_THREAD0x000000000ignoredCalling thread dies. Syscall not run. Historically spelled SECCOMP_RET_KILL.
SECCOMP_RET_TRAP0x00030000196608becomes si_errnosyscall_rollback(), then a synchronous SIGSYS to this thread. Syscall not run.
SECCOMP_RET_ERRNO0x00050000327680the errno, capped at MAX_ERRNOReturn register set to -data. Syscall not run.
SECCOMP_RET_USER_NOTIF0x7fc000002143289344ignoredThread blocks; a supervisor is woken on a listener fd. -ENOSYS if no listener. Since Linux 5.0.
SECCOMP_RET_TRACE0x7ff000002146435072via PTRACE_GETEVENTMSGPTRACE_EVENT_SECCOMP stop. -ENOSYS if no tracer.
SECCOMP_RET_LOG0x7ffc00002147221504ignoredSyscall runs, after an audit record. Since Linux 4.14.
SECCOMP_RET_ALLOW0x7fff00002147418112ignoredSyscall runs.
anything elseTreated as SECCOMP_RET_KILL_PROCESS (the default: label). Since Linux 4.14; KILL_THREAD in 4.13 and earlier.

The eight actions, in precedence order, with the numeric encoding that makes precedence a comparison. What it shows: the s32 column is strictly increasing down the table — that is not a coincidence, it is the design. The insight to take: the unknown-action row is the safety property that matters most in practice: a filter compiled against a newer seccomp.h than the kernel it runs on, returning an action the kernel has never heard of, kills the process rather than failing open. SECCOMP_GET_ACTION_AVAIL (Linux 4.14) exists so a program can ask first. Full per-action semantics are in seccomp Filter Modes and Return Actions.

Resolving the stack

Filters form a singly-linked list per task, newest first, walked via filter->prev. seccomp_run_filters() is the whole resolver:

#define ACTION_ONLY(ret) ((s32)((ret) & (SECCOMP_RET_ACTION_FULL)))
 
static u32 seccomp_run_filters(const struct seccomp_data *sd,
			       struct seccomp_filter **match)
{
	u32 ret = SECCOMP_RET_ALLOW;
	struct seccomp_filter *f = READ_ONCE(current->seccomp.filter);
 
	/* Ensure unexpected behavior doesn't result in failing open. */
	if (WARN_ON(f == NULL))
		return SECCOMP_RET_KILL_PROCESS;
 
	if (seccomp_cache_check_allow(f, sd))
		return SECCOMP_RET_ALLOW;
 
	/*
	 * All filters in the list are evaluated and the lowest BPF return
	 * value always takes priority (ignoring the DATA).
	 */
	for (; f; f = f->prev) {
		u32 cur_ret = bpf_prog_run_pin_on_cpu(f->prog, sd);
 
		if (ACTION_ONLY(cur_ret) < ACTION_ONLY(ret)) {
			ret = cur_ret;
			*match = f;
		}
	}
	return ret;
}

Five details in twenty lines, each with a visible consequence:

  1. The seed is SECCOMP_RET_ALLOW. With no filter that would fail open — hence the WARN_ON(f == NULL) guard immediately above, whose comment is “Ensure unexpected behavior doesn’t result in failing open,” and which returns KILL_PROCESS, not ALLOW, if the invariant is ever violated.
  2. The cast to s32 is what implements precedence. ACTION_ONLY() masks with SECCOMP_RET_ACTION_FULL (0xffff0000, including the sign bit) and casts. Using SECCOMP_RET_ACTION (0x7fff0000) instead would drop the sign bit and KILL_PROCESS would compare as zero, losing to KILL_THREAD. The two masks differ by exactly one bit and that bit is the entire kill-process/kill-thread ordering.
  3. Every filter runs, always. There is no early exit even after a KILL_PROCESS. The seccomp(2) man page explains why: “all filters will be called even if one of the earlier filters returns SECCOMP_RET_KILL. This is done to simplify the kernel code and to provide a tiny speed-up in the execution of sets of filters by avoiding a check for this uncommon case.” The cost of stacked filters is therefore strictly additive and not data-dependent — you always pay for all of them.
  4. The comparison is strictly <, so ties keep the earlier winner. The list is walked newest-first, so among filters returning the same action, the most recently installed one supplies the SECCOMP_RET_DATA. The kernel documentation states the resulting rule: “When multiple filters return values of the same precedence, only the SECCOMP_RET_DATA from the most recently installed filter will be returned.” Two filters both returning ERRNO with different errno values: the newest one’s errno is what userspace sees.
  5. *match records which filter won, and is left NULL when the verdict is ALLOW (the seed). __seccomp_filter() uses match for two things: to find the notification listener for USER_NOTIF, and to read match->log when deciding whether to emit an audit record.
flowchart TB
    START["a syscall arrives<br/>ret := SECCOMP_RET_ALLOW (0x7fff0000)<br/>f := current->seccomp.filter (newest)"]
    START --> CACHE{"constant-action bitmap:<br/>is nr marked always-allow<br/>for this arch?"}
    CACHE -->|"yes"| FAST["return ALLOW<br/>— no filter runs at all"]
    CACHE -->|"no"| LOOP

    subgraph LOOP["for (; f; f = f->prev) — newest to oldest, no early exit"]
      RUN["cur := bpf_prog_run_pin_on_cpu(f->prog, sd)"]
      CMP{"(s32)(cur & 0xffff0000)<br/>&lt;<br/>(s32)(ret & 0xffff0000) ?"}
      RUN --> CMP
      CMP -->|"yes — strictly more restrictive"| TAKE["ret := cur (action AND data)<br/>match := f"]
      CMP -->|"no — equal or more permissive"| KEEP["keep ret<br/>(ties keep the NEWER filter's data)"]
      TAKE --> NEXT["f := f->prev"]
      KEEP --> NEXT
    end

    LOOP --> DONE["ret is the minimum over the whole stack"]
    DONE --> DISPATCH{"action = ret & 0xffff0000"}

    DISPATCH --> K1["0x80000000 KILL_PROCESS"]
    DISPATCH --> K2["0x00000000 KILL_THREAD"]
    DISPATCH --> T1["0x00030000 TRAP"]
    DISPATCH --> E1["0x00050000 ERRNO"]
    DISPATCH --> N1["0x7fc00000 USER_NOTIF"]
    DISPATCH --> R1["0x7ff00000 TRACE"]
    DISPATCH --> L1["0x7ffc0000 LOG"]
    DISPATCH --> A1["0x7fff0000 ALLOW"]
    DISPATCH --> X1["anything else<br/>-> treated as KILL_PROCESS"]

    K1 --- ORDER["most restrictive"]
    A1 --- ORDER2["least restrictive"]

How a stack of filters becomes one verdict. What it shows: the bitmap fast path short-circuits before any program runs; otherwise every filter in the chain executes and the running minimum — compared as a signed 32-bit value over the top 16 bits — is the answer. The insight to take: this is the fact readers cannot hold in their heads, so hold onto the shape instead: a later filter can only ever tighten, never loosen. Installing a second filter that returns ALLOW for everything changes nothing, because ALLOW is the maximum and loses every comparison. There is no “override” and no “unfilter” — the only direction the stack moves is toward restriction.

What each action actually does at the dispatch point

__seccomp_filter() switches on the resolved action. Three of the branches have mechanics worth reading:

SECCOMP_RET_ERRNO clamps and negates:

case SECCOMP_RET_ERRNO:
	/* Set low-order bits as an errno, capped at MAX_ERRNO. */
	if (data > MAX_ERRNO)
		data = MAX_ERRNO;
	syscall_set_return_value(current, current_pt_regs(), -data, 0);
	goto skip;

MAX_ERRNO is 4095, so the 16-bit SECCOMP_RET_DATA is silently clamped. Returning SECCOMP_RET_ERRNO | 60000 does not produce errno == 60000; the value is silently clamped to 4095, which is not an assigned errno on Linux, so the caller sees a failure code no strerror() can name. The goto skip sets the return register and returns -1L, so the syscall handler is never entered.

SECCOMP_RET_TRAP rolls the registers back first:

case SECCOMP_RET_TRAP:
	/* Show the handler the original registers. */
	syscall_rollback(current, current_pt_regs());
	/* Let the filter pass back 16 bits of data. */
	force_sig_seccomp(this_syscall, data, false);
	goto skip;

syscall_rollback() restores the return register from the saved original (on x86-64, regs->ax = regs->orig_ax), so a SIGSYS handler sees the register state as it was at the trap and can emulate the call. The false argument means “do not force a coredump.”

The kill branch is where SECCOMP_MODE_DEAD is set, and where the coredump decision lives:

case SECCOMP_RET_KILL_THREAD:
case SECCOMP_RET_KILL_PROCESS:
default:
	current->seccomp.mode = SECCOMP_MODE_DEAD;
	seccomp_log(this_syscall, SIGSYS, action, true);
	/* Dump core only if this is the last remaining thread. */
	if (action != SECCOMP_RET_KILL_THREAD ||
	    (atomic_read(&current->signal->live) == 1)) {
		/* Show the original registers in the dump. */
		syscall_rollback(current, current_pt_regs());
		/* Trigger a coredump with SIGSYS */
		force_sig_seccomp(this_syscall, data, true);
	} else {
		do_exit(SIGSYS);
	}
	return -1;

Read the condition carefully: a KILL_THREAD in a multithreaded process takes the else branch and calls do_exit(SIGSYS) with no coredump; a KILL_THREAD in a single-threaded process (signal->live == 1) and every KILL_PROCESS take the coredump path. The man page dates the behaviour: “Before Linux 4.11, any process terminated in this way would not trigger a coredump… Since Linux 4.11, a single-threaded process will dump core if terminated in this way.” The same page adds the warning that follows from do_exit() on one thread of many: “the use of SECCOMP_RET_KILL_THREAD to kill a single thread in a multithreaded process is likely to leave the process in a permanently inconsistent and possibly corrupt state.” If you are choosing between the two kill actions and the target is threaded, KILL_PROCESS is almost always what you meant.

Note also that default: falls into the same branch — this is the code behind the “unknown actions are fatal” row of the table above.

Installation, Stacking and Inheritance

Installing a filter is a single call whose failure modes are worth enumerating, because most of them are checked before the filter is ever looked at.

fd = seccomp(SECCOMP_SET_MODE_FILTER, flags, &prog);   /* since Linux 3.17 */
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);     /* since Linux 3.5, no flags */

prog is a struct sock_fprog{ unsigned short len; struct sock_filter *filter; } — the same structure socket filters use. Under CONFIG_COMPAT, a 32-bit caller’s struct compat_sock_fprog is unpacked separately, with compat_ptr() applied to the filter pointer.

no_new_privs: the interlock, and why it is required

seccomp_prepare_filter() refuses to build a filter unless one of two conditions holds:

	/*
	 * Installing a seccomp filter requires that the task has
	 * CAP_SYS_ADMIN in its namespace or be running with no_new_privs.
	 * This avoids scenarios where unprivileged tasks can affect the
	 * behavior of privileged children.
	 */
	if (!task_no_new_privs(current) &&
			!ns_capable_noaudit(current_user_ns(), CAP_SYS_ADMIN))
		return ERR_PTR(-EACCES);

The comment states the threat in one line, but it repays unpacking, because “why does seccomp need no_new_privs?” is the most common confused question about the interface and the answer is not “hardening in general.”

Filters are inherited across execve(). no_new_privs — set by prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0), itself irreversible — guarantees that execve() will not grant the process privileges it does not already have: set-user-ID and set-group-ID bits are ignored, file capabilities are ignored, and an LSM cannot transition the process to a more privileged domain. Without that guarantee, an unprivileged attacker could:

  1. install a filter that makes some syscall a set-user-ID root helper depends on return a lie — say, SECCOMP_RET_ERRNO on the setuid() that drops privileges, or on a getuid() the helper uses to decide what to trust;
  2. execve() the set-user-ID binary, which inherits the filter and gains root;
  3. let the now-root program run with an attacker-chosen view of the kernel.

The filter is the attacker’s program running against a privileged process’s syscalls. no_new_privs closes it by making step 2 impossible: the exec’d binary does not gain privilege, so the attacker has only ever confined itself. The kernel documentation puts the same requirement more briefly: “This requirement ensures that filter programs cannot be applied to child processes with greater privileges than the task that installed them.”

ns_capable_noaudit(current_user_ns(), CAP_SYS_ADMIN) is the escape valve for a process that is already privileged enough that this cannot be an escalation. The noaudit variant is used so that a routine failed check does not spam the audit log.

The flags, and the two combinations the kernel rejects

FlagValueSinceEffect
SECCOMP_FILTER_FLAG_TSYNC1 << 03.17Apply the caller’s whole filter tree to every other thread in the process.
SECCOMP_FILTER_FLAG_LOG1 << 14.14Set filter->log, so every non-ALLOW action from this filter is audited.
SECCOMP_FILTER_FLAG_SPEC_ALLOW1 << 24.17Opt out of the Speculative Store Bypass mitigation that filter install otherwise turns on.
SECCOMP_FILTER_FLAG_NEW_LISTENER1 << 35.0Return a listener file descriptor for SECCOMP_RET_USER_NOTIF.
SECCOMP_FILTER_FLAG_TSYNC_ESRCH1 << 45.7On TSYNC failure, return -ESRCH instead of the offending thread ID.
SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV1 << 55.19Once a notification is received by the supervisor, the target ignores non-fatal signals.

The six install flags at v6.12, with the tag-diff dating for the two newest. What it shows: SECCOMP_FILTER_FLAG_MASK in include/linux/seccomp.h is exactly the OR of these six; anything outside it is -EINVAL. TSYNC_ESRCH appears 0 times in kernel/seccomp.c at v5.6 and 2 times at v5.7; WAIT_KILLABLE_RECV appears 0 times at v5.18 and 2 times at v5.19. The insight to take: SPEC_ALLOW is the odd one out — installing any filter normally calls arch_seccomp_spec_mitigate(), on the reasoning that a process bothering to confine itself probably wants the Speculative Store Bypass (Spectre variant 4) mitigation too; the man page describes the flag simply as “Disable Speculative Store Bypass mitigation.” It trades a real throughput win for a real side-channel exposure, and it is the only flag whose effect is invisible to the filter itself. See Speculation Barriers and Spectre Hardening at the Syscall Boundary.

Two flag combinations are refused outright, and the reasons are pure interface mechanics:

	/*
	 * In the successful case, NEW_LISTENER returns the new listener fd.
	 * But in the failure case, TSYNC returns the thread that died. If you
	 * combine these two flags, there's no way to tell whether something
	 * succeeded or failed. So, let's disallow this combination if the user
	 * has not explicitly requested no errors from TSYNC.
	 */
	if ((flags & SECCOMP_FILTER_FLAG_TSYNC) &&
	    (flags & SECCOMP_FILTER_FLAG_NEW_LISTENER) &&
	    ((flags & SECCOMP_FILTER_FLAG_TSYNC_ESRCH) == 0))
		return -EINVAL;

Both flags want to use the same positive return value for different purposes: a file descriptor on success, a thread ID on failure. TSYNC_ESRCH exists precisely to resolve the collision by making TSYNC report failures as -ESRCH, at which point a positive return is unambiguously a file descriptor. Separately, WAIT_KILLABLE_RECV without NEW_LISTENER is -EINVAL, since it configures behaviour that only exists when there is a supervisor.

A third rejection happens later: has_duplicate_listener() walks the existing filter chain and returns -EBUSY if any filter in it already has a notifier. One listener per filter tree, ever.

The length budget

seccomp_attach_filter() charges the new filter against a per-path budget before anything else:

	/* Validate resulting filter length. */
	total_insns = filter->prog->len;
	for (walker = current->seccomp.filter; walker; walker = walker->prev)
		total_insns += walker->prog->len + 4;  /* 4 instr penalty */
	if (total_insns > MAX_INSNS_PER_PATH)
		return -ENOMEM;

with

/* Limit any path through the tree to 256KB worth of instructions. */
#define MAX_INSNS_PER_PATH ((1 << 18) / sizeof(struct sock_filter))

sizeof(struct sock_filter) is 8, so MAX_INSNS_PER_PATH is 32768 instructions, and a single filter is separately capped at BPF_MAXINSNS = 4096. The + 4 per already-installed filter is a bookkeeping charge for the per-filter overhead, and the man page documents it verbatim: “Note that for the purposes of calculating this limit, each already existing filter program incurs an overhead penalty of 4 instructions.” Overrun and you get -ENOMEM, which is a confusing errno for “your policy is too long” but is what the ABI says.

Inheritance and the filter tree

The comment above struct seccomp_filter describes a data structure people usually picture wrongly:

 * seccomp_filter objects are organized in a tree linked via the @prev
 * pointer.  For any task, it appears to be a singly-linked list starting
 * with current->seccomp.filter, the most recently attached or inherited filter.
 * However, multiple filters may share a @prev node, by way of fork(), which
 * results in a unidirectional tree existing in memory.  This is similar to
 * how namespaces work.

Each task sees a list; the system holds a tree, because fork() gives the child a reference to the same chain and the child may then push filters of its own. Two reference counts keep it straight: refs governs memory lifetime, users governs whether any task can still reach the filter (dropping users to zero is what wakes a listener with EPOLLHUP). The comment notes the invariant: “The @users count is always smaller or equal to @refs. Hence, reaching 0 for @users does not mean the filter can be freed.”

fork() does the inheritance in copy_seccomp() (kernel/fork.c, v6.12), under the shared sighand->siglock:

	/* Ref-count the new filter user, and assign it. */
	get_seccomp_filter(current);
	p->seccomp = current->seccomp;
 
	/*
	 * Explicitly enable no_new_privs here in case it got set
	 * between the task_struct being duplicated and holding the
	 * sighand lock. The seccomp state and nnp must be in sync.
	 */
	if (task_no_new_privs(current))
		task_set_no_new_privs(p);
 
	/*
	 * If the parent gained a seccomp mode after copying thread
	 * flags and between before we held the sighand lock, we have
	 * to manually enable the seccomp thread flag here.
	 */
	if (p->seccomp.mode != SECCOMP_MODE_DISABLED)
		set_task_syscall_work(p, SECCOMP);

Both of the “in case it got set between” comments are guarding the same race: dup_task_struct() copies the thread flags early in fork(), and the parent may install a filter after that copy but before copy_seccomp() takes the lock. The fix is to re-derive both the no_new_privs bit and the SYSCALL_WORK_SECCOMP bit from the parent’s current state while holding the lock, rather than trusting the earlier copy.

execve() needs no equivalent code at all — it does not touch task->seccomp, so the filter simply survives. That is what the documentation means by “any child processes will be constrained to the same filters and system call ABI as the parent.”

TSYNC and the publication barrier

SECCOMP_FILTER_FLAG_TSYNC exists because seccomp state is per-thread, not per-process, and a filter installed by one thread of a threaded program leaves its siblings unconfined. TSYNC is a two-phase operation: seccomp_can_sync_threads() validates that every other thread is either seccomp-disabled or running a filter tree that is an ancestor of the caller’s (checked by is_ancestor() walking prev pointers), and only then does seccomp_sync_threads() point them all at the caller’s chain with smp_store_release(). If any thread cannot be synced, nothing is attached and the call fails with that thread’s PID — or -ESRCH with TSYNC_ESRCH. The whole operation holds signal->cred_guard_mutex, with an explicit purpose: “Make sure we cannot change seccomp or nnp state via TSYNC while another thread is in the middle of calling exec.”

TSYNC also propagates no_new_privs, with a comment naming the attack it prevents: “Don’t let an unprivileged task work around the no_new_privs restriction by creating a thread that sets it up, enters seccomp, then dies.”

Finally, seccomp_assign_mode() publishes the new state in a specific order, and this is a memory-ordering subtlety a casual reading skips:

	task->seccomp.mode = seccomp_mode;
	/*
	 * Make sure SYSCALL_WORK_SECCOMP cannot be set before the mode (and
	 * filter) is set.
	 */
	smp_mb__before_atomic();
	if ((flags & SECCOMP_FILTER_FLAG_SPEC_ALLOW) == 0)
		arch_seccomp_spec_mitigate(task);
	set_task_syscall_work(task, SECCOMP);

The SYSCALL_WORK_SECCOMP bit is what causes other CPUs to start calling into seccomp, so it must become visible last. The matching barrier is the first statement of __seccomp_filter():

	/*
	 * Make sure that any changes to mode from another thread have
	 * been seen after SYSCALL_WORK_SECCOMP was seen.
	 */
	smp_rmb();

Without the pair, a thread being TSYNC’d could observe its work bit set while still seeing a stale mode of SECCOMP_MODE_DISABLED, and __secure_computing() would hit default: BUG().

stateDiagram-v2
    direction TB

    state "Thread: unconfined" as U
    state "Thread: filter stack of depth N" as F
    state "Child task after fork()/clone()" as C
    state "Same task after execve()" as X
    state "Sibling thread after TSYNC" as S

    [*] --> U
    U --> F : seccomp(SET_MODE_FILTER)<br/>filter.prev = NULL<br/>mode := FILTER, then set work bit
    F --> F : seccomp(SET_MODE_FILTER) again<br/>filter.prev = old head<br/>depth N+1, budget charged +4 per filter

    F --> C : fork() / clone()<br/>copy_seccomp(): refcount the chain,<br/>copy mode, re-derive nnp and work bit
    C --> C : child may push its own filters, at which<br/>point the chain becomes a TREE (shared prev)

    F --> X : execve()<br/>NOTHING happens to task->seccomp<br/>the stack simply persists

    F --> S : TSYNC<br/>can_sync (ancestor check) then<br/>smp_store_release into each sibling

    note right of F
      No transition ever removes a filter.
      There is no unfilter operation and no
      way back to "unconfined" for this task.
      Depth is bounded only by
      MAX_INSNS_PER_PATH = 32768 instructions.
    end note

    note right of C
      Inheritance is by reference, not by copy:
      parent and child run the SAME bpf_prog
      objects. refs guards memory lifetime;
      users guards reachability and drives
      the listener's EPOLLHUP.
    end note

Filter installation and how the stack propagates across fork and execve. What it shows: four ways a task acquires filters — direct install, stacking, inheritance at fork, and TSYNC from a sibling — and exactly zero ways to lose them. The insight to take: execve() has no arrow doing anything, and that is the point: seccomp survives exec because nothing in exec touches it. A confined process therefore cannot escape by spawning a fresh child, by re-execing itself, or by loading a different program — which is what makes “install the filter, then jump into untrusted code” a sound pattern rather than wishful thinking.

What a Filter Stack Costs, and the Constant-Action Bitmap

Because every filter in the stack runs on every syscall with no early exit, the cost of seccomp is O(depth × program length) per system call, paid on the hottest path in the kernel. Corbet’s framing of the problem is precise (LWN, October 2020):

many real-world use cases do not take advantage of this capability; instead, they make decisions based only on which system call is being invoked while paying no attention to the arguments to those calls. It turns out that the BPF mechanism is far from optimal for this case, which must be implemented as a long series of comparisons against the system-call number… Much of this work is wasted. If a seccomp() configuration of this type allows read() once, it will allow it every time, but the kernel must work it out the hard way each time regardless.

A typical container allowlist is several hundred JEQ comparisons against nr, walked linearly. Reordering by frequency helps; it does not change the shape.

Linux 5.11 added a fast path for exactly this case. Dating by tag: seccomp_cache_prepare, seccomp_is_const_allow and struct action_cache appear zero times in kernel/seccomp.c at v5.10 and 7, 3 and 7 times respectively at v5.11. The mechanism has three parts.

Part one: a per-filter bitmap, one bit per syscall number, per architecture.

struct action_cache {
	DECLARE_BITMAP(allow_native, SECCOMP_ARCH_NATIVE_NR);
#ifdef SECCOMP_ARCH_COMPAT
	DECLARE_BITMAP(allow_compat, SECCOMP_ARCH_COMPAT_NR);
#endif
};

On x86-64 that is NR_syscalls bits for AUDIT_ARCH_X86_64 plus IA32_NR_syscalls bits for AUDIT_ARCH_I386 — a few hundred bytes per filter, embedded directly in struct seccomp_filter. arm64 defines the same pair over AUDIT_ARCH_AARCH64 and AUDIT_ARCH_ARM. Only an allow bitmap exists; as Corbet notes of the merged design, “only the ‘allow’ bitmap is implemented on the understanding that the ‘deny’ cases do not really need to be optimized” — a denied syscall is by definition off the hot path.

Part two: a partial cBPF emulator that runs at install time, once per syscall number.

The kernel must decide, for each nr, whether the filter’s verdict depends on anything other than nr and arch. seccomp_is_const_allow() answers that by interpreting the program with nr and arch bound to constants, and bailing out the moment it meets an instruction whose result it cannot know:

		switch (code) {
		case BPF_LD | BPF_W | BPF_ABS:
			switch (k) {
			case offsetof(struct seccomp_data, nr):
				reg_value = sd->nr;
				break;
			case offsetof(struct seccomp_data, arch):
				reg_value = sd->arch;
				break;
			default:
				/* can't optimize (non-constant value load) */
				return false;
			}
			break;
		case BPF_RET | BPF_K:
			/* reached return with constant values only, check allow */
			return k == SECCOMP_RET_ALLOW;
		case BPF_JMP | BPF_JA:
			pc += insn->k;
			break;
		/* JEQ / JGE / JGT / JSET against a constant, and BPF_ALU|BPF_AND|BPF_K */
		default:
			/* can't optimize (unknown insn) */
			return false;
		}

The emulator understands exactly six things: an absolute load of nr, an absolute load of arch, an unconditional jump, the four constant-compare jumps, AND with an immediate, and a constant return. Any load of args[] or of instruction_pointer returns false immediately, and so does any instruction outside that set. That is the whole argument-independence test, and it is deliberately conservative: a filter the emulator cannot follow is simply not cached, never mis-cached.

This design was not the first attempt. Kees Cook’s original June 2020 patch determined argument-independence by “placing the arguments in a separate page, running the BPF code, then looking at the page-table entry to see whether the page had been referenced or not” — which worked but “relied on some complex memory-management trickery.” Jann Horn suggested the emulator instead, with the key observation that “the emulator need not be complete, since programs that only compare system-call numbers tend to be quite simple. Only a small subset of the available instructions would need to be emulated; anything that the emulator does not recognize can be taken as an indication that more complex logic is involved and the bitmap cannot be used.” The merged code is that suggestion, almost literally.

Part three: the bitmap is built monotonically down the stack.

	if (bitmap_prev) {
		/* The new filter must be as restrictive as the last. */
		bitmap_copy(bitmap, bitmap_prev, bitmap_size);
	} else {
		/* Before any filters, all syscalls are always allowed. */
		bitmap_fill(bitmap, bitmap_size);
	}
 
	for (nr = 0; nr < bitmap_size; nr++) {
		/* No bitmap change: not a cacheable action. */
		if (!test_bit(nr, bitmap))
			continue;
		sd.nr = nr; sd.arch = arch;
		/* No bitmap change: continue to always allow. */
		if (seccomp_is_const_allow(fprog, &sd))
			continue;
		__clear_bit(nr, bitmap);
	}

A new filter starts from its predecessor’s bitmap and can only clear bits, never set them — which is the bitmap-level expression of the same “later filters can only tighten” rule the min() implements at runtime. Bits already cleared are skipped without even running the emulator. The result is that the newest filter’s bitmap is the answer for the entire stack, which is why seccomp_run_filters() consults seccomp_cache_check_allow(f, sd) on the head filter only, before entering the loop.

flowchart TB
    subgraph INSTALL["at seccomp(SET_MODE_FILTER) time — once"]
      P0["previous filter's bitmap<br/>(or all-ones if this is the first)"]
      P0 --> ITER["for nr in 0..NR_syscalls-1"]
      ITER --> B0{"bit already clear?"}
      B0 -->|"yes"| SKIP["skip — a predecessor<br/>already made it argument-dependent<br/>or non-allow"]
      B0 -->|"no"| EMU["seccomp_is_const_allow(prog, {nr, arch})<br/>partial cBPF emulator"]
      EMU -->|"returns ALLOW with<br/>only nr/arch loads"| KEEP["leave the bit set"]
      EMU -->|"loads args[] or IP,<br/>or any unknown insn,<br/>or returns non-ALLOW"| CLR["__clear_bit(nr)"]
      SKIP --> DONE0["bitmap stored in<br/>filter->cache"]
      KEEP --> DONE0
      CLR --> DONE0
    end

    subgraph RUNTIME["on every syscall — the hot path"]
      H["f = current->seccomp.filter (newest)"]
      H --> AR{"sd->arch == SECCOMP_ARCH_NATIVE?"}
      AR -->|"yes"| BN["test_bit(nr, cache->allow_native)"]
      AR -->|"no, == SECCOMP_ARCH_COMPAT"| BC["test_bit(nr, cache->allow_compat)"]
      AR -->|"neither<br/>(e.g. x32: nr has bit 30 set,<br/>nr >= bitmap_size)"| OOR["out of range -> false"]
      BN --> HIT{"bit set?"}
      BC --> HIT
      OOR --> MISS
      HIT -->|"yes"| FAST["return SECCOMP_RET_ALLOW<br/>ZERO filter programs run"]
      HIT -->|"no"| MISS["run the whole filter stack,<br/>take min() as usual"]
    end

    DONE0 -.->|"consulted by"| H

The constant-action bitmap, built at install time and consulted on every syscall. What it shows: all the work happens once, at seccomp() time, by emulating the filter against every possible syscall number; the runtime path collapses to a bounds check and a test_bit(). The insight to take: the optimisation is only reachable for filters whose verdict is a pure function of (nr, arch) — the moment a rule inspects an argument, that syscall number falls off the fast path for the rest of the stack’s life. Argument-inspecting rules are not free, and they are not free only for the syscalls they mention. Note also that the index is bounds-checked with array_index_nospec() before test_bit(), because nr is attacker-controlled and this is a speculation gadget otherwise.

The bitmap is inspectable when the kernel is built with CONFIG_SECCOMP_CACHE_DEBUG, which exposes /proc/<pid>/seccomp_cache in the format <arch-name> <nr> ALLOW|FILTER, one line per syscall number per architecture. Reading it requires CAP_SYS_ADMIN in the initial user namespace (file_ns_capable(m->file, &init_user_ns, CAP_SYS_ADMIN)), and the Kconfig help text explains why the option is off by default: “This option is for debugging only. Enabling presents the risk that an adversary may be able to infer the seccomp filter logic.” It also depends on !HAVE_SPARSE_SYSCALL_NR — an architecture whose syscall numbers are sparse (MIPS, notably) cannot use a dense bitmap indexed by number at all, and gets neither the cache nor the debug file.

One consequence worth carrying to the architecture section below: on x86-64, an x32 call arrives with __X32_SYSCALL_BIT (0x40000000) set in nr, which is far larger than SECCOMP_ARCH_NATIVE_NR, so seccomp_cache_check_allow_bitmap() fails its range check and returns false. The header says so in as many words: “x32 will have __X32_SYSCALL_BIT set in syscall number. We don’t support caching them and they are treated as out of range syscalls, which will always pass through the BPF filter.” Safe, and slow, and deliberate.

The arch Field, and Why the Number Alone Is Not Enough

The kernel documentation’s Pitfalls section is one paragraph and names one hazard: “The biggest pitfall to avoid during use is filtering on system call number without checking the architecture value… Always check the arch value!” The mechanism behind that advice, and the place it stops being sufficient, both belong here; the policy consequences and the record of real sandbox escapes belong to Seccomp and seccomp-BPF, which treats them at length.

Mechanically there are two layers.

Layer one — one kernel, several numbering schemes. A 64-bit kernel built with CONFIG_IA32_EMULATION executes 32-bit int 0x80 calls from any process, and the i386 table is a different numbering: number 4 is write on i386 and stat on x86-64. syscall_get_arch() distinguishes them, so pinning arch closes this layer. See The Compat Syscall Layer for 32-bit Binaries and System Call Numbers and the ABI.

Layer two — x32 shares arch with x86-64. Read the accessor (arch/x86/include/asm/syscall.h, v6.12):

static inline int syscall_get_arch(struct task_struct *task)
{
	/* x32 tasks should be considered AUDIT_ARCH_X86_64. */
	return (IS_ENABLED(CONFIG_IA32_EMULATION) &&
		task->thread_info.status & TS_COMPAT)
		? AUDIT_ARCH_I386 : AUDIT_ARCH_X86_64;
}

An x32 caller and an x86-64 caller present the same arch value. What separates them is a bit inside nr: __X32_SYSCALL_BIT is 0x40000000. And the reason the filter sees that bit is the entry-path ordering this note started with — do_syscall_64() passes the raw nr into syscall_enter_from_user_mode() and only afterwards tries the two dispatch tables (arch/x86/entry/common.c, v6.12):

__visible noinstr bool do_syscall_64(struct pt_regs *regs, int nr)
{
	add_random_kstack_offset();
	nr = syscall_enter_from_user_mode(regs, nr);   /* <-- seccomp runs in here */
 
	instrumentation_begin();
	if (!do_syscall_x64(regs, nr) && !do_syscall_x32(regs, nr) && nr != -1) {
		/* Invalid system call, but still a system call. */
		regs->ax = __x64_sys_ni_syscall(regs);
	}
	...
}

do_syscall_x64() treats nr as unsigned and fails the unr < NR_syscalls test for anything with bit 30 set; do_syscall_x32() then subtracts __X32_SYSCALL_BIT and succeeds. So a value that compares unequal to every plain number a filter enumerated still reaches a working handler. The seccomp(2) man page states the rule that follows: “a policy must either deny all syscalls with __X32_SYSCALL_BIT or it must recognize syscalls with and without __X32_SYSCALL_BIT set. A list of system calls to be denied based on nr that does not also contain nr values with __X32_SYSCALL_BIT set can be bypassed by a malicious program that sets __X32_SYSCALL_BIT.”

ABI in use on an x86-64 kernelseccomp_data.archseccomp_data.nrWhich dispatcher serves itOn the bitmap fast path?
x86-64 nativeAUDIT_ARCH_X86_64 = 0xC000003Eplain number from syscall_64.tbldo_syscall_x64()yes — cache->allow_native
i386 compat (int 0x80, or a 32-bit process)AUDIT_ARCH_I386 = 0x40000003number from the i386 tablethe compat dispatcheryes — cache->allow_compat
x32AUDIT_ARCH_X86_64 = 0xC000003Ethe same value0x40000000 | x32 indexdo_syscall_x32(), after subtracting the bitno — out of bitmap range, always runs the filters

What a filter is actually handed for each of the three ABIs an x86-64 kernel serves. What it shows: two of the three rows carry the identical arch value, and the only thing separating them is bit 30 of nr. The insight to take: arch answers “which numbering table?” and it answers it wrongly for x32, because x32 reuses the x86-64 audit identity by deliberate kernel decision (syscall_get_arch()’s own comment says so). A filter that tests arch and then compares nr against plain numbers has checked the field the documentation told it to check and is still incomplete. The defences, and the escapes that resulted from skipping them, are in Seccomp and seccomp-BPF.

Two mechanism-side notes to carry away. First, the filter’s view is genuinely pre-dispatch: it sees the number the hardware delivered, not the number the kernel will end up using, which is why nr can hold a value no entry in sys_call_table corresponds to. Second, as the previous section showed, these numbers are also out of range for the constant-action bitmap, so x32 traffic always takes the slow path — a small, permanent performance asymmetry that follows from the same fact.

SECCOMP_RET_USER_NOTIF — The Escape Hatch, and Its Own Races

Everything above says a filter cannot dereference a pointer. SECCOMP_RET_USER_NOTIF (Linux 5.0) is the kernel’s answer to “then how do I ever make a decision that depends on one?” — and it is important to present it honestly, because it is routinely described as though it fixed the limitation. It did not. It moved it.

The mechanism, from the kernel side: a filter installed with SECCOMP_FILTER_FLAG_NEW_LISTENER causes seccomp() to return a listener file descriptor. When such a filter returns USER_NOTIF, __seccomp_filter() calls seccomp_do_user_notification(), which builds a struct seccomp_knotif on the notifying thread’s own kernel stack, appends it to the filter’s notification list, wakes anything polling the listener, and then blocks:

	do {
		bool wait_killable = should_sleep_killable(match, &n);
 
		mutex_unlock(&match->notify_lock);
		if (wait_killable)
			err = wait_for_completion_killable(&n.ready);
		else
			err = wait_for_completion_interruptible(&n.ready);
		mutex_lock(&match->notify_lock);
		...
	}  while (n.state != SECCOMP_NOTIFY_REPLIED);

Because the seccomp_knotif is stack-allocated in the blocked thread, the struct seccomp_data pointer it carries stays valid for exactly as long as the notification is outstanding — the comment says so: “This pointer is valid the entire time this notification is active, since it comes from __seccomp_filter which eclipses the entire lifecycle here.” The notification moves through three states, INIT → SENT → REPLIED, and reverts from SENT to INIT if a signal interrupts the wait, causing the message to be re-delivered.

On reply, the kernel either sets the return value or lets the call proceed:

	/* Userspace requests to continue the syscall. */
	if (flags & SECCOMP_USER_NOTIF_FLAG_CONTINUE)
		return 0;
 
	syscall_set_return_value(current, current_pt_regs(), err, ret);
	return -1;

That two-line branch is where the TOCTOU comes back. Returning 0 means “do not skip” — control falls out of __seccomp_filter() back into syscall_trace_enter(), which returns the syscall number, and the handler runs with whatever is in the target’s memory at that moment. The uapi header carries an unusually blunt warning right above the flag definition:

 * Note, the SECCOMP_USER_NOTIF_FLAG_CONTINUE flag must be used with caution!
 * ...This is problematic because of an inherent TOCTOU.
 * An attacker can exploit the time while the supervised process is waiting on
 * a response from the supervising process to rewrite syscall arguments which
 * are passed as pointers of the intercepted syscall.
 * It should be absolutely clear that this means that the seccomp notifier
 * _cannot_ be used to implement a security policy!
sequenceDiagram
    autonumber
    participant T as Target thread
    participant T2 as Target's other thread
    participant K as Kernel (seccomp)
    participant S as Supervisor process
    participant M as /proc/tid/mem

    T->>K: mount("/dev/sdX", "/mnt", ...)
    K->>K: filter returns SECCOMP_RET_USER_NOTIF
    K->>K: build seccomp_knotif on T's kernel stack<br/>state = INIT, id from filter.notif.next_id++
    K->>S: listener fd becomes readable (EPOLLIN)
    K-->>T: block in wait_for_completion_interruptible()
    S->>K: ioctl(SECCOMP_IOCTL_NOTIF_RECV), state becomes SENT
    Note over S: The notif carries seccomp_data:<br/>pointer VALUES, not pointed-to bytes.
    S->>M: open("/proc/<pid>/mem")
    S->>K: ioctl(SECCOMP_IOCTL_NOTIF_ID_VALID, &id)
    Note over S,K: guards PID reuse: the tid in the notif<br/>may name a different task by now
    S->>M: read the pathname bytes
    S->>K: ioctl(SECCOMP_IOCTL_NOTIF_ID_VALID, &id)
    Note over S,K: guards the SECOND race: T may have taken a<br/>signal, aborted the syscall, and reused that stack
    alt supervisor performs the action itself
        S->>S: mount() on the target's behalf
        S->>K: NOTIF_SEND { id, val, error, flags = 0 }
        K->>T: syscall_set_return_value(), then skip the syscall
    else supervisor says "let it run"
        S->>K: NOTIF_SEND { flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE }
        T2->>T2: rewrite the pathname buffer (the race)
        K->>K: return 0, so the real mount() runs
        Note over K,T2: The kernel re-reads the pointer.<br/>The supervisor's decision was about<br/>different bytes. THIS is why CONTINUE<br/>is not a security mechanism.
    end

The user-notification round trip, with both of its documented races marked. What it shows: the supervisor genuinely can do what a filter cannot — read the target’s memory through /proc/<pid>/mem — but every read must be bracketed by SECCOMP_IOCTL_NOTIF_ID_VALID checks, and the CONTINUE reply reopens the exact TOCTOU window seccomp’s no-pointer rule exists to close. The insight to take: USER_NOTIF is a syscall-emulation channel for a privileged helper, not a policy engine. Use it where the supervisor performs the operation and returns a result; treat CONTINUE as an optimisation that is only safe when some other mechanism — the kernel’s own permission checks, an LSM — will independently reject an unsafe rewrite.

The seccomp_unotify(2) man page documents a second race that catches supervisors which have already handled PID reuse correctly. Its worked scenario: the supervisor opens /proc/tid/mem and passes ID_VALID; the target then takes a signal that aborts its mount(); the signal handler runs and returns; the interrupted function returns, and later calls overwrite the stack frame that held the pathname; the supervisor now reads those recycled bytes and acts on them. The conclusion is stated as a rule:

since the target’s blocked system call may be interrupted by a signal handler, the supervisor must be written to expect that the target may abandon its system call at any time; in such an event, any information that the supervisor obtained from the target’s memory must be considered invalid. To prevent such scenarios, every read from the target’s memory must be separated from use of the bytes so obtained by a SECCOMP_IOCTL_NOTIF_ID_VALID check.

And, one sentence later, the harder rule: “it should be clear that a write by the supervisor into the target’s memory can never be considered safe.”

Two further mechanism facts that constrain what can be built:

  • SECCOMP_IOCTL_NOTIF_ADDFD (Linux 5.9) lets the supervisor install a file descriptor directly into the target — receive_fd(), or receive_fd_replace() when SECCOMP_ADDFD_FLAG_SETFD names a specific number, in which case an already-open descriptor at that number is replaced. With SECCOMP_ADDFD_FLAG_SEND the install and the reply are one atomic step, and the injected descriptor number becomes the syscall’s return value. This is how a supervisor emulates openat() or socket() without ever letting the target’s own call run — and it is the safe pattern, precisely because nothing is re-read after the decision.
  • A notifier can be overridden by a newer filter. The uapi header spells out the stacking hazard: “For SECCOMP_RET_USER_NOTIF filters acting on the same syscall, the most recently added filter takes precedence. This means that the new SECCOMP_RET_USER_NOTIF filter can override any SECCOMP_IOCTL_NOTIF_SEND from earlier filters, essentially allowing all such filtered syscalls to be executed by sending the response SECCOMP_USER_NOTIF_FLAG_CONTINUE.” The man page draws the operational conclusion: “a user-space notifier can be bypassed if the existing filters allow the use of seccomp(2) or prctl(2) to install a filter that returns an action value with a higher precedence.” If your policy relies on a supervisor, your policy must also deny seccomp and prctl.

The supervisor-side protocol in full — the receive/inspect/respond loop, the poll() semantics, a worked libseccomp implementation — is seccomp User Notification’s subject and is not repeated here.

Failure Modes and Common Misunderstandings

SymptomMechanical causeWhere it is decided
seccomp() returns EACCESNeither no_new_privs nor CAP_SYS_ADMIN in the caller’s user namespaceseccomp_prepare_filter(), before either BPF verifier
seccomp() returns EINVAL on a filter that “looks fine”len == 0 or > BPF_MAXINSNS (4096); a BPF_ABS offset unaligned or >= 64; an opcode outside seccomp_check_filter()’s allowlistseccomp_prepare_filter() / bpf_check_classic() / seccomp_check_filter()
seccomp() returns ENOMEM with plenty of RAMStack exceeds MAX_INSNS_PER_PATH (32768), counting +4 per existing filterseccomp_attach_filter()
seccomp() returns EBUSYA second filter with NEW_LISTENER — one listener per tree, everhas_duplicate_listener()
A rule on open never firesglibc ≥ 2.26 calls openat; exit() calls exit_group; fork() calls clonenot the kernel — the libc wrapper
A rule on clock_gettime never firesServed from the vDSO without trappingnever reaches the entry path
A denylist is bypassed on x86-64__X32_SYSCALL_BIT set in nr compares unequal to every plain numberdo_syscall_x32() after seccomp has already voted
A 64-bit argument check is bypassedcBPF loads are 32 bits; only the low half was comparedthe filter’s own logic
errno is not the value the filter returnedSECCOMP_RET_DATA clamped to MAX_ERRNO (4095)__seccomp_filter(), SECCOMP_RET_ERRNO branch
Process dies with no core dumpKILL_THREAD in a multithreaded process takes the do_exit(SIGSYS) branch__seccomp_filter(), kill branch
Process dies on a syscall no rule mentionsAn action value the kernel does not recognise falls through default: to KILL_PROCESS__seccomp_filter(), default:

A symptom-to-mechanism index for the failure modes discussed below. What it shows: which of the mechanisms in this note each common complaint actually lands in — three of the eleven are not kernel behaviour at all. The insight to take: the errno alone tells you which stage rejected you, and the stages are strictly ordered — permission check, then structural verification, then opcode allowlist, then budget, then listener uniqueness. An EACCES therefore says nothing about whether your cBPF is well-formed, because the filter was never examined.

“seccomp is not re-run after a SECCOMP_RET_TRACE tracer changes the syscall.” This was true, is widely repeated, and is false on any kernel since Linux 4.8. The in-tree documentation still asserts it — seccomp_filter.rst at v6.12 says “The seccomp check will not be run again after the tracer is notified. (This means that seccomp-based sandboxes MUST NOT allow use of ptrace…)” — but the code beside it does re-run the filters:

	/* Check if the tracer forced the syscall to be skipped. */
	this_syscall = syscall_get_nr(current, current_pt_regs());
	if (this_syscall < 0)
		goto skip;
 
	/*
	 * Recheck the syscall, since it may have changed. This
	 * intentionally uses a NULL struct seccomp_data to force
	 * a reload of all registers. This does not goto skip since
	 * a skip would have already been reported.
	 */
	if (__seccomp_filter(this_syscall, NULL, true))
		return -1;

The recheck_after_trace parameter that guards the recursion appears 0 times in kernel/seccomp.c at v4.7 and 3 times at v4.8. The recursive call deliberately passes NULL for the seccomp_data, forcing populate_seccomp_data() to re-read every register, so the filters judge the tracer’s rewritten call; recheck_after_trace == true makes the second evaluation return immediately if it also lands on TRACE, which is what stops the recursion from being unbounded. The seccomp(2) man page is the up-to-date source here: “Before Linux 4.8, the seccomp check will not be run again after the tracer is notified.” The kernel documentation is the stale one, and this note previously repeated its claim as current — corrected on 2026-09-04. The security reading of the change, including why it does not make ptrace safe to allow in a sandbox, is in Seccomp and seccomp-BPF.

EACCES from seccomp() and no idea why. An unprivileged caller must set prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) first. The check happens in seccomp_prepare_filter() before either BPF verifier runs, so the error tells you nothing about your filter.

ENOMEM from seccomp() when memory is plentiful. The filter stack exceeded MAX_INSNS_PER_PATH (32768 instructions, counting a 4-instruction penalty per already-installed filter). EINVAL with a well-formed-looking program usually means the single-filter cap BPF_MAXINSNS (4096), a BPF_ABS offset that is unaligned or >= 64, or an opcode outside seccomp_check_filter()’s allowlist.

Filtering open and finding it never fires. “Starting in glibc 2.26, the implementation switched to calling openat(2) on all architectures,” per the man page’s Caveats, which also notes that exit(2) calls exit_group(2) and fork(2) calls clone(2). seccomp filters system-call numbers; your program calls libc wrappers. Those are different things and the mapping moves between glibc versions and between architectures. See libc Syscall Wrappers and errno Translation.

Filtering clock_gettime and finding it never fires. It is served from the vDSO without trapping, so it never reaches the checkpoint. The documentation’s suggested test is to force the slow path: set /sys/devices/system/clocksource/clocksource0/current_clocksource to something like acpi_pm and re-run, because “there are cases where the vDSO implementations may fall back to invoking the true system call.” A filter that is correct on your laptop and wrong on a VM with a different clocksource is this bug.

Checking only the low 32 bits of a 64-bit argument. cBPF loads are 32 bits, args[n] is 64 bits, and a rule that compares only offset 16 + 8n leaves the upper half unconstrained. Every 64-bit argument comparison is two loads and two jumps.

Assuming SECCOMP_RET_ERRNO data is a full 16-bit field. It is clamped to MAX_ERRNO (4095).

Assuming a second filter can loosen the first. It cannot; see the precedence section. There is also no way to remove a filter, so a “test mode” built by installing a permissive filter over a strict one does not exist. Build the permissive one first, or use SECCOMP_RET_LOG and read the audit log.

vsyscall emulation on x86-64 is a genuine oddity documented in seccomp_filter.rst: emulated vsyscalls do honour seccomp, but a SECCOMP_RET_TRAP sets si_call_addr to the vsyscall entry rather than the address after a syscall instruction, and under SECCOMP_RET_TRACE “the syscall may not be changed to another system call using the orig_rax register. It may only be changed to -1 in order to skip the currently emulated call.” The documentation gives a detection test — addr & ~0x0C00 == 0xFFFFFFFFFF600000 — and notes that “modern systems are unlikely to use vsyscalls at all.” See The vsyscall Legacy Mechanism.

Alternatives — Other Things Attached to the Same Chokepoint

seccomp is one of six work items on the syscall-entry path. Comparing them at the mechanism level makes clear why they are not substitutes for one another.

MechanismRuns atSeesCan change the call?In-kernel?Security boundary?
seccomp filterentry, 3rdseccomp_data only — registers, no memorySkip, fake an errno, kill, delegateYes — cBPF translated to eBPF, often JIT’dYes. Irreversible, inherited, unprivileged-safe via no_new_privs.
Syscall User Dispatchentry, 1stFull registers; the target handles itDiverts to a userspace handler by IP rangeNo — signal to the process itselfNo. The process flips its own selector byte in userspace. Built for Wine-style emulators.
ptrace and Syscall Tracingentry, 2ndEverything, including memory via PTRACE_PEEKDATA and /proc/pid/memRewrite nr, args, return valueNo — two context switches per stopOnly with care. Slow, TOCTOU-prone, and the tracer can die.
Syscall Tracepoints sys_enter and sys_exitentry, 4thRegisters; BPF programs attached here can read memoryNo (observability; a BPF program may alter nr, which the entry code re-reads)YesNo. Observation, not enforcement.
Syscall auditentry, 5thNumber and first four argsNoYesNo. Record-keeping.
LSM hooks (SELinux, AppArmor, Landlock)inside the handlers, after arguments are copied inFully resolved kernel objects — inodes, sockets, pathsDeny with an errnoYesYes, and on a different axis: objects, not syscall numbers.

The six interception points and what each can actually do. What it shows: seccomp’s position — early, cheap, in-kernel, register-only — is what gives it both its safety properties and its blind spot. The insight to take: the last row is the complement, not the competitor. seccomp answers “may this thread call openat at all?”; an LSM answers “may this thread open this file?” A filter cannot answer the second because at entry time the pathname is still a userspace pointer; an LSM cannot answer the first cheaply because by the time it runs, the syscall has already been entered. Real sandboxes stack them — see Landlock vs seccomp vs Namespaces.

The most common category error is treating seccomp as a sandbox on its own. The kernel documentation heads an entire section “What it isn’t” and answers it directly: “System call filtering isn’t a sandbox. It provides a clearly defined mechanism for minimizing the exposed kernel surface. It is meant to be a tool for sandbox developers to use. Beyond that, policy for logical behavior and information flow should be managed with a combination of other system hardening techniques and, potentially, an LSM of your choosing.” It then warns that expressive filters “could be construed, incorrectly, as a more complete sandboxing solution.”

Production Notes — Inspecting the Mechanism on a Live System

Five places the mechanism is observable, all of them useful when a filter is misbehaving and none of them requiring you to guess:

  • /proc/<pid>/status, field Seccomp: — the raw current->seccomp.mode value: 0 disabled, 1 strict, 2 filter. prctl(PR_GET_SECCOMP) returns the same number to the process itself (prctl_get_seccomp() is literally return current->seccomp.mode;). Note that a task killed by a filter transiently holds mode 3, but you will not observe it.
  • /proc/sys/kernel/seccomp/actions_avail (Linux 4.14) — a read-only, precedence-ordered list of the action names this kernel supports, “from least permissive return value to most.” This is how a userspace library discovers that the kernel it is running on is older than the header it was compiled against. seccomp(SECCOMP_GET_ACTION_AVAIL, 0, &action) tests a single action and returns EOPNOTSUPP if unsupported. Both exist because the fallback for an unknown action is killing the process.
  • /proc/sys/kernel/seccomp/actions_logged (Linux 4.14) — writable; controls which actions may reach the audit log. allow is rejected with EINVAL because SECCOMP_RET_ALLOW is never logged. Note the escape clause the man page adds: this file “does not prevent certain filter return actions from being logged when the audit subsystem is configured to audit a task” — for anything other than ALLOW, the audit subsystem gets the final say.
  • /proc/<pid>/seccomp_cache — the constant-action bitmap, if the kernel was built with CONFIG_SECCOMP_CACHE_DEBUG; one <arch> <nr> ALLOW|FILTER line per syscall per architecture. This is the direct answer to “is my filter getting the fast path?” It is off by default and needs CAP_SYS_ADMIN in the initial user namespace, for the reason the Kconfig gives: it leaks filter logic to anyone who can read it.
  • ptrace(PTRACE_SECCOMP_GET_FILTER, ...) (Linux 4.4) — dumps a task’s installed filters as cBPF, which is how checkpoint/restore tooling round-trips them and how you confirm that the filter a container runtime says it installed is the one that is actually there. It requires CONFIG_CHECKPOINT_RESTORE; the same config gates the save_orig flag in seccomp_prepare_filter() that keeps the original classic-BPF text alive after translation — as does SECCOMP_ARCH_NATIVE, since the bitmap emulator needs the untranslated program too.

Two practical mechanism notes. First, SECCOMP_RET_LOG is the discovery tool: run the program under a filter that logs instead of denying, read the audit records, and build the real syscall set from evidence rather than from a guess. The documentation says exactly this — it “should be used by application developers to learn which syscalls their application needs without having to iterate through multiple test and development cycles.” Second, almost nobody writes the cBPF by hand. libseccomp compiles a rule set into the instruction subset described above, handling the arch check, the __X32_SYSCALL_BIT question and the 64-bit-argument split for you; the man page recommends it in as many words. Writing a seccomp Filter covers both paths.

See Also