Virtualization · Sandboxing

What a Seccomp Filter Can See of a Syscall

The fixed 64-byte record a seccomp filter receives, why the architecture field decides whether the syscall numbers mean anything, and what the filter cost buys.

A seccomp filter is not a policy engine. It is a small decision procedure attached to a thread, evaluated before the kernel dispatches a system call, and its entire input is a single fixed-size record. Everything a filter can express, and everything it provably cannot express, follows from the fields of that record and from the instruction set used to inspect them.

The filter reads one fixed struct

The kernel passes the filter a struct seccomp_data, defined in linux/seccomp.h. The layout is fixed by the ABI and by the offsets a filter is allowed to load from:

struct seccomp_data {
    int   nr;                    /* offset 0  : syscall number */
    __u32 arch;                  /* offset 4  : AUDIT_ARCH_* token */
    __u64 instruction_pointer;   /* offset 8  : userspace PC at the call */
    __u64 args[6];               /* offset 16 : six raw argument words */
};

That is 64 bytes. Six arguments is not a coincidence: it is the maximum any supported Linux architecture passes in registers, and the kernel copies them out of the register file before entering the syscall body. On x86-64 the six words arrive in rdi, rsi, rdx, r10, r8, and r9; r10 stands in for rcx because the syscall instruction itself clobbers rcx with the return address. A filter never sees the registers, only these copies.

The instruction_pointer field exists for one purpose: to let a filter distinguish a syscall issued from a known library routine from the same syscall issued from anonymous executable memory. Filters that use it are brittle, since a statically linked binary or an inlined wrapper shifts the address, but the field is there and some hardened runtimes key on it.

Architecture is not decoration

The arch field carries an AUDIT_ARCH_* token from linux/audit.h. For x86-64 it is AUDIT_ARCH_X86_64, numerically 0xC000003E: the ELF machine value 62, plus a bit marking 64-bit, plus a bit marking little-endian.

A filter that ignores this field is broken in a way that is easy to demonstrate. On x86-64, syscall number 1 is write. On i386, syscall number 1 is exit. A 32-bit process running on a 64-bit kernel enters through the compatibility path and presents AUDIT_ARCH_I386 with the i386 numbering, so a filter written only against x86-64 numbers will permit exit where it meant to permit write, and vice versa. The correct program therefore begins by comparing arch and returning a kill action on mismatch, then compares nr. Every allowlist generator that emits a bare syscall-number table is quietly assuming a single ABI.

The x32 ABI adds a second wrinkle: those syscalls are numbered with bit 30 set (__X32_SYSCALL_BIT, 0x40000000), so a filter comparing against small integers treats them as distinct numbers rather than as the same call in a different width.

Why the program is classic BPF, not a script

A filter is a classic BPF program, the same instruction set used by SO_ATTACH_FILTER for socket packet matching. That choice, made when the feature was proposed, determined several of its limits.

Classic BPF has no loops and no function calls, only forward jumps. The verifier therefore proves termination by construction: a program is a straight-line sequence of loads, conditional jumps, and one terminating return per path. There is no way to express iteration, which is why filters cannot walk a table of permitted numbers at runtime; the table must be unrolled into comparisons or moved into a map consulted from a different mechanism.

The instruction budget is BPF_MAXINSNS, 4096 instructions per program. Each filter is attached with seccomp(SECCOMP_SET_MODE_FILTER, 0, &prog) or the older prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog), and both require the calling thread to have no_new_privs set unless it holds CAP_SYS_ADMIN. The no_new_privs requirement is the load-bearing one: it guarantees that a program which installs a filter cannot subsequently gain privilege through a set-user-ID binary or a file capability, because such an elevation would run new code under the same filter with more authority than the filter was written to bound.

A minimal filter that permits write and kills everything else:

#include <linux/seccomp.h>
#include <linux/filter.h>
#include <linux/audit.h>
#include <sys/prctl.h>
#include <stddef.h>

#define DENY  SECCOMP_RET_KILL_PROCESS
#define ALLOW SECCOMP_RET_ALLOW

static struct sock_filter prog[] = {
    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, DENY),
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, ALLOW),
    BPF_STMT(BPF_RET | BPF_K, DENY),
};

static struct sock_fprog fprog = { .len = 7, .filter = prog };

offsetof rather than a literal is the safe habit: the kernel documents the field offsets, but naming them through the struct keeps the program readable and lets the compiler catch a mismatch.

The return value carries an action and a payload

A filter returns a 32-bit word. The upper 16 bits are the action; the lower 16 bits are action-specific data, reachable as SECCOMP_RET_DATA & 0x0000ffff. SECCOMP_RET_ERRNO | (EACCES & SECCOMP_RET_DATA) makes the syscall fail with EACCES without the kernel ever executing it. SECCOMP_RET_TRAP delivers SIGSYS. SECCOMP_RET_KILL_PROCESS terminates every thread in the process; the older SECCOMP_RET_KILL_THREAD terminates only the calling thread, which in a multithreaded program leaves a damaged process behind.

The distinction between returning EPERM and returning ENOSYS matters more than it looks. A program that receives ENOSYS from a syscall may conclude the kernel predates that syscall and fall back to an older interface. glibc does exactly this for several calls. Denying a modern syscall with ENOSYS therefore does not remove the capability; it redirects the caller to the legacy path the filter may not have considered. Denial by EPERM states a policy refusal and does not invite substitution.

The cost is per syscall, not per decision

Every filter in the chain runs on every system call the thread makes, in sequence, with no verdict cache. A 200-instruction filter costs its comparisons on each of the roughly several hundred syscalls a process makes per second at idle, and considerably more under I/O load. That is the axis of the trade: a longer, more precise allowlist buys tighter policy and pays in instructions executed in the syscall entry path, before any useful work happens. It is why generated allowlists are usually sorted by frequency and why the first comparison is often on the number rather than on argument values.

Filters cannot be removed once installed. There is no SECCOMP_SET_MODE_DISABLED path back to unrestricted execution for a thread that has a filter attached, which is the property that makes the mechanism usable as a sandbox primitive: the confinement is monotonic.

Where the model runs out

The record contains no memory contents. Argument values are raw words, so for openat a filter sees the address of the pathname, not the path. For ioctl it sees the request number and an address, which is why filters can meaningfully gate ioctl on the request code but not on the structure behind it. For write it sees a length and an address, which bounds nothing about what is written.

This is not an oversight to be worked around with more instructions; it is the boundary of the mechanism. The next two articles in this cluster take the consequences in order: Action Precedence When Seccomp Filters Stack covers how conflicting verdicts resolve, and Why a Seccomp Filter Cannot Inspect Syscall Arguments in Memory explains why argument filtering is confined to scalar comparisons. Programs that need the contents of a buffer have to move the decision to a supervisor, which is a different design with a different cost.

Edge cases that bite

A filter whose default action is ALLOW and which enumerates denied syscalls is a denylist, and denylists age badly: a syscall added in a later kernel is permitted by default, on a kernel the filter was never tested against. A filter whose default is KILL_PROCESS and which enumerates permitted calls is an allowlist, and allowlists fail loudly, which is the correct failure direction but requires the list to be complete enough for the program’s lazy paths, the ones taken on error, at exit, or under memory pressure. Those paths are exercised rarely, so an allowlist validated by a normal successful run is not validated at all.

The instruction_pointer field is filled from the architecture’s syscall entry path, so what it contains, whether a precise userspace address, an address in a vsyscall page, or nothing useful, depends on the architecture and on how the call entered the kernel. A filter that branches on it is architecture-specific in a way that is easy to miss. And a filter attached by one thread of a process is inherited by children created afterwards, but not retroactively applied to siblings; the synchronization call SECCOMP_FILTER_FLAG_TSYNC exists precisely because the per-thread model makes a process-wide policy a coordination problem rather than a single call.

References

  1. seccomp(2) — Linux manual page — man-pages project
  2. Seccomp filter — Linux kernel documentation — Linux kernel project
  3. Linux kernel source: kernel/seccomp.c — Linux kernel project