Mastering Linux Seccomp: System Call Filtering for Process Isolation and Hardening

Linux tutorial - IT technology blog
Linux tutorial - IT technology blog

Context & Why

Modern Linux 6.x kernels expose over 450 system calls. A typical Go microservice serving JSON over HTTP needs roughly 40 of them. It never needs to call ptrace, reboot, mount, or load kernel modules via init_module. Even so, standard unprivileged processes can still dispatch almost any syscall by default.

Kernel bugs remain among the most reliable paths from an application exploit to root. When attackers achieve remote code execution in a web worker, they immediately probe the kernel interface to escalate privileges. If that compromised process cannot open raw sockets, spawn subshells, or trace peers, an attacker hits a dead end.

Secure Computing Mode (Seccomp) solves this problem at the kernel boundary. First merged in Linux 2.6.12 and upgraded with BPF filtering in version 3.5, Seccomp inspects syscall numbers and arguments before execution. If a thread invokes a forbidden call, the kernel immediately intervenes. It can terminate the process, return an EPERM errno, or write an audit record to syslog.

Seccomp differs fundamentally from Mandatory Access Control systems like SELinux or AppArmor. Those tools evaluate path names, network endpoints, and security labels. Seccomp operates one layer deeper. It strips away the vocabulary a binary uses to speak to the kernel.

Installation

Modern enterprise distributions ship with Seccomp enabled in the kernel. You do not need third-party modules or custom drivers. You will, however, need user-space development headers, runtime libraries, and tracing tools to build and test profiles.

On Debian 12 and Ubuntu 22.04 or 24.04:

sudo apt-get update
sudo apt-get install -y libseccomp-dev seccomp strace auditd

On RHEL 9, Rocky Linux, or Fedora:

sudo dnf install -y libseccomp-devel strace audit

Verify that your active kernel has Seccomp support compiled in:

grep -i seccomp /boot/config-$(uname -r)

Ensure both CONFIG_SECCOMP=y and CONFIG_SECCOMP_FILTER=y appear in the output. Standard cloud images on AWS, GCP, and Azure enable both out of the box.

Configuration

You can deploy Seccomp at three distinct layers: compiled into C or Go binaries, enforced via systemd unit files, or applied as container profiles in Docker and containerd. Pick the layer that fits your operational model.

1. Native Application Filtering with libseccomp

Embedding filters directly into C or C++ services delivers portable security. The restrictions follow the binary anywhere it runs, even without container runtimes or systemd wrappers. The following program drops into a strict allowlist right after startup, allowing only standard I/O and process exit:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <seccomp.h>
#include <sys/prctl.h>

int main(void) {
    /* Default to killing the entire process on any unlisted call */
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS);
    if (!ctx) {
        perror("seccomp_init");
        exit(EXIT_FAILURE);
    }

    /* Explicitly permit necessary syscalls */
    seccomp_rule_add(ctx, SCMP_ACT_PERMIT, SCMP_SYS(read), 0);
    seccomp_rule_add(ctx, SCMP_ACT_PERMIT, SCMP_SYS(write), 0);
    seccomp_rule_add(ctx, SCMP_ACT_PERMIT, SCMP_SYS(exit), 0);
    seccomp_rule_add(ctx, SCMP_ACT_PERMIT, SCMP_SYS(exit_group), 0);
    seccomp_rule_add(ctx, SCMP_ACT_PERMIT, SCMP_SYS(fstat), 0);

    /* Load the compiled BPF program into the kernel */
    if (seccomp_load(ctx) < 0) {
        perror("seccomp_load");
        seccomp_release(ctx);
        exit(EXIT_FAILURE);
    }
    seccomp_release(ctx);

    printf("Filter loaded. Standard write operations work.\n");

    /* Attempt a blocked call: getpid() */
    printf("Invoking getpid()...\n");
    pid_t pid = getpid();
    printf("PID: %d (Unreachable)\n", pid);

    return 0;
}

Compile and run the test binary:

gcc -O2 -o seccomp_demo seccomp_demo.c -lseccomp
./seccomp_demo

The program writes its initial status line to standard output. Once it calls getpid(), the kernel instantly terminates the thread with Bad system call (core dumped). The process never reaches the final print statement.

2. Hardening Daemons with systemd

Legacy daemons and closed-source tools rarely expose internal hook points. For these workloads, systemd provides declarative filtering via the SystemCallFilter directive with near-zero performance overhead (under 1% in most benchmarks).

Save this unit to /etc/systemd/system/api-gateway.service:

[Unit]
Description=Internal API Gateway
After=network.target

[Service]
Type=simple
User=www-data
Group=www-data
ExecStart=/usr/local/bin/api-gateway --port 8080
Restart=always

# Enforce native architecture and syscall allowlists
SystemCallArchitectures=native
SystemCallFilter=@system-service @network-io
SystemCallFilter=~@privileged @resources @obsolete @mount @reboot
SystemCallErrorNumber=EPERM

[Install]
WantedBy=multi-user.target

Systemd bundles common syscalls into pre-defined sets like @system-service and @network-io. The tilde prefix (~) subtracts dangerous groups from that baseline. If the application attempts a blocked action, systemd returns EPERM rather than killing the worker ungracefully.

Apply the unit changes:

sudo systemctl daemon-reload
sudo systemctl restart api-gateway.service

3. Restricting Containerized Services with Docker Profiles

Docker blocks roughly 44 syscalls by default, including reboot, clock_settime, and kexec_load. That default balances broad compatibility with baseline security. For critical microservices, replace that broad net with a tailored allowlist.

Save the following policy as strict-profile.json:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": [
    "SCMP_ARCH_X86_64"
  ],
  "syscalls": [
    {
      "names": [
        "read",
        "write",
        "open",
        "openat",
        "close",
        "fstat",
        "futex",
        "epoll_wait",
        "epoll_ctl",
        "exit_group"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Start a container with the custom filter attached:

docker run --rm -it \
  --security-opt seccomp=strict-profile.json \
  alpine:latest sh

Commands that rely on unlisted system calls will now fail with permission errors directly inside the container shell.

Verification & Monitoring

Never push strict allowlists straight into production. If you guess wrong on required syscalls, your service will crash the moment it hits an uncommon edge case. Use this profiling and monitoring workflow to prevent outages.

Tracing Required Syscalls

Map the binary’s actual runtime activity before locking anything down. Run the service under strace during a synthetic benchmark run:

strace -c -f -S name /usr/local/bin/api-gateway --port 8080

Drive at least 5,000 to 10,000 real HTTP requests against the process. Exercise database connections, health endpoints, metrics scrapers, and error-handling routines. When you terminate strace with Ctrl+C, it outputs an execution table showing total calls, errors, and elapsed time per syscall. Build your allowlist directly from that report.

Checking Process Seccomp State

Confirm that your filter is actively protecting the target PID via the proc filesystem:

grep -i seccomp /proc/$(pgrep -f api-gateway)/status

The kernel reports one of three integer states for Seccomp:

  • 0 (Disabled): No syscall filtering is active on this process.
  • 1 (Strict): Legacy mode enabled via prctl(PR_SET_SECCOMP, 1). The process may only use read, write, _exit, and sigreturn.
  • 2 (Filter): Active BPF filters are evaluating syscalls via seccomp(SECCOMP_SET_MODE_FILTER, ...).

Monitoring Filter Violations in Audit Logs

During testing, set rule actions to SCMP_ACT_LOG. The kernel lets the call proceed while logging every violation, giving you clean telemetry without risking uptime.

Search recent audit records:

sudo ausearch -m SECCOMP -ts recent

Triggered filters generate log entries in /var/log/audit/audit.log:

type=SECCOMP msg=audit(1708701234.567:890): auid=1000 uid=33 gid=33 ses=1 \
subj=unconfined pid=1423 comm="api-gateway" exe="/usr/local/bin/api-gateway" \
syscall=101 compat=0 ip=0x7f9a1234 arch=c000003e code=0x0

Translate the raw decimal syscall index with ausyscall:

ausyscall x86_64 101

Syscall 101 translates to ptrace on x86_64. If an external library legitimately requires that operation, add it to your profile. If an unvetted dependency attempted process injection, your Seccomp filter successfully blocked the exploit at zero runtime cost.

Share: