Seccomp and seccomp-BPF
seccomp (“secure computing”) is the Linux kernel’s primitive for letting a process shrink the slice of the kernel it is able to reach — the single most effective tool for reducing the kernel attack surface a program exposes. A modern x86-64 kernel exports 375 system calls in its native table (counted from
arch/x86/entry/syscalls/syscall_64.tblat v6.12), and almost every one is an entry point into privileged kernel C code that a compromised process could try to abuse to escalate privilege, escape a sandbox, or trigger a kernel bug. seccomp installs a classic Berkeley Packet Filter (cBPF) program — hence “seccomp-BPF” — that the kernel runs on every system call the thread makes, and that program votes per-syscall on whether to allow it, fake an error, kill the task, or hand the decision to a userspace supervisor (seccomp_filter.rst, v6.12). The security payoff is blunt and powerful: a process can only attack the syscalls you leave open to it.
What this note covers, and what its siblings cover
This cluster is deliberately split five ways. Read the one that matches your question.
Note Owns The question it answers This note — Seccomp and seccomp-BPFThreat model, policy design, real profiles Why would I seccomp something, and what policy should I write? seccomp and Syscall Filtering The kernel-side mechanism Where in the syscall entry path does the filter run, what exactly does it see, how are filters stacked and cached? seccomp Filter Modes and Return Actions The SECCOMP_RET_*/SECCOMP_SET_MODE_*constant referenceWhich verdict do I return, and what are its exact bits and precedence? seccomp User Notification The supervisor protocol How do I build a SECCOMP_RET_USER_NOTIFsupervisor, and what races must it defend against?Writing a seccomp Filter Authoring How do I actually emit the cBPF, by hand or through libseccomp? This note therefore states mechanism only as far as the security argument requires it, and cross-links rather than re-deriving. Where the two are inseparable — above all the pointer-dereference limitation — both this note and seccomp and Syscall Filtering state it in full, because a reader who lands on either one and gets it wrong will write an unsafe policy.
Version pin. Everything below is read against Linux v6.12, a maintained long-term-support (LTS) series (v6.12.107 had shipped as of 2026-08-29); mainline had moved on to the 7.x series by then. Anything dated later than v6.12 is called out explicitly as such.
The Threat Model: The Syscall Boundary Is the Attack Surface
To understand why seccomp matters, start from where the trust boundary actually sits. A userspace process cannot touch hardware, files, network, other processes, or kernel memory directly — every privileged operation must cross into the kernel through a system call. The system-call interface is therefore the entire surface across which an untrusted process can act on the rest of the machine. (For how that boundary works mechanically, see Linux System Call Interface MOC.)
This framing reveals the core insight: the kernel’s attack surface, as seen by one process, is exactly the set of syscalls that process can issue. Every syscall handler is kernel C code running at ring 0 with full privilege; every one is a potential target for a memory-corruption bug, a logic flaw, or a privilege-escalation path. The container escapes and local-privilege-escalation bugs of the last decade route through a small number of recurring syscalls, and the deployed profiles name them explicitly. Docker’s own documentation gives its reason for refusing each one, and the reasons cluster into a handful of families (docs.docker.com/engine/security/seccomp, fetched 2026-08-29):
| Family | Syscalls Docker’s default profile refuses | Docker’s stated reason |
|---|---|---|
| Not namespaced | add_key, keyctl, request_key | “the kernel keyring, which is not namespaced” |
| Not namespaced | clock_settime, clock_adjtime, settimeofday, stime | “Time/date is not namespaced” |
| Host takeover | kexec_load, kexec_file_load, reboot | “Deny loading a new kernel for later execution”; “Don’t let containers reboot the host” |
| Escape the mount view | mount, umount, umount2, pivot_root, setns, unshare | “Should be a privileged operation”; “Deny associating a thread with a namespace” |
| Kernel code injection | init_module, finit_module, delete_module, bpf | “Deny loading potentially persistent BPF programs into kernel” |
| Historic escape | open_by_handle_at | “Cause of an old container breakout” |
| Fresh escape surface | io_uring_setup, io_uring_enter, io_uring_register | “security vulnerabilities that can be exploited to break out of containers” |
| Crypto-API escalation | socket(AF_ALG, …) | “prevent in-container privilege escalation via the kernel cryptographic API” |
Docker’s refusal families, as documented by Docker itself. What it shows: the “dangerous” syscall set is not arbitrary — it is dominated by two structural weaknesses (things the kernel never namespaced, and things that let you load code or change the mount view) plus a steady trickle of newly-exploitable subsystems. The insight to take: you cannot enumerate this set once and be done; io_uring was added to the list years after the profile was written, which is precisely the argument for a default-deny allowlist made in the next section.
seccomp operationalizes the threat model. Even a process running as root, or one that has already been partially compromised, can do nothing through a syscall the kernel has been told to refuse — the handler is never entered. If a process never needs bpf(2), and the kernel refuses to even enter the bpf handler when that process calls it, then the entire class of bpf-subsystem kernel bugs becomes unreachable from that process, regardless of whether those bugs exist or get discovered later. This is why seccomp is described in Linux Security MOC as the single most effective sandbox primitive for reducing kernel attack surface: it is the only mechanism that directly narrows the count of kernel entry points a process can drive.
flowchart LR subgraph BEFORE["No seccomp"] P1["Compromised<br/>process"] -->|"any of 375 x86-64 syscalls"| K1["Full kernel<br/>syscall surface"] end subgraph AFTER["With a seccomp allowlist"] P2["Compromised<br/>process"] -->|"only the ~40-60 it needs"| K2["Narrowed surface"] P2 -.->|"bpf, ptrace, mount,<br/>kexec_load, keyctl,<br/>io_uring_setup..."| BLOCKED["REFUSED<br/>handler never entered"] end
The seccomp threat-model reduction. What it shows: without seccomp a compromised process can drive any of the 375 native x86-64 syscall handlers; with an allowlist it reaches only the handful the program legitimately needs, and the dangerous syscalls are refused before their kernel code ever runs. The insight to take: seccomp does not make the blocked syscalls safe — it makes them unreachable, which is strictly stronger, because an unreachable bug cannot be exploited even if it exists.
A short history, because it explains the design
The original seccomp shipped in Linux 2.6.12 (2005) — verified by reading include/linux/seccomp.h at the v2.6.12 tag, where the file defines NR_SECCOMP_MODES 1 and nothing else. That single mode permitted exactly four syscalls: read, write, _exit, and sigreturn. LWN’s retrospective explains the motivation and the outcome: Andrea Arcangeli’s original concept was to “securely run other people’s code, so that there could be a marketplace for selling unused CPU cycles,” but “the idea never really took off” (Edge, A seccomp overview, LWN, 2015-09-02). A sandbox that cannot open, mmap, or futex cannot host a real program, so mode 1 stayed a curiosity.
The rewrite that mattered was Will Drewry’s, merged as SECCOMP_MODE_FILTER in Linux 3.5 (verified: SECCOMP_MODE_FILTER is absent from include/linux/seccomp.h at v3.4 and present at v3.5). The choice of classic BPF was pragmatic rather than principled — an existing, long-audited, userspace-facing filter language that already ran over a fixed register-like record, with a jit and a verifier, was reused rather than a new policy language invented. Two years later, Linux 3.17 added the dedicated seccomp(2) syscall with its flags argument and the thread-sync flag (verified: SECCOMP_SET_MODE_FILTER and SECCOMP_FILTER_FLAG_TSYNC are absent at v3.16, present at v3.17), because prctl(PR_SET_SECCOMP, …) had no room for options.
That history is load-bearing for the threat model in one specific way: cBPF was chosen partly because it cannot dereference pointers, and the entire shape of what seccomp can and cannot express follows from that decision. The rest of this note is largely a working-out of its consequences.
Allowlist vs Denylist — and Why Allowlists Win
A seccomp policy is, fundamentally, a partition of the syscall set into “permitted” and “refused.” There are two ways to author that partition, and the choice has profound security consequences.
A denylist (blocklist) enumerates the dangerous syscalls and refuses them, allowing everything else by default. An allowlist enumerates the needed syscalls and refuses everything else by default. The structural difference is the default action: a denylist defaults to ALLOW and overrides specific entries to deny; an allowlist defaults to DENY (an errno or a kill) and overrides specific entries to allow.
flowchart TB NEW["A new syscall lands in the kernel<br/>(io_uring 5.1, pidfd_open 5.3,<br/>process_madvise 5.10, mseal 6.10, ...)"] NEW --> D{"Policy authored<br/>before it existed"} D -->|"denylist:<br/>default = ALLOW"| DL["Not named in the deny set<br/>-> silently PERMITTED<br/>FAILS OPEN"] D -->|"allowlist:<br/>default = DENY"| AL["Not named in the allow set<br/>-> silently REFUSED<br/>FAILS CLOSED"] DL --> DBAD["New kernel subsystem is<br/>reachable from the sandbox<br/>until someone notices"] AL --> AGOOD["Sandbox breaks loudly if the<br/>program actually needed it;<br/>attacker gains nothing"]
Why the default action is the whole argument. What it shows: the two policy styles differ only in what happens to a syscall nobody thought about — and since the kernel adds syscalls every release, that case is the common case over a profile’s lifetime. The insight to take: correctness of a denylist depends on the author having predicted the future; correctness of an allowlist depends only on the author knowing their own program. Only one of those is achievable.
Both authoritative sources say this outright. The seccomp(2) man page: “It is strongly recommended to use an allow-list approach whenever possible because such an approach is more robust and simple. A deny-list will have to be updated whenever a potentially dangerous system call is added (or a dangerous flag or option if those are deny-listed), and it is often possible to alter the representation of a value without altering its meaning, leading to a deny-list bypass” (seccomp(2)). systemd’s manual makes the same case with a concrete example: “the pidfd_send_signal() system call may be used to execute operations similar to what can be done with the older kill() system call, hence blocking the latter without the former only provides weak protection. Since new system calls are added regularly to the kernel as development progresses, keeping system call deny lists comprehensive requires constant work” (systemd.exec(5)).
That second failure mode — redundant syscalls — is subtler than the “new syscall” one and worth dwelling on. The kernel routinely grows a second, more capable way to do something the old syscall already did: openat2 beside openat, clone3 beside clone, pidfd_send_signal beside kill, faccessat2 beside faccessat, statx beside stat. A denylist that names one and not the other is not merely stale; it is wrong on the day it was written, because the redundancy already existed. An allowlist has no such failure: whichever variant is not named is refused.
There is a third, softer argument. The set of syscalls a given program needs is small, knowable, and discoverable — you can run the program under SECCOMP_RET_LOG (or SECCOMP_FILTER_FLAG_LOG), exercise it fully, and harvest the log, which is typically a few dozen syscalls. The set of syscalls that are dangerous is large, fuzzy, and version-dependent — it requires a complete and current mental model of every kernel subsystem. It is far easier to be exhaustive about “what my program does” than about “what an attacker might want.”
“Docker blocks ~44 syscalls” is a description of the effect, not the mechanism
The most-repeated seccomp folk fact is that Docker’s default profile “blocks about 44 dangerous syscalls.” The profile is structurally an allowlist, and the number is a derived, drifting count. Reading moby/profiles seccomp/default.json directly (fetched 2026-08-29) settles it:
- Top-level
"defaultAction": "SCMP_ACT_ERRNO"with"defaultErrnoRet": 1— default-deny, returningEPERMfor anything not explicitly permitted. - 33 rule blocks, listing 426 distinct syscall names with
SCMP_ACT_ALLOW(441 name-entries, some repeated across conditional blocks) and exactly oneSCMP_ACT_ERRNOblock. - Nine architectures in
archMap, so one profile covers amd64/x32/x86, arm/arm64, ppc64le, s390/s390x, riscv64.
Intersecting that allow-set with the 375 syscalls in v6.12’s native x86-64 table gives 28 native syscalls that no rule permits at all, of which 13 are dead stubs the kernel wires to sys_ni_syscall (_sysctl, afs_syscall, create_module, get_kernel_syms, getpmsg, nfsservctl, putpmsg, query_module, security, tuxcall, uselib, ustat, vserver). The 15 live, unconditionally-refused ones are:
add_key, io_uring_enter, io_uring_register, io_uring_setup, kexec_file_load, kexec_load, keyctl, migrate_pages, move_pages, pivot_root, request_key, swapoff, swapon, sysfs, userfaultfd.
So where does the larger “blocked” list come from? From conditional allows. Docker’s documentation table names 54 syscalls (fetched 2026-08-29), and most of the difference is syscalls that are in the allow-set but gated behind a capability the default container does not hold — mount, bpf, clone3 and 23 others behind CAP_SYS_ADMIN; ptrace, kcmp, pidfd_getfd, process_vm_readv/writev, process_madvise behind CAP_SYS_PTRACE; init_module/finit_module/delete_module behind CAP_SYS_MODULE; reboot behind CAP_SYS_BOOT; settimeofday/clock_settime behind CAP_SYS_TIME; iopl/ioperm behind CAP_SYS_RAWIO; syslog behind CAP_SYSLOG; open_by_handle_at behind CAP_DAC_READ_SEARCH. They are effectively blocked for a default container and available for --cap-added ones. That is the reconciliation: the profile is an allowlist; the “blocked” list is the derived set of well-known syscalls that an unprivileged container ends up unable to reach, and it changes as both the kernel and the capability set change. Anyone quoting a fixed number is quoting a snapshot.
Uncertain
Verify:
CVE-2026-31431, cited by Docker’s documentation as the reasonsocket(AF_ALG, …)is refused. Reason: the identifier is named only in Docker’s own documentation page as fetched 2026-08-29; it was not independently confirmed against the CVE record or the kernel commit during this task. The policy fact — that the profile permitssocketonly fordomain < 38,domain == 39, ordomain > 40, i.e. refusesAF_ALG(38) andAF_VSOCK(40) — is verified directly fromdefault.jsonandinclude/linux/socket.hat v6.12. To resolve: look the identifier up in the CVE database orlinux-cve-announceand confirm the affected subsystem is the kernel crypto user API. Add#uncertain.
The Hard Limitation: Filters Cannot Dereference Pointers
The single most important constraint to internalize about seccomp — the one that shapes every real profile, explains why a whole second API (SECCOMP_RET_USER_NOTIF) exists, and answers every “why can’t I filter by path” question — is that a cBPF seccomp filter can read the syscall’s register arguments but cannot dereference any pointer among them.
When the filter runs, it is handed a small, kernel-built, read-only record: struct seccomp_data, 64 bytes containing the syscall number, the architecture, the userspace instruction pointer, and the six argument registers as raw 64-bit values. (Its exact field layout, and the kernel code that populates it, are in seccomp and Syscall Filtering.) Crucially, those are register values. If a syscall argument is a pointer — openat(int, const char *pathname, …), connect(int, const struct sockaddr *, …), execve(const char *filename, …) — the filter sees only the numeric address, not the bytes it points at. cBPF, by deliberate design, has no instruction that loads from an arbitrary userspace address; the kernel’s verifier for seccomp programs rewrites every BPF_LD|BPF_W|BPF_ABS into a bounds-checked load against the seccomp_data buffer and rejects any offset >= sizeof(struct seccomp_data). Per the kernel documentation, “BPF programs may not dereference pointers which constrains all filters to solely evaluating the system call arguments directly” (seccomp_filter.rst, v6.12).
| The filter can decide on | The filter cannot decide on |
|---|---|
Which syscall (nr) | The pathname a file syscall names |
Which ABI (arch) | The contents of a struct sockaddr |
Where the call came from (instruction_pointer) | The contents of a struct clone_args |
Scalar arguments: fds, mode_t, prot, flags bitmasks, domain/type/protocol | An iovec array, an argv[], an environment |
| Bit tests and masked comparisons on those scalars | Anything at all behind any of the six pointers |
What a cBPF seccomp filter sees. What it shows: the boundary is not “simple vs complex predicates” — it is exactly “in a register vs behind a pointer.” The insight to take: if the value you want to filter on is not physically sitting in one of the six argument registers at the moment of the trap, seccomp cannot see it, and no amount of cleverness in the filter changes that.
Why this is a feature, not an oversight
Earlier system-call-interposition frameworks did read the memory behind pointer arguments to make decisions, and they were systematically broken by time-of-check-to-time-of-use (TOCTOU) races. The attack is simple: the sandbox reads the pathname behind a pointer, sees an innocuous "/tmp/safe", approves the call; then a second thread in the same address space overwrites that memory with "/etc/shadow" after the check but before the kernel’s syscall handler copies it in. The check and the use see different bytes.
The kernel documentation is explicit that avoiding this was a design goal: “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.” Kees Cook restated it at Linux Security Summit 2019 when the community discussed re-opening the question: “if one of those values is a pointer, dereferencing it will not work. Even if it were possible to do so, another thread could change the values after the check is done. That is a classic time-of-check-to-time-of-use (TOCTTOU) race” (Edge, Deep argument inspection for seccomp, LWN, 2019-09-18).
sequenceDiagram participant TA as "Target thread A" participant TB as "Target thread B (attacker)" participant SC as "Hypothetical dereferencing filter" participant K as "Syscall handler" Note over TA,K: The race that pointer-dereferencing filters would suffer TA->>SC: openat(dirfd, ptr, ...) — *ptr = "/tmp/safe" SC->>SC: read *ptr -> "/tmp/safe" -> vote ALLOW TB-->>TA: write *ptr = "/etc/shadow" SC->>K: dispatch openat(dirfd, ptr, ...) K->>K: copy_from_user(*ptr) -> "/etc/shadow" Note over K: opens the WRONG file — the check and the use disagree
The TOCTOU race, and why cBPF refuses to play. What it shows: the two reads of *ptr — the sandbox’s and the kernel’s — are separated in time, and the attacker controls the interval, so any decision made on the first read is unenforceable at the second. The insight to take: cBPF’s inability to dereference pointers is the feature. By refusing to look at pointed-to memory at all, seccomp is structurally immune to this race, at the price of being unable to filter on string, path, or struct content.
The clone3 case study — the limitation, in a shipping profile
The clearest real-world proof of this limitation sits in Docker’s default profile, and it is worth walking through because it is the argument in miniature.
clone(2) takes its namespace flags in a register: on x86-64, flags is argument 0. So a filter can say “allow clone, but only if none of the CLONE_NEW* bits are set.” Docker does exactly that, for containers without CAP_SYS_ADMIN:
{
"names": ["clone"],
"action": "SCMP_ACT_ALLOW",
"excludes": { "caps": ["CAP_SYS_ADMIN"], "arches": ["s390", "s390x"] },
"args": [{ "index": 0, "value": 2114060288, "op": "SCMP_CMP_MASKED_EQ" }]
}Line by line: index: 0 selects args[0], the flags register. value: 2114060288 is 0x7E020000, which decodes exactly — verified against include/uapi/linux/sched.h at v6.12 — as CLONE_NEWNS (0x00020000) | CLONE_NEWCGROUP (0x02000000) | CLONE_NEWUTS (0x04000000) | CLONE_NEWIPC (0x08000000) | CLONE_NEWUSER (0x10000000) | CLONE_NEWPID (0x20000000) | CLONE_NEWNET (0x40000000). SCMP_CMP_MASKED_EQ with libseccomp’s (arg & mask) == value semantics and a datum of 0 means: permit the call only when every one of those bits is clear. A container therefore cannot create a new namespace of any kind — enforced on a register, race-free, because the flags never leave the register file. (Note the honest gap: CLONE_NEWTIME is 0x00000080 and is not in the mask.)
Now clone3(2). It takes a pointer to struct clone_args, and the flags live inside that struct. There is no register to test. The filter cannot read the struct, and even if it could, a second thread could rewrite cl_args.flags in the window between the filter’s read and copy_clone_args_from_user(). So Docker does the only safe thing:
{
"names": ["clone3"],
"action": "SCMP_ACT_ERRNO",
"errnoRet": 38,
"excludes": { "caps": ["CAP_SYS_ADMIN"] }
}errnoRet: 38 is ENOSYS, and the choice of errno is deliberate rather than incidental: glibc’s fork()/pthread_create() try clone3 first and fall back to clone on ENOSYS, so returning ENOSYS degrades gracefully to the filterable syscall instead of breaking the container. Returning EPERM here would have broken every modern glibc.
flowchart TB subgraph OK["clone(2) — flags in a register"] C1["args[0] = flags"] --> C2{"filter: (args[0] & 0x7E020000) == 0 ?"} C2 -->|yes| C3["ALLOW — no new namespaces possible"] C2 -->|no| C4["deny (falls through to default EPERM)"] end subgraph NOPE["clone3(2) — flags behind a pointer"] D1["args[0] = &struct clone_args"] --> D2{"filter wants cl_args.flags"} D2 --> D3["cannot dereference;<br/>and could be rewritten<br/>before copy_clone_args_from_user()"] D3 --> D4["only safe verdict:<br/>ERRNO ENOSYS (38)<br/>-> glibc retries clone()"] end
The same policy question, one syscall apart. What it shows: identical semantics (create a process, maybe with new namespaces) become filterable or unfilterable purely according to whether the deciding value sits in a register or behind a pointer. The insight to take: when a kernel interface migrates its arguments into a struct — the modern trend, for extensibility — it moves out of seccomp’s reach, and the only honest policy response is to refuse the new variant outright.
The consequence for policy design
seccomp can filter on which syscall is called and on scalar/flag arguments; it cannot filter on the contents of strings, paths, or structures. You can write a filter that allows openat but refuses socket(AF_PACKET, …) — the address family is a scalar in a register. You cannot write a filter that allows openat only for paths under /var/log. This is why path-level confinement is a job for Landlock, which reasons about which file a syscall touches from inside the kernel where the path has already been resolved race-free, and why the two are used together rather than one instead of the other (see Landlock vs seccomp vs Namespaces).
The kernel’s own answer for cases that genuinely need deep argument inspection is SECCOMP_RET_USER_NOTIF — but it is not a clean solution, and presenting it as one is a common and dangerous error. See the escape-hatches section below.
Installing a Policy Is a One-Way Door
From the security perspective the installation path has three load-bearing properties: it is irreversible, it is inherited, and it requires the process to have first given up the ability to gain privilege.
A filter is installed with prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) or, more flexibly, seccomp(SECCOMP_SET_MODE_FILTER, flags, &prog). Once installed, a filter can never be removed or relaxed for the lifetime of the thread — only further filters can be added, and because the kernel takes the most restrictive verdict across the whole stack, an added filter can only narrow (the precedence mechanism is detailed in seccomp Filter Modes and Return Actions). This one-way property is what makes seccomp trustworthy as confinement: a compromised process cannot un-sandbox itself, and neither can a program that gets tricked into calling prctl again.
Filters are inherited across fork(2)/clone(2) and preserved across execve(2): “If fork/clone and execve are allowed by @prog, any child processes will be constrained to the same filters and system call ABI as the parent” (seccomp_filter.rst, v6.12). This is what lets a container runtime install a profile once, in the runtime, before execveing the container’s entrypoint, and have it bind the application and every subprocess it will ever spawn.
stateDiagram-v2 [*] --> Unconfined: task starts Unconfined --> NNP: prctl(PR_SET_NO_NEW_PRIVS, 1) Unconfined --> Rejected: seccomp(SET_MODE_FILTER) without NNP<br/>and without CAP_SYS_ADMIN Rejected --> Unconfined: EACCES — nothing changed NNP --> Filtered: seccomp(SET_MODE_FILTER, ..., &prog) Filtered --> Filtered: another filter stacked<br/>(verdict can only tighten) Filtered --> Filtered: fork()/clone() child inherits<br/>execve() preserves Filtered --> Dead: a KILL_* action fires<br/>mode := SECCOMP_MODE_DEAD Dead --> [*] note right of Filtered No transition back to Unconfined exists. There is no "unfilter" operation. end note
The lifecycle of a seccomp-confined task. What it shows: every arrow into Filtered is one-way, and the only exits are death or continued confinement — including across fork and execve. The insight to take: irreversibility plus inheritance is what upgrades seccomp from a convenience into a trust boundary; a policy that could be dropped by the confined code, or shed by execing a new binary, would confine nothing.
no_new_privs: the interlock that makes unprivileged seccomp safe
The precondition is the keystone of the whole design. Unless the caller holds CAP_SYS_ADMIN in its namespace, it must first set the no_new_privs bit via prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); otherwise the filter install fails with EACCES. In the kernel:
if (!task_no_new_privs(current) &&
!ns_capable_noaudit(current_user_ns(), CAP_SYS_ADMIN))
return ERR_PTR(-EACCES);(kernel/seccomp.c, v6.12). The reason is precisely a privilege-escalation defence. From the man page: “This requirement ensures that an unprivileged process cannot apply a malicious filter and then invoke a set-user-ID or other privileged program using execve(2)” (seccomp(2)); the kernel documentation phrases the same rule as ensuring “that filter programs cannot be applied to child processes with greater privileges than the task that installed them.”
The attack it closes is concrete. Without no_new_privs, an unprivileged attacker could install a filter that makes setuid, setgroups, or capset return a fake success via SECCOMP_RET_ERRNO with data 0, then execve a setuid-root helper. That helper would run as root, believe it had successfully dropped to an unprivileged UID, and proceed to do privileged work under attacker-supplied input — a confused deputy manufactured entirely out of a filter. no_new_privs closes it by guaranteeing no descendant can ever acquire a privilege the installer lacked, which makes the filter’s lies harmless: there is no privileged binary for them to lie to. This is what makes seccomp safe to expose to wholly unprivileged processes, and it is why it is the same prerequisite Landlock demands. The full mechanism is in no_new_privs and Privilege Escalation Control.
Escape Hatches, and Why Each Has Its Own Sharp Edge
seccomp’s core is a closed, race-free decision made in the kernel. Every mechanism that reaches outside that — to a tracer, to a supervisor, to a checkpoint tool — trades some of the guarantee away. All three exist, all three are used in production, and all three have bitten people.
flowchart TB F["Filter verdict"] --> UN["USER_NOTIF<br/>-> supervisor process"] F --> TR["TRACE<br/>-> ptrace tracer"] F --> SUS["(outside the filter)<br/>PTRACE_O_SUSPEND_SECCOMP"] UN --> UNR["Risk: supervisor reads target memory<br/>via /proc/PID/mem -> TOCTOU is BACK.<br/>Mitigations: ID_VALID, ADDFD,<br/>never CONTINUE for policy"] TR --> TRR["Risk (pre-4.8): tracer rewrote nr<br/>after the check and it was NOT rechecked.<br/>Since 4.8: filters re-run on the new nr"] SUS --> SUSR["Turns seccomp OFF for the tracee.<br/>Gated on CAP_SYS_ADMIN +<br/>CONFIG_CHECKPOINT_RESTORE +<br/>tracer not itself confined"]
The three ways out of the closed decision, and what each costs. What it shows: every hand-off from in-kernel cBPF to a userspace decision-maker re-introduces exactly the race cBPF was designed to avoid, and the kernel’s answer in each case is a set of ioctls and preconditions rather than a fix. The insight to take: treat every escape hatch as a privileged helper, never as the enforcement point; the moment policy lives in userspace, the target’s threads can move under it.
SECCOMP_RET_USER_NOTIF is a syscall-emulation channel, not a policy engine
When a filter returns SECCOMP_RET_USER_NOTIF (added in Linux 5.0 — verified absent at v4.17, present at v5.0), the calling thread blocks in the kernel and a struct seccomp_notif becomes readable on a listener file descriptor obtained at install time with SECCOMP_FILTER_FLAG_NEW_LISTENER. A supervisor in a different address space reads it with ioctl(SECCOMP_IOCTL_NOTIF_RECV), can inspect the target’s memory through /proc/PID/mem — which a cBPF filter never could — and replies with ioctl(SECCOMP_IOCTL_NOTIF_SEND). The canonical use is a privileged container manager performing a mount(2) or finit_module(2) on behalf of an unprivileged container.
It would be easy to present this as “the solution to the pointer problem.” It is not, and the man page says so in the strongest terms available to a man page:
“Note well: this mechanism must not be used to make security policy decisions about the system call, which would be inherently race-prone for reasons described next. … It should thus be absolutely clear that the seccomp user-space notification mechanism can not be used to implement a security policy!” (seccomp_unotify(2))
Three distinct races, each with its own mitigation:
- The
CONTINUErace.SECCOMP_USER_NOTIF_FLAG_CONTINUE(added Linux 5.5 — verified absent atv5.0, present atv5.5) tells the kernel to let the original syscall proceed after the supervisor approves. That reopens the classic TOCTOU: “an attacker could exploit the interval of time where the target is blocked waiting on the ‘continue’ response to do things such as rewriting the system call arguments.” The uapi header carries the same warning in a comment, adding that a supervisor may only continue a syscall when “another security mechanism or the kernel itself will sufficiently block syscalls if arguments are rewritten to something unsafe” (include/uapi/linux/seccomp.h, v6.12). - The PID-reuse race. The notification carries the target’s TID. Between receiving it and opening
/proc/tid/mem, the target can exit and its TID be recycled onto an unrelated process — so the supervisor would read someone else’s memory.SECCOMP_IOCTL_NOTIF_ID_VALIDexists solely to close this: open the file, then re-validate the notification cookie, and only then read. The man page walks the four-step race explicitly. - The precedence bypass. Subtler and easy to miss: “a user-space notifier can be bypassed if the existing filters allow the use of
seccomp(2)orprctl(2)to install a filter that returns an action value with a higher precedence thanSECCOMP_RET_USER_NOTIF.” A confined target that can still install filters can stack one returningSECCOMP_RET_ERRNO(numerically smaller, hence more restrictive, hence winning) on the very syscalls the supervisor was meant to arbitrate — silently removing the supervisor from the loop.
SECCOMP_IOCTL_NOTIF_ADDFD (Linux 5.9 — verified absent at v5.7, present at v5.9) is the mitigation for the most common case: instead of approving a syscall the target then executes, the supervisor performs the operation itself and injects the resulting file descriptor, atomically with the response when SECCOMP_ADDFD_FLAG_SEND is used. Nothing the target does in the interval can change the outcome, because the target never runs the syscall. The full supervisor protocol is in seccomp User Notification.
SECCOMP_RET_TRACE, and a documentation claim that stopped being true in 4.8
SECCOMP_RET_TRACE notifies a ptrace(2) tracer that requested PTRACE_O_TRACESECCOMP, and lets it inspect, skip, or rewrite the call before it runs. The historical hazard was that the tracer’s rewrite was not re-checked, so a confined process able to ptrace a sibling could launder a forbidden syscall through it.
The kernel documentation still asserts this: “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, even of other sandboxed processes, without extreme care; ptracers can use this mechanism to escape.)” That statement is stale. The v6.12 code re-runs the filters:
case SECCOMP_RET_TRACE:
/* We've been put in this state by the ptracer already. */
if (recheck_after_trace)
return 0;
...
ptrace_event(PTRACE_EVENT_SECCOMP, data);
...
this_syscall = syscall_get_nr(current, current_pt_regs());
if (this_syscall < 0)
goto skip;
/* Recheck the syscall, since it may have changed. */
if (__seccomp_filter(this_syscall, NULL, true))
return -1;
return 0;The recheck_after_trace parameter was introduced in Linux 4.8, dated here by fetching kernel/seccomp.c at successive tags: the identifier appears 0 times at v4.6 and v4.7, and 3 times at v4.8. The recursive call deliberately passes NULL for seccomp_data to force a reload of all registers, so the filters evaluate the tracer’s rewritten syscall; the recheck_after_trace guard exists only to stop an infinite loop if the second evaluation also returns TRACE.
The change had a visible downstream consequence, which is the best confirmation that it is real: Docker’s default profile permits ptrace unconditionally on modern kernels, in a rule block carrying "includes": {"minKernel": "4.8"}, and Docker’s documentation gives the reason in as many words — ptrace was “Blocked in Linux kernel versions before 4.8 to avoid seccomp bypass.”
The operational conclusion is not that sandboxes may now freely allow ptrace. A tracer can still read and write the tracee’s memory and registers, which is a data-exfiltration and code-injection channel independent of seccomp; Docker’s own justification for keeping it away from containers is that “Tracing/profiling arbitrary processes is already blocked by dropping CAP_SYS_PTRACE, because it could leak a lot of information on the host.” The conclusion is narrower and more precise: the specific “rewrite the syscall number past the filter” escape is closed since 4.8, and reasoning that cites the documentation’s sentence as current is reasoning from a stale source.
PTRACE_O_SUSPEND_SECCOMP — the deliberate off switch
Checkpoint/restore tooling (CRIU Checkpoint Restore in Userspace) needs to run a restored process’s setup code without its seccomp filters interfering, so the kernel offers a ptrace option that disables seccomp for the tracee. __secure_computing() checks it first and returns immediately. Its gating, in check_ptrace_options(), is what makes it acceptable (kernel/ptrace.c, v6.12):
if (unlikely(data & PTRACE_O_SUSPEND_SECCOMP)) {
if (!IS_ENABLED(CONFIG_CHECKPOINT_RESTORE) || !IS_ENABLED(CONFIG_SECCOMP))
return -EINVAL;
if (!capable(CAP_SYS_ADMIN))
return -EPERM;
if (seccomp_mode(¤t->seccomp) != SECCOMP_MODE_DISABLED ||
current->ptrace & PT_SUSPEND_SECCOMP)
return -EPERM;
}Read the third condition carefully, because it is the interesting one: a tracer that is itself under seccomp, or itself has seccomp suspended, may not use the option. capable(CAP_SYS_ADMIN) here is against the initial user namespace (not ns_capable), so a root-in-a-user-namespace container cannot reach it either. Together those close the obvious escalation — confine a process, have it attach to a sibling and switch the sibling’s filters off.
The Architecture Pitfall — arch, __X32_SYSCALL_BIT, and Real Bypasses
The kernel documentation’s Pitfalls section is one sentence long and names only 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!” It deserves more than a sentence, because on x86-64 the naive reading of that advice is insufficient.
The trap has two layers.
Layer one: different ABIs, different numbers. A 64-bit kernel with CONFIG_IA32_EMULATION will happily execute a 32-bit int 0x80 syscall from a 64-bit process. The i386 table is a completely different numbering: number 4 is write on i386 and stat on x86-64. A filter that allows or denies by number without first pinning seccomp_data.arch is comparing numbers from a table the caller may not be using. syscall_get_arch() reports AUDIT_ARCH_I386 for a task in compat mode and AUDIT_ARCH_X86_64 otherwise, so pinning arch does close this layer.
Layer two, and the one that actually produces escapes: x32 shares arch with x86-64. Read the kernel’s own syscall_get_arch() for x86 (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;
}The comment says it outright. An x32 caller and an x86-64 caller present the same arch value. What distinguishes them is a bit inside nr: __X32_SYSCALL_BIT is 0x40000000 (arch/x86/include/uapi/asm/unistd.h, v6.12). The dispatcher tries the x64 table first and then the x32 table, subtracting the bit (arch/x86/entry/common.c, v6.12):
if (!do_syscall_x64(regs, nr) && !do_syscall_x32(regs, nr) && nr != -1)
regs->ax = __x64_sys_ni_syscall(regs);seccomp runs before that dispatch and sees the raw nr, bit and all. So the man page’s rule is:
“This means that a policy must either deny all syscalls with
__X32_SYSCALL_BITor it must recognize syscalls with and without__X32_SYSCALL_BITset. A list of system calls to be denied based onnrthat does not also containnrvalues with__X32_SYSCALL_BITset can be bypassed by a malicious program that sets__X32_SYSCALL_BIT.” (seccomp(2))
flowchart TB A["Attacker in a sandbox wants ptrace (x86-64 nr = 101)"] A --> B{"Policy shape"} B -->|"DENYLIST: if nr == 101 -> KILL"| C["issue nr = 101 | 0x40000000 = 0x40000065<br/>101 != 0x40000065 -> check misses"] C --> D["kernel: do_syscall_x64 misses,<br/>do_syscall_x32 hits -> x32 ptrace RUNS"] D --> E["BYPASSED"] B -->|"ALLOWLIST: default DENY,<br/>allow only listed nr"| F["0x40000065 is in no allow rule"] F --> G["default action fires -> REFUSED"] G --> H["safe — but only because the<br/>allowlist never named it"]
The x32 bypass, and why allowlists survive it by construction. What it shows: setting bit 30 of the syscall number produces a value that compares unequal to every plain number in a denylist, while the kernel still routes it to a working handler. The insight to take: “always check arch” is necessary but not sufficient on x86-64; a denylist must additionally reject or normalize __X32_SYSCALL_BIT, and this is one more concrete reason denylists are the wrong shape.
Two further wrinkles worth knowing:
- Kernels before 5.4 were worse. Per the man page, they “incorrectly permitted
nrin the ranges 512-547 as well as the corresponding non-x32 syscalls ORed with__X32_SYSCALL_BIT. For example,nr == 521andnr == (101 | __X32_SYSCALL_BIT)would result in invocations ofptrace(2)with potentially confused x32-vs-x86_64 semantics in the kernel.” On 5.4 and newer such calls fail withENOSYSand do nothing. - x32 also defeats the kernel’s own fast path.
arch/x86/include/asm/seccomp.hnotes: “x32 will have__X32_SYSCALL_BITset 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.” Correct for safety, but it means x32 traffic never benefits from the constant-action bitmap (see seccomp and Syscall Filtering).
The practical defences, in order of preference: refuse __X32_SYSCALL_BIT outright at the top of the filter (the pattern the seccomp(2) man page’s own example uses, setting upper_nr_limit = X32_SYSCALL_BIT - 1 and killing anything above it); use systemd’s SystemCallArchitectures=native; or, in a container, rely on the profile being an allowlist. OpenSSH’s privilege-separated sandbox — written in 2012 by Will Drewry himself, the author of seccomp-BPF — shows the canonical opening sequence (sandbox-seccomp-filter.c):
/* Ensure the syscall arch convention is as expected. */
BPF_STMT(BPF_LD+BPF_W+BPF_ABS, offsetof(struct seccomp_data, arch)),
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, SECCOMP_AUDIT_ARCH, 1, 0),
BPF_STMT(BPF_RET+BPF_K, SECCOMP_FILTER_FAIL),
/* Load the syscall number for checking. */
BPF_STMT(BPF_LD+BPF_W+BPF_ABS, offsetof(struct seccomp_data, nr)),Arch first; kill on mismatch; only then look at nr. Every hand-written filter should start this way.
Failure Modes and Common Misunderstandings
“seccomp blocks files, paths, or network destinations.” It does not, for the reasons in the pointer section. If you want “this process may only read files under /srv,” that is a Landlock or mandatory-access-control (AppArmor / SELinux) job. Conflating them yields profiles that look far stricter than they are: a profile that allows openat allows opening every file the process’s UID can reach.
“seccomp is a sandbox.” The kernel documentation opens with a section titled What it isn’t: “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.” Filtering which syscalls a process may issue says nothing about what an allowed syscall may do. A real sandbox layers namespaces, capabilities, an LSM, and seccomp.
A too-tight allowlist dies with SIGSYS, or worse, with a nonsense errno. The most common operational failure is omitting a syscall the program needs. The symptom depends on the default action: a killing action produces a process that vanishes with SIGSYS and si_code == SYS_SECCOMP; an errno action produces a baffling EPERM or ENOSYS surfacing from somewhere deep in libc. The seccomp(2) man page lists the standing traps: glibc wrappers do not map one-to-one onto syscalls (“the exit(2) wrapper function actually employs the exit_group(2) system call, and the fork(2) wrapper function actually calls clone(2)”), the mapping varies by architecture, and it changes across libc versions — “in older versions, the glibc wrapper function for open(2) invoked the system call of the same name, but starting in glibc 2.26, the implementation switched to calling openat(2) on all architectures.” A profile that was correct on one distro can break on the next simply because libc started using clone3, rseq, getrandom, statx, or newfstatat. The robust loop is: develop under SECCOMP_RET_LOG or SECCOMP_FILTER_FLAG_LOG, exercise every code path including the error paths, harvest the audit log, and only then switch to a refusing action.
The vDSO hides syscalls from you until it doesn’t. Calls served from the vDSO — clock_gettime, gettimeofday, time — may never trap into the kernel at all, so your filter never sees them and you never notice they are missing from the allowlist. Then the program runs on a machine whose clocksource forces the real syscall, and a call that “always worked” is refused. The kernel documentation’s advice is to test with /sys/devices/system/clocksource/clocksource0/current_clocksource set to something like acpi_pm to force the syscall path.
Comparing only the low 32 bits of a 64-bit argument. cBPF is a 32-bit machine, but seccomp_data.args[] are always 64-bit. A filter that loads only the low word of an argument and compares it is bypassable by setting the high word. Real filters handle this explicitly; OpenSSH’s SC_ALLOW_ARG macro loads and compares both halves, with ARG_LO_OFFSET/ARG_HI_OFFSET selected by endianness, and only then returns SECCOMP_RET_ALLOW.
Arguments can be truncated after the check. From seccomp(2): “When checking values from args, keep in mind that arguments are often silently truncated before being processed, but after the seccomp check.” A filter that validates the full 64-bit value of an argument the kernel will later narrow to 32 bits is validating something the handler never sees.
Filters accumulate, and the cost is real. Each installed filter is capped at BPF_MAXINSNS (4096) instructions, and the total across a thread’s whole stack at MAX_INSNS_PER_PATH (32768) — the kernel defines the latter as ((1 << 18) / sizeof(struct sock_filter)), i.e. 256 KiB of instruction space — “Note that for the purposes of calculating this limit, each already existing filter program incurs an overhead penalty of 4 instructions.” Exceeding it returns ENOMEM. Because the kernel runs every filter in the stack on every syscall and takes the minimum verdict, a deep stack is a per-syscall tax; the constant-action bitmap (Linux 5.11) removes it for the common argument-independent case, but only for the common case. The mechanics are in seccomp and Syscall Filtering.
SECCOMP_RET_KILL_THREAD can leave a corpse walking. Killing only the offending thread of a multithreaded program can leave the process holding locks mid-transaction rather than cleanly dead. SECCOMP_RET_KILL_PROCESS (Linux 4.14 — verified absent at v4.13, present at v4.14) exists precisely to fix that, and is what a hard sandbox should use.
Alternatives and When to Choose Them
seccomp is one of several confinement primitives, and the right tool depends on what you are constraining. The honest summary is that these are layers, not competitors.
| Primitive | Constrains | Granularity | Needs privilege to apply? | Can it see a pathname? | Blind spot |
|---|---|---|---|---|---|
| seccomp | Which syscalls may be issued | Syscall number + scalar register args | No (with no_new_privs) | No | Anything behind a pointer; what an allowed syscall then does |
| POSIX Capabilities | Which privileged operations succeed | Per-capability check inside the handler | Dropping needs none | n/a | Handler is still entered — no attack-surface reduction |
| Landlock | Which filesystem objects and TCP ports may be touched | Object / access right | No (with no_new_privs) | Yes, race-free | Cannot filter arbitrary syscalls |
| SELinux / AppArmor | Subject-object access under a system policy | Labelled objects, very expressive | Yes — admin authors policy | Yes | Not self-imposable; policy is system-wide |
| Namespaces | What the process can see and name | Per-namespace view | User namespaces: no | Changes the view, doesn’t deny the op | Does not reduce kernel entry points |
Choosing among the confinement primitives. What it shows: only seccomp reduces the number of kernel entry points; only Landlock and the MAC systems can reason about which object; only seccomp and Landlock are self-imposable by an unprivileged process. The insight to take: the axes are genuinely orthogonal, so the question is never “seccomp or X” but “which of these do I need, and in what order” — the canonical answer being no_new_privs first, then namespaces, then capability drop, then a seccomp allowlist, then Landlock.
The capability comparison deserves a sentence of its own because it is the one most often confused. Dropping CAP_SYS_MODULE means init_module(2) will fail its capability check — but the process still enters sys_init_module, which parses attacker-controlled input before reaching that check in some subsystems. Refusing init_module in seccomp means the handler is never entered at all. That is why real profiles do both, and why Docker’s documentation annotates nearly every refusal with “Also gated by CAP_…” — belt and braces, deliberately. For the explicit three-way comparison with Landlock and namespaces, see Landlock vs seccomp vs Namespaces.
Production Notes
Docker / Moby, containerd, CRI-O
The canonical profile is moby/profiles seccomp/default.json, applied to every container unless the user passes --security-opt seccomp=unconfined or a custom profile. Its verified structure (2026-08-29) is described in the allowlist section above; the parts worth knowing operationally are the conditional rules, because they are where the policy is actually interesting:
- Capability-gated allows. 26 syscalls (
bpf,clone,clone3,mount,mount_setattr,fsopen/fsconfig/fsmount/fspick, thelsm_*trio, and others) are allowed only if the container was grantedCAP_SYS_ADMIN. Nine further capability-gated blocks coverCAP_SYS_PTRACE,CAP_SYS_MODULE,CAP_SYS_BOOT,CAP_SYS_CHROOT,CAP_SYS_PACCT,CAP_SYS_RAWIO,CAP_SYS_TIME,CAP_SYS_TTY_CONFIG,CAP_SYS_NICE,CAP_SYSLOG,CAP_BPF,CAP_PERFMON, andCAP_DAC_READ_SEARCH. This is the profile composing with the capability set rather than duplicating it. - Argument-masked allows. The
clonenamespace-flag mask walked through earlier, andpersonality, which is permitted only for the values0(PER_LINUX),8(PER_LINUX32),0x20000(UNAME26),0x20008(UNAME26|PER_LINUX32), and0xffffffff(the query form) — all verified againstinclude/uapi/linux/personality.hat v6.12. Docker’s reason: “Prevent container from enabling BSD emulation. Not inherently dangerous, but poorly tested, potential for a lot of kernel vulnerabilities.” - An architecture-specific argument index. The
clonemask rule is duplicated for s390/s390x with"index": 1instead of"index": 0, because s390’sclonetakes its arguments in the opposite order. A profile author who forgot this would have written a rule that masks the child stack pointer on s390 and enforced nothing. This is the sharpest available reminder that seccomp rules are ABI-specific all the way down. socketdomain filtering. Three rules permitdomain < 38,domain == 39, anddomain > 40, which refuses exactlyAF_ALG(38) andAF_VSOCK(40). Docker’s documentation notes the limits of this honestly: the rules “do not coversocketcall(2), which can be easily bypassed,” and observes thatAF_ALGis additionally denied by the default AppArmor profile whileAF_VSOCK“has no equivalent LSM rule.”- The
io_uringretrofit.io_uring_setup,io_uring_enter, andio_uring_registerare absent from the allow set, refused with the reason “Blocked due to security vulnerabilities that can be exploited to break out of containers.”io_uringarrived in Linux 5.1, years after the profile was authored. That the allowlist already refused it before anyone made the decision explicit is the entire allowlist argument, demonstrated on a real profile in production on millions of hosts.
Kubernetes surfaces the same machinery through the pod and container securityContext.seccompProfile field (RuntimeDefault, Localhost, or Unconfined) — see SecurityContext and Pod Security Standards, the latter of which requires RuntimeDefault or Localhost at the Restricted level.
systemd SystemCallFilter=
systemd compiles SystemCallFilter= into a seccomp filter per service, and the semantics are worth stating exactly because they invert depending on one character (systemd.exec(5), fetched 2026-08-29; the setting dates to systemd v187):
- A plain list is an allow-list: “system calls executed by the unit processes except for the listed ones will result in the system call being denied.”
- “If the first character of the list is
~, the effect is inverted: only the listed system calls will be denied (deny-listing).” - Groups are named with a leading
@—@basic-io,@file-system,@network-io,@process,@signal,@privileged,@mount,@module,@reboot,@swap,@clock,@debug,@raw-io,@keyring,@sandbox, and others. @system-serviceis documented as “the recommended starting point for allow-listing system calls for system services … it excludes overly specific interfaces. For example, the following APIs are excluded:@clock,@mount,@swap,@reboot.”- The default action is death: “The default action when a system call is denied is to terminate the processes with a
SIGSYSsignal.”SystemCallErrorNumber=(systemd v209) changes it to a returned errno instead. - Some syscalls are implicitly permitted so that service startup can work at all: “The
execve(),exit(),exit_group(),getrlimit(),rt_sigreturn(),sigreturn()system calls and the system calls for querying time and sleeping are implicitly allow-listed.”
The two-line recipe the manual itself recommends is therefore:
[Service]
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERMwith two standard companions: SystemCallArchitectures=native — recommended because “on systems supporting multiple ABIs (such as x86/x86-64) it is recommended to turn off alternative ABIs for services, so that they cannot be used to circumvent the restrictions of this option” — and SystemCallFilter=~@mount alongside any of the namespacing options (PrivateTmp=, ProtectSystem=, ReadOnlyPaths=, …), “in order to prohibit the unit’s processes to undo the mappings.” Two operational warnings from the same page are easy to learn the hard way: blocking execve() makes the service unstartable by construction, and blocking the dynamic linker’s syscalls (“which include open(), openat() or mmap()”) “will make most programs typically shipped with generic distributions unusable.”
OpenSSH
sshd’s pre-authentication network-facing process — the one parsing bytes from an unauthenticated remote peer — runs under a hand-written cBPF filter, originally contributed by Will Drewry in 2012. It is a useful counter-example to “always use libseccomp,” and a compact model of a real policy: arch is checked first and mismatches are fatal; a short deny section returns EACCES for the stat/open/statx/SysV-IPC families so that library code probing for files fails gracefully instead of dying; then roughly forty SC_ALLOW entries name the syscalls the privilege-separated child genuinely needs; and futex and mmap get argument-level rules (only FUTEX_WAIT/WAKE/REQUEUE and friends; only PROT_READ|PROT_WRITE|PROT_NONE mappings). The default action is SECCOMP_RET_KILL, downgraded to SECCOMP_RET_TRAP only in an explicitly non-production debug build.
Browsers, gVisor, Android
Chromium’s renderer and GPU processes and Firefox’s content processes install aggressive filters, so that a compromised renderer — the component parsing untrusted HTML, JavaScript, images, and fonts — cannot directly attack the kernel. This was one of the first large-scale production uses of seccomp-BPF and a primary motivator for its design; the shared authorship with OpenSSH’s filter and the kernel’s own samples/seccomp/bpf-direct.c (also Drewry, copyright “The Chromium OS Authors”) is not a coincidence. Google’s gVisor runs its userspace kernel (the Sentry) under a tight seccomp filter, so that even a compromised Sentry has minimal reach into the host kernel — seccomp as last-line containment for the sandbox’s own privileged component. Android applies seccomp filters to app processes through zygote.
Uncertain
Verify: the specific claims about Chromium’s, Firefox’s, gVisor’s, and Android’s seccomp policies above (which processes are filtered, what the baseline policy permits, how denials are reported). Reason: the Chromium sandboxing design document at
chromium.googlesource.com/chromium/src/+/main/docs/linux/sandboxing.mdreturned HTTP 404 to bothcurland WebFetch during this task, and no equivalent primary source for the Firefox, gVisor, or Android policies was retrieved; these statements rest on general knowledge and on the shared authorship visible in the kernel’s own sample code, not on a source consulted here. To resolve: locate the current path of Chromium’s Linux sandboxing document (thedocs/linux/tree has been reorganised), readgvisor.dev’s security model page, and read AOSP’sbionic/libc/seccomppolicy sources. Add#uncertain.
libseccomp is how these are actually built
Almost nobody writes raw cBPF; OpenSSH is the exception that proves the rule. libseccomp compiles a high-level rule set — seccomp_init(SCMP_ACT_ERRNO(EPERM)), seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0), seccomp_load(ctx) — into an optimised cBPF program, handles the multi-ABI problem, and installs it. Its action wrappers map onto the kernel return actions one-for-one: SCMP_ACT_ALLOW, SCMP_ACT_ERRNO(n), SCMP_ACT_KILL, SCMP_ACT_KILL_PROCESS, SCMP_ACT_TRAP, SCMP_ACT_NOTIFY, SCMP_ACT_LOG (seccomp_rule_add(3)). Docker, containerd, runc, CRI-O, systemd, and most language runtimes build their filters through it. The exact return-action catalogue is in seccomp Filter Modes and Return Actions; the authoring walkthrough is Writing a seccomp Filter.
See Also
- seccomp and Syscall Filtering — sibling; the kernel-side mechanism: where in the entry path the filter runs, how
struct seccomp_datais built, the cBPF subset the verifier accepts, filter stacking and the constant-action bitmap - seccomp Filter Modes and Return Actions — the
SECCOMP_RET_*andSECCOMP_SET_MODE_*reference: exact constants, precedence, installation flags - seccomp User Notification — the supervisor protocol behind
SECCOMP_RET_USER_NOTIFand its races - Writing a seccomp Filter — the practical authoring walkthrough (raw cBPF and libseccomp)
- no_new_privs and Privilege Escalation Control — the interlock that makes seccomp safe for unprivileged processes
- Landlock — the complementary primitive for which files and endpoints, doing the path filtering seccomp structurally cannot
- Landlock vs seccomp vs Namespaces — explicit three-way comparison and the canonical composition
- POSIX Capabilities — the orthogonal “which privileged operations” axis
- Classic BPF vs Extended BPF — why seccomp filters are classic BPF, and what the kernel does with them at load time
- The vDSO Virtual Dynamic Shared Object — calls that may never reach the kernel, and so are never filtered
- The Compat Syscall Layer for 32-bit Binaries — the multi-ABI machinery behind the
archpitfall - SecurityContext · Pod Security Standards — how Kubernetes exposes seccomp profiles
- Linux Containers and Isolation MOC — how containers compose seccomp with capabilities, namespaces, cgroups
- Linux Security MOC — parent; seccomp is §E, “the system-call boundary is the security perimeter”