seccomp-bpf: syscall filtering without pledge
Published by RodHat

OpenBSD has pledge. You call it once, pass it a string like "stdio inet", and the kernel enforces that allowlist for the lifetime of the process. It takes five minutes to retrofit into an existing program and about three lines of actual code.
Linux has seccomp-bpf. It is more powerful than pledge. It is also substantially harder to get right. These two facts are related.
The two modes
seccomp shipped in Linux 3.5 (July 2012) with two modes.
SECCOMP_SET_MODE_STRICT: the original. Enable it and the process can only call read(2), write(2), _exit(2), and sigreturn(2). Anything else sends SIGKILL. This was designed for sandboxing untrusted computation in a subprocess where you fork, load untrusted code, install strict mode, exec. It’s useful for that exact case and nothing else.
SECCOMP_SET_MODE_FILTER: this is what people mean when they say seccomp. You supply a BPF program that runs in the kernel on every syscall and returns a verdict. Container runtimes, browsers, and any daemon that processes attacker-controlled data and cares about it use this mode.
PR_SET_NO_NEW_PRIVS first
Before the kernel will accept a seccomp filter, you have to set:
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
This makes the process permanently unable to gain privileges through setuid binaries or file capabilities, for the life of the process. It cannot be unset. It is not optional unless you are running as root, which you are not in any code that needs sandboxing.
The reason it exists: without it, sandboxed code could exec a setuid binary and escape the filter entirely. PR_SET_NO_NEW_PRIVS closes that hole. Set it before the filter. Set it early. Do not forget it; the kernel will reject your prctl(PR_SET_SECCOMP, ...) call with EACCES if you do.
What the BPF program sees
The filter program gets a seccomp_data struct:
struct seccomp_data {
int nr; /* syscall number */
__u32 arch; /* AUDIT_ARCH_* constant */
__u64 instruction_pointer;
__u64 args[6]; /* syscall arguments */
};
The arch field is not optional to check. On a 64-bit kernel, a 32-bit process can issue syscalls through the legacy int 0x80 path. The 32-bit and 64-bit ABIs use different syscall numbers. If your filter only inspects nr without validating arch, a 32-bit binary can bypass it by picking a 32-bit syscall number that happens to map to something your filter allows in the 64-bit table.
Every correct filter starts with this:
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 ... */
Wrong arch? Kill the process before looking at the syscall number.
The return values
The BPF program returns one of:
SECCOMP_RET_ALLOW: the syscall proceeds normally.SECCOMP_RET_ERRNO | value: the syscall fails immediately witherrnoset tovalue. The kernel never runs the syscall.SECCOMP_RET_KILL_THREAD: SIGSYS kills the calling thread.SECCOMP_RET_KILL_PROCESS: SIGSYS kills the entire process. Added in Linux 4.14. Prefer this over KILL_THREAD.SECCOMP_RET_TRAP: sends SIGSYS to the thread. If you have a SIGSYS handler, it can log the violation, dump state, and exit cleanly. Useful for debugging a filter before you trust it.SECCOMP_RET_LOG: allows the syscall but writes to the audit log. Available since 4.14. Use this during development only, never in production.
For almost everything: allow the calls you expect, KILL_PROCESS for anything else. The ERRNO path is useful when you want sandboxed code to fail gracefully rather than crash: return ENOSYS and a well-written library will degrade instead of dying.
Raw BPF is miserable. Use libseccomp.
Writing a struct sock_filter array by hand means managing jump offsets manually, knowing syscall numbers for every architecture you support, and debugging errors that surface at runtime as mysterious SIGKILL. You will get it wrong. Use libseccomp.
#include <seccomp.h>
int install_filter(void) {
scmp_filter_ctx ctx;
int rc;
ctx = seccomp_init(SCMP_ACT_KILL_PROCESS);
if (!ctx)
return -1;
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(exit_group), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 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(mprotect), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(futex), 0);
rc = seccomp_load(ctx);
seccomp_release(ctx);
return rc;
}
SCMP_SYS(read) resolves to the correct syscall number for your build architecture. libseccomp inserts the arch check automatically. The generated filter handles 32-bit compat mode correctly without you thinking about it.
Call this after you have opened files and sockets, finished one-time setup, and set PR_SET_NO_NEW_PRIVS. Before you start touching untrusted input.
Finding what your program actually calls
The hardest part of a seccomp filter is building the allowlist. Your program calls more syscalls than you think, and getting one wrong means a crash on a code path you did not test.
strace -c gives a sorted summary:
$ strace -c ./daemon < testinput
% time seconds usecs/call calls syscall
----------------------------------------------
31.4 0.000452 8 56 read
22.7 0.000327 6 54 write
12.1 0.000174 7 25 mmap
9.8 0.000141 3 47 close
8.3 0.000120 5 24 futex
6.2 0.000089 4 22 mprotect
...
That is your starting allowlist. Be careful: strace attaches before your filter is in place, so the trace includes setup calls your filter will never see. Run representative workloads to catch syscalls that only appear on specific code paths. A test suite that never exercises the error handling will miss the calls that happen on write failures.
The other approach: start with SCMP_ACT_LOG as the default action instead of SCMP_ACT_KILL_PROCESS. The program runs normally. Every unfiltered syscall lands in the audit log. Read it with ausearch -m seccomp or directly from /var/log/audit/audit.log. Build the allowlist from what shows up, then switch to SCMP_ACT_KILL_PROCESS when you are confident. Never ship with LOG as the default; it is a development tool.
The argument filtering trap
libseccomp supports argument filtering:
/* Disallow 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));
You can check flags, integers, and bit masks. You cannot follow pointers. The filter sees the raw 64-bit argument value; for syscalls that take pointers, that is a userspace address, and BPF cannot dereference it.
This means you cannot use seccomp to allow open("/tmp/safe", ...) while blocking open("/etc/shadow", ...). The path argument is a pointer. seccomp cannot read it. For path-based access control, that is Landlock (since Linux 5.13). seccomp handles the syscall-level policy; Landlock handles the filesystem. They compose. A real sandbox uses both.
How this compares to pledge
pledge is a five-minute retrofit. You look up the right promise strings, add one call, done. The coarseness is by design: “inet” covers all network syscalls because pledge’s goal is quick, correct, hard-to-misuse sandboxing. It trades precision for a 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, handle architecture variants, think about every code path, and wire up libseccomp correctly. The payoff is that you can be precise at the level of individual flag bits. Chromium’s sandbox is built on seccomp-bpf. Docker ships a default profile of roughly 300 syscall allow/deny rules. That precision is real.
For most daemons, pledge’s coarseness is fine and the implementation cost is genuinely nil. For anything sandboxing untrusted code at browser scale, or a container runtime, or a language VM running arbitrary user scripts, seccomp-bpf’s precision is worth the pain.
They are right for different threat models. Linux’s is more powerful. OpenBSD’s is harder to get wrong. Thirty-some years of this and we still have not agreed on one answer, which probably tells you something about how hard the problem actually is.
The pledge/unveil post covers the OpenBSD approach and the Capsicum comparison if you want the BSD side of the same argument.
The pidfd post covers subprocess lifecycle management, which is the other piece of writing a daemon that sandboxes worker processes without races on PID reuse.