$linuxjunkies
>

Seccomp-BPF Syscall Filtering

Apply seccomp-BPF syscall filtering with libseccomp and systemd SystemCallFilter to harden Linux services using allowlists, denylists, and argument-level rules.

AdvancedUbuntuDebianFedoraArch12 min readUpdated June 7, 2026

Before you start

  • Root or sudo access on the target system
  • Basic familiarity with C compilation and systemd unit files
  • Kernel 3.17 or newer (seccomp(2) syscall required; all current LTS distros qualify)
  • auditd or journald access to observe denied syscall events during development

Seccomp (Secure Computing Mode) in BPF mode lets you attach a Berkeley Packet Filter program to a process that intercepts every system call and decides whether to allow, deny, or kill the caller. It is one of the most effective ways to shrink the attack surface of a service: even if an attacker achieves arbitrary code execution inside a process, they cannot make syscalls the filter disallows. Container runtimes, browsers, and modern init systems all rely on it. This guide covers the two filter strategies—allowlists and denylists—then shows how to write filters with libseccomp, and how to apply them declaratively with systemd's SystemCallFilter.

Allowlists vs Denylists

The choice of strategy determines your security posture and maintenance burden.

  • Denylist – start with all syscalls permitted, block known-dangerous ones (e.g., ptrace, kexec_load). Easy to deploy, but you must keep the list current as new dangerous syscalls appear in the kernel.
  • Allowlist – start with a default action of SCMP_ACT_KILL_PROCESS or SCMP_ACT_ERRNO, then explicitly permit the small set the process actually needs. Harder to build correctly but far more robust; an unknown syscall is denied by default.

For production hardening, prefer allowlists wherever you can profile the target process. Denylists are acceptable as a quick-win layer added to an existing service when you cannot yet profile it fully.

How Seccomp-BPF Works

When a process calls prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) or seccomp(SECCOMP_SET_MODE_FILTER, ...), the kernel loads a classic BPF program. On each subsequent syscall the BPF program runs in kernel space and returns one of several actions:

  • SECCOMP_RET_ALLOW – let it through.
  • SECCOMP_RET_ERRNO – return a synthetic error to userspace (process survives).
  • SECCOMP_RET_KILL_PROCESS – deliver SIGSYS and terminate the entire process group immediately.
  • SECCOMP_RET_TRAP – send SIGSYS so a signal handler (or tracer) can decide.
  • SECCOMP_RET_LOG – allow but log to the audit subsystem.

Filters are inherited by children and cannot be relaxed once set (without CAP_SYS_ADMIN and the no_new_privs bit, and even then only to add more restrictive filters).

Installing libseccomp

libseccomp provides a C API (and bindings for Python, Go, Rust) that compiles human-readable rules down to BPF bytecode so you never have to write raw BPF.

Debian / Ubuntu

sudo apt install libseccomp-dev libseccomp2 seccomp

Fedora / RHEL 9 / Rocky 9

sudo dnf install libseccomp-devel libseccomp

Arch Linux

sudo pacman -S libseccomp

Writing a Filter with libseccomp in C

The example below builds a strict allowlist for a hypothetical network daemon: it permits only the syscalls needed for I/O and process lifecycle, then kills on anything else.

cat > seccomp_demo.c <<'EOF'
#include <seccomp.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int apply_filter(void) {
    /* Default action: kill the process on any unlisted syscall */
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS);
    if (!ctx) return -1;

    /* Explicitly allow the syscalls this daemon needs */
    int rc = 0;
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read),   0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write),  0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(close),  0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(accept4),0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(recvmsg),0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(sendmsg),0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(epoll_wait), 0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk),    0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap),   0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(munmap), 0);
    rc |= seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(futex),  0);

    if (rc != 0) { seccomp_release(ctx); return -1; }

    rc = seccomp_load(ctx);   /* applies the filter to this process */
    seccomp_release(ctx);
    return rc;
}

int main(void) {
    if (apply_filter() != 0) {
        fprintf(stderr, "seccomp filter failed\n");
        return 1;
    }
    /* Daemon work happens here */
    write(STDOUT_FILENO, "filter active\n", 14);
    /* execve() is not in the allowlist — would trigger SIGSYS */
    return 0;
}
EOF
gcc -O2 -o seccomp_demo seccomp_demo.c -lseccomp
./seccomp_demo

Output will be filter active. If you add a call to execve() after apply_filter() the process is killed immediately with SIGSYS.

Argument-Level Filtering

libseccomp can match on individual syscall arguments, not just the syscall number. For example, allow open only with O_RDONLY:

# In C: restrict openat to read-only flags (flag argument is arg index 2)
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat), 1,
    SCMP_A2(SCMP_CMP_MASKED_EQ, O_WRONLY | O_RDWR, 0));

This level of granularity lets you permit a syscall family while blocking the dangerous flag combinations within it.

Profiling Which Syscalls a Process Uses

Before writing an allowlist you need to know what syscalls the process actually makes. Two practical methods:

  • strace: strace -f -e trace=all -o strace.log ./myservice, then extract unique syscall names with grep -oP '^[a-z0-9_]+' strace.log | sort -u.
  • systemd audit integration: Add SystemCallFilter=~ with an empty deny set plus SystemCallLog=yes in a unit drop-in to log all calls without blocking.

Run the process through every realistic code path—startup, normal operation, shutdown, and error handling—before finalising the list.

systemd SystemCallFilter

For services managed by systemd you rarely need to write C. The SystemCallFilter= directive compiles a seccomp filter at service start time.

Using Predefined Groups (Allowlist Mode)

systemd ships named syscall groups (prefixed with @). Specify what is allowed by listing groups; anything not listed gets ENOSYS by default.

# /etc/systemd/system/myservice.service.d/seccomp.conf
[Service]
SystemCallFilter=@system-call-filter
SystemCallFilter=@basic-io @io-event @ipc @network-io @process @signal
SystemCallErrorNumber=EPERM

View all available groups and their members:

systemd-analyze syscall-filter

Denylist Mode with the ~ Prefix

Prefix a group or name with ~ to block it while allowing everything else:

[Service]
# Block dangerous syscall classes, allow the rest
SystemCallFilter=~@debug @mount @reboot @swap @privileged @resources @clock

Real-World Example: Hardening nginx

sudo mkdir -p /etc/systemd/system/nginx.service.d
sudo tee /etc/systemd/system/nginx.service.d/seccomp.conf >/dev/null <<'EOF'
[Service]
SystemCallFilter=@system-call-filter
SystemCallFilter=@basic-io @io-event @ipc @network-io @process @signal @file-system
SystemCallFilter=~@debug @reboot @swap @module
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
EOF
sudo systemctl daemon-reload
sudo systemctl restart nginx

SystemCallArchitectures=native prevents the process from switching to a 32-bit personality to bypass the filter—always include it.

Verifying the Filter Is Active

# Check the process's seccomp status (replace PID)
grep Seccomp /proc/$(pgrep -n nginx)/status

A value of 2 means SECCOMP_MODE_FILTER is active. Value 0 means no filter is loaded.

# Inspect the BPF filter attached to the process
sudo bpftool prog list
# Or for a specific PID:
cat /proc/$(pgrep -n nginx)/fdinfo/* 2>/dev/null | grep seccomp
# For systemd units, confirm the filter appears in the unit's effective settings
systemctl show nginx | grep -i syscall

Troubleshooting

  • Service crashes immediately at start (SIGSYS): The filter is too strict. Switch the default action to SCMP_ACT_LOG (libseccomp) or use SystemCallLog=yes and audit logs to identify the blocked call. On systemd, temporarily set SystemCallErrorNumber=EPERM instead of kill so the service can log an errno.
  • Filter not taking effect: Confirm no_new_privs is set. systemd sets it automatically via NoNewPrivileges=yes; without it a setuid binary can drop the filter. Check with grep NoNewPrivs /proc/PID/status.
  • 32-bit compatibility issues: If you omit SystemCallArchitectures=native, a process could call personality() to switch ABI and evade filters keyed to 64-bit numbers. Always set it.
  • glibc wrapper surprises: Some libc functions use different underlying syscalls across versions or architectures (e.g., pthread_create uses clone or clone3). Profile on the exact runtime you deploy to.
  • Audit logging: Enable SCMP_ACT_LOG as the default during development and watch auditd or journalctl -k for denied calls before switching to SCMP_ACT_KILL_PROCESS.
tested on:Ubuntu 24.04Fedora 40Arch 2024.06Rocky 9.4

Frequently asked questions

Can a process remove or relax its own seccomp filter?
No. Once a filter is loaded it can only be made more restrictive, never relaxed. Additional filters added later are ANDed with the existing ones—the most restrictive rule always wins.
Does seccomp filtering affect performance?
The overhead is measurable but small—typically under 1% on syscall-heavy workloads on modern kernels with the constant-action optimization. It is negligible for most services and worth the security gain.
What is the difference between SystemCallFilter allow mode and deny mode in systemd?
When SystemCallFilter lists names or groups without a ~ prefix, systemd builds an allowlist and blocks everything else with ENOSYS. Prefixing entries with ~ builds a denylist against an otherwise-open default.
How do I handle multiarch or 32-bit binaries running under a 64-bit kernel?
Set SystemCallArchitectures=native in systemd units, or call seccomp_arch_remove() to strip non-native architectures from the libseccomp context. This prevents syscall number remapping attacks across ABIs.
Can seccomp filtering be bypassed via /proc or other kernel interfaces?
Seccomp only intercepts system calls, not /proc reads that do not require a gated syscall path. Combine it with other mechanisms—namespaces, capabilities dropping, and mandatory access control—for defense in depth.

Related guides