$RodHat_
Console Tips

Your process doesn't need 400 syscalls. seccomp-BPF lets you say so.

Published by

Your process doesn't need 400 syscalls. seccomp-BPF lets you say so.
Photo: AI-generated — no human photographer / RodHat AI Cover

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.

seccomp (SECure COMPuting) started in Linux 2.6.12 as a strict mode: once you enabled it, a process could only call read, write, _exit, and rt_sigreturn. Anything else got SIGKILL. Too blunt to use on anything real, but the idea was right.

In Linux 3.5, seccomp-BPF landed. 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.

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. 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.

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.

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.

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 — crash reporters and process supervisors see it differently.

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 system logger collect the denials, 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.

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.

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.


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.