Your process doesn't need 400 syscalls. seccomp-BPF lets you say so.
Published by RodHat · Updated

The Linux kernel ships something like 440 syscalls on x86-64. A typical server process uses maybe 40–60 of them to do its actual job. The other 380 are sitting there, callable, by anything running as that UID, including whatever code an attacker just smuggled in through your input parsing. That gap is what seccomp closes.
Strict mode vs filter mode
seccomp (SECure COMPuting) started in Linux 2.6.12 as a strict mode: once you enabled it, a thread could only call read(2), write(2), _exit(2) (not exit_group(2), so glibc’s exit() is off the table), and sigreturn(2). Anything else got SIGKILL. It was built for exactly one job: fork, read untrusted bytecode from a pipe, crunch numbers, write the answer back. Useful for that and nothing else. One gotcha even there: the jailed thread can still use sigreturn to block every signal except SIGKILL and SIGSTOP, so alarm(2) won’t reliably stop a runaway. Use RLIMIT_CPU or a timer that delivers SIGKILL.
In Linux 3.5, seccomp-BPF landed as filter mode. Instead of a hardcoded allowlist, you load a classic BPF program into the kernel that runs on every syscall your process attempts. The filter sees the syscall number, the architecture, and up to six arguments. It returns an action. The whole thing runs in kernel space before the syscall executes: there’s no race, no TOCTOU, no way for the process to skip it once it’s loaded. This is the version you actually use, and what everyone means when they say “seccomp”: container runtimes, browsers, anything chewing on attacker-controlled input.
Both modes originally went in through prctl(PR_SET_SECCOMP, ...). Linux 3.17 added a dedicated seccomp(2) syscall with SECCOMP_SET_MODE_STRICT and SECCOMP_SET_MODE_FILTER operations; it’s a superset of the prctl interface. You can see any process’s current mode in the Seccomp: line of /proc/<pid>/status.
The privilege model
To load a seccomp filter, you have two options. One is CAP_SYS_ADMIN. The other, and this is the one you want for services dropping privileges, is setting PR_SET_NO_NEW_PRIVS before calling seccomp():
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
This prevents the process or any of its descendants from gaining privileges through execve: no setuid binaries will work, no file capabilities will apply. It’s not revocable. In exchange, you’re allowed to install seccomp filters without root.
The reason the kernel insists: without it, an unprivileged process could load a filter that makes some syscall fail in a way a setuid binary doesn’t expect, then exec that binary and let it trip over its own error handling with root privileges. Order matters. Set the bit before you load the filter; if you have neither it nor CAP_SYS_ADMIN, seccomp() (or prctl(PR_SET_SECCOMP, ...)) fails with EACCES and you’re running unfiltered while thinking you aren’t. Check the return value.
Every well-behaved privilege-dropping daemon does this immediately after calling setuid(). Most don’t then follow up with seccomp filters, which is the missed opportunity.
Filters are inherited across fork(), clone(), and execve(). A parent’s filter applies to every child process. Crucially, filters are additive and monotonically restrictive: a child can install a more restrictive filter on top of the parent’s, but never a more permissive one. Once you’ve loaded a filter that blocks ptrace, neither the process nor any child can ever re-enable it. That’s not a bug.
What the BPF program sees
Every syscall invocation passes a seccomp_data struct to the filter:
struct seccomp_data {
int nr; /* syscall number */
__u32 arch; /* AUDIT_ARCH_* constant */
__u64 instruction_pointer; /* at time of syscall */
__u64 args[6]; /* syscall arguments */
};
The BPF program loads fields from this struct, compares, branches, and returns a 32-bit value encoding the action to take. The kernel evaluates all installed filters (most recently loaded first) and applies the most restrictive result across all of them. Priority from most to least restrictive: KILL_PROCESS > KILL_THREAD > TRAP > ERRNO(n) > USER_NOTIF > TRACE > LOG > ALLOW.
Always check arch before checking nr. On a 64-bit kernel, a 32-bit process can invoke syscalls via the old int 0x80 path, and the syscall numbers are completely different. A filter that allows nr == 2 on x86-64 is allowing open. On x86 (32-bit ABI), nr == 2 is fork. If you skip the arch check, a process that loads the 32-bit ABI can bypass your filter entirely. This is not theoretical; it has been exploited.
In raw BPF, every correct filter opens the same way. Wrong arch? Kill it before you even look at the syscall number:
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
/* ... syscall-number checks ... */
The return values
What each action actually does:
SECCOMP_RET_ALLOW: the syscall runs normally.SECCOMP_RET_ERRNO | n: the syscall is never executed; the caller gets-1witherrnoset ton.SECCOMP_RET_KILL_PROCESS(Linux 4.14+): the whole process dies with a core dump, as though by SIGSYS. Signal handlers don’t get a vote.SECCOMP_RET_KILL_THREAD(the oldSECCOMP_RET_KILL): only the calling thread dies; the rest keep running. In a multithreaded program that leaves the process in a half-dead, possibly corrupt state. UseKILL_PROCESS.SECCOMP_RET_TRAP: the syscall isn’t executed and the thread gets a SIGSYS it can catch. A handler can log the violation, dump state, and exit cleanly, which makes it a decent debugging tool for a filter you don’t trust yet.SECCOMP_RET_USER_NOTIF(Linux 5.0+): hands the decision to a user-space supervisor process.SECCOMP_RET_TRACE: notifies an attachedptracetracer.SECCOMP_RET_LOG(Linux 4.14+): allows the syscall and logs it.
When several filters are stacked, the precedence order listed above decides the winner. The SECCOMP_RET_* names are the kernel’s; libseccomp spells them SCMP_ACT_*.
libseccomp: the API that doesn’t make you write BPF by hand
You can write raw cBPF bytecode. I’ve done it. It looks like assembly from 1992 and produces bugs that are extremely difficult to spot. The canonical example in the kernel documentation has four separate VALIDATE_ARCHITECTURE macros before it’ll let you near the actual policy. Skip this path unless you’re debugging the library or writing one.
libseccomp wraps the raw interface in a sane C API. Install libseccomp-dev (or libseccomp-devel on rpm systems).
#include <seccomp.h>
#include <sys/prctl.h>
#include <stdio.h>
#include <errno.h>
int apply_filter(void) {
scmp_filter_ctx ctx;
int rc;
/* Default action: return EPERM for any syscall not explicitly allowed */
ctx = seccomp_init(SCMP_ACT_ERRNO(EPERM));
if (!ctx)
return -1;
/* Allow these syscalls unconditionally */
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(close), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(fstat), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(munmap), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigreturn), 0);
/* Allow open only for read-only access: check flag argument */
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat), 1,
SCMP_A2(SCMP_CMP_MASKED_EQ, O_WRONLY | O_RDWR, 0));
rc = seccomp_load(ctx);
seccomp_release(ctx);
return rc;
}
int main(void) {
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
if (apply_filter() < 0) {
perror("seccomp_load");
return 1;
}
/* From here, only the allowed syscalls work */
printf("filter loaded\n");
/* This will return EPERM: socket() is not in our list */
int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0)
perror("socket"); /* Permission denied */
return 0;
}
Compile with -lseccomp. Run it and watch socket() return EPERM like the kernel is reading your mind.
SCMP_SYS() is a macro that translates a syscall name to the correct number for the current architecture. seccomp_rule_add() with a nonzero argument count applies the rule only when the argument comparison matches: the last argument here says “allow openat only if args[2] & (O_WRONLY | O_RDWR) == 0”, which is the read-only flag check. libseccomp also emits the arch check from the previous section for you, so the int 0x80 bypass is handled without you thinking about it.
Where you call it matters. Load the filter after you’ve opened your files and sockets and finished one-time setup, and before you touch a single byte of untrusted input. Everything before seccomp_load() is free; everything after has to be on the list.
Auditing what your binary actually needs
Before you can write a filter, you need to know which syscalls the program actually calls. strace -c gives you the counts:
strace -c -f ./myservice
# ...runs until you kill it or it exits...
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
34.21 0.000823 10 82 read
28.17 0.000677 9 75 write
12.41 0.000298 7 40 epoll_wait
8.93 0.000214 10 21 accept4
...
-f follows forks. The output is a sorted list of everything the process called. This is your starting point. See the strace tip for how to narrow this to just the syscalls without the noise.
For a more targeted audit, trace just syscall names:
strace -f -e trace=all -o /tmp/trace.txt ./myservice
awk '{print $2}' /tmp/trace.txt | grep -oP '^\w+' | sort -u
That gives you an alphabetical list of every syscall your process tree used in that run. Your seccomp allowlist should be a superset of this list. The tricky part is coverage: you need to trace through all code paths, not just the happy path. Startup, error handling, signal handling, and shutdown paths all potentially use different syscalls. A test suite that never exercises a failed write will never show you the syscalls that happen after one.
Two things cut the other way. strace sees the process from its first instruction, so the trace includes setup calls that happen before seccomp_load() and that your filter will never see; you don’t have to allow those. And filter on what the kernel actually receives, not what the C code says: glibc’s exit() is exit_group(2), fork() is clone(2), and since glibc 2.26 open() is openat(2) everywhere. strace shows the real names, which is one more reason to trust it over reading source. Meanwhile clock_gettime and gettimeofday usually never enter the kernel at all on vDSO architectures, so seccomp can’t filter them.
For a long-running service, run it under strace -c in a test environment for several minutes of real load before building the filter. Don’t guess. A filter that’s too tight will cause your service to return EPERM from a syscall it needs, which usually manifests as a confusing error message three layers up from where the actual failure happened.
Default action: ERRNO vs KILL
Two reasonable defaults exist. SCMP_ACT_ERRNO(EPERM) returns an error to the calling process: the process stays alive, the syscall just fails. SCMP_ACT_KILL_PROCESS terminates the process immediately.
For development and initial deployment, use SCMP_ACT_ERRNO(EPERM). You will have missed syscalls in your audit. Better to get an error log entry than to have your service die silently under load. Once the filter is stable and you’ve watched the error counters for a few weeks with nothing unexpected, switch to SCMP_ACT_KILL_PROCESS for production hardening. The signal is SIGSYS, not SIGKILL, so crash reporters and process supervisors see it differently.
ERRNO has a permanent use too, not just a training-wheels one: when you’d rather sandboxed code degrade than die. Return ENOSYS for a syscall you don’t want and a well-written library will treat it as “kernel doesn’t have that” and fall back, instead of falling over.
There is also SCMP_ACT_LOG, which allows the syscall but logs it. This is how you audit what a running process is doing that your filter would block, without actually blocking anything yet. Run with SCMP_ACT_LOG as the default, let the audit log collect the hits (ausearch -m seccomp, or grep /var/log/audit/audit.log), build your allowlist from what actually fires, then switch to SCMP_ACT_ERRNO. This is the right workflow for a service you didn’t write and don’t fully understand. Do not ship with LOG as the default. A filter that allows everything is a logging feature, not a sandbox.
Argument filtering for the syscalls you have to allow
Some syscalls are dangerous in certain modes but necessary in others. mmap with PROT_EXEC is how code injection happens; mmap without it is how allocators work. You can allow one and not the other:
/* Allow mmap without PROT_EXEC */
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 1,
SCMP_A2(SCMP_CMP_MASKED_EQ, PROT_EXEC, 0));
/* Block mmap with PROT_EXEC */
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(mmap), 1,
SCMP_A2(SCMP_CMP_MASKED_EQ, PROT_EXEC, PROT_EXEC));
Same logic applies to socket(): you can allow AF_INET and AF_UNIX while blocking AF_NETLINK and AF_PACKET. open/openat flags. prctl operations. Wherever the syscall has a “what kind of operation” argument, you can split on it.
Here’s the trap: you can compare flags, integers, and bit masks. You cannot follow pointers. The filter sees the raw 64-bit register value, and for any argument that’s a pointer, that’s a userspace address BPF has no way to dereference. So no, you cannot allow openat("/tmp/safe", ...) while denying openat("/etc/shadow", ...). The path is a pointer; seccomp sees a number. Path-based policy is Landlock’s job (Linux 5.13+): seccomp says which syscalls, Landlock says which files, and a real sandbox uses both. See Landlock for the filesystem half.
Don’t over-engineer this. A tight syscall number allowlist, even without argument filtering, removes the vast majority of the attack surface. Argument filtering is for cases where the syscall itself is inherently high-risk and you need to allow a restricted subset.
Combining with namespaces and cgroups
seccomp, Linux namespaces, and cgroups v2 are orthogonal controls that compose cleanly. namespaces limit what a process can see: filesystem, network, PIDs, users. cgroups limit what it can consume: CPU, memory, I/O. seccomp limits what it can call. Docker uses all three: it applies a default seccomp profile that blocks ~44 syscalls, a user namespace, and cgroup limits in concert.
You don’t need a container runtime to compose these. A service that sets PR_SET_NO_NEW_PRIVS, drops into a new network namespace (no raw sockets, no network stack at all except loopback), puts itself in a memory-limited cgroup, and loads a seccomp filter that blocks ptrace, process_vm_readv, perf_event_open, and exec is substantially harder to exploit and pivot from than one that relies purely on “it runs as nobody.” See ip netns for network isolation and cgroups v2 for resource limits for the other two legs of this.
What to block regardless
Even if you’re not ready to whitelist-only, there’s a short list of syscalls worth blocking in any security-sensitive process:
/* Kernel module loading: you're not doing this from user services */
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(init_module), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(finit_module), 0);
/* Raw memory access for live process inspection/modification */
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(ptrace), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(process_vm_readv), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(process_vm_writev), 0);
/* Kernel keyring: rarely needed, often abused */
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(keyctl), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(add_key), 0);
/* Performance monitoring: exploitation vector for some hardware bugs */
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(perf_event_open), 0);
These are the Docker default seccomp profile’s high-confidence blocks: the syscalls that have historically been exploitation targets and have essentially no legitimate use in application code. Blocking them with SCMP_ACT_ERRNO(EPERM) is safe as a starting point. Note that blocking perf_event_open breaks perf and BPF-backed tools like bpftrace; that’s intentional on a production service, where you use ftrace for tracing that doesn’t require it.
How this compares to pledge
OpenBSD has pledge. You call it once with a string like "stdio inet" and the kernel enforces that for the life of the process. Three lines of code, a five-minute retrofit. The coarseness is the point: inet covers all the network calls because pledge is optimizing for quick, correct, hard-to-misuse sandboxing, and it trades precision for near-zero implementation cost.
seccomp-BPF is a two-day job the first time you do it right. You iterate with strace and SCMP_ACT_LOG, deal with architecture variants, think through every code path, and wire libseccomp up correctly. The payoff is precision down to individual flag bits. Chromium’s sandbox is built on it, and so is every container runtime’s default profile.
For most daemons, pledge-level coarseness would be fine, and on OpenBSD it’s free. For sandboxing untrusted code at browser scale, a container runtime, or a language VM running arbitrary user scripts, seccomp’s precision is worth the pain. Linux’s answer is more powerful. OpenBSD’s is harder to get wrong. Thirty-some years of this and we still haven’t agreed on one answer, which tells you something about how hard the problem actually is. The pledge/unveil tip covers the BSD side, including Capsicum. And if you’re building a daemon that sandboxes worker processes, pidfds are the other piece: they let you manage those children without racing PID reuse.
The kernel documentation lives at Documentation/userspace-api/seccomp_filter.rst in the kernel source tree. Kerrisk’s The Linux Programming Interface covers both the original strict mode and seccomp-BPF in thorough detail, with the syscall table and argument encoding you need to write raw filters. Gregg’s BPF Performance Tools covers the eBPF side, which is not what seccomp filters use internally (they use cBPF), but understanding eBPF helps clarify why the kernel deliberately kept seccomp on the older, more constrained instruction set.