How seccomp Works at the Kernel Level
seccomp operates as a hook inside the kernel's syscall path. When a process makes a syscall, the kernel checks whether a seccomp filter is attached to that thread. If one exists, the BPF bytecode in the filter runs against the syscall number and its arguments before the syscall executes.
There are two modes. Mode 1 (SECCOMP_MODE_STRICT) allows only read, write, exit, and sigreturn. It is suitable for sandboxed computation engines but almost nothing else. Mode 2 (SECCOMP_MODE_FILTER) is what you actually use: you attach a cBPF program that returns one of several actions per syscall.
The available actions, from most permissive to most restrictive, are: SECCOMP_RET_ALLOW (let it through), SECCOMP_RET_ERRNO (return a fake error), SECCOMP_RET_TRACE (notify a tracer via ptrace), SECCOMP_RET_LOG (allow but log via audit), SECCOMP_RET_KILL_THREAD (terminate the thread with SIGSYS), and SECCOMP_RET_KILL_PROCESS (terminate the entire process, added in kernel 4.14). For production hardening, SECCOMP_RET_KILL_PROCESS is preferable to KILL_THREAD because a determined attacker can sometimes recover from thread death.
Filters are inherited across fork and execve unless the NO_NEW_PRIVS bit is set before loading. Always set NO_NEW_PRIVS first with prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) or the equivalent seccomp() syscall flag. Without it, an unprivileged process cannot install a seccomp filter at all.
# Verify seccomp support in your running kernel
grep CONFIG_SECCOMP /boot/config-$(uname -r)
# Expected output:
# CONFIG_SECCOMP=y
# CONFIG_SECCOMP_FILTER=y
# Check if a running process has a seccomp filter
cat /proc/$(pidof nginx)/status | grep Seccomp
# 0 = no filter, 1 = strict, 2 = filter mode
Profiling Syscalls Before Writing a Filter
Writing a seccomp filter blind is the fastest way to break your application. You need a syscall trace first. We use three tools depending on the situation: strace for development, perf for production-grade tracing without the overhead, and sysdig when we need argument-level detail.
strace is the most direct. Run your application through a realistic workload and collect every unique syscall number. The -e trace=all flag captures everything; pipe through sort and uniq to get a clean list.
For a containerized workload where strace overhead is unacceptable, use perf with the raw_syscalls:sys_enter tracepoint. This adds negligible overhead and works on production hardware.
Once you have the list, cross-reference it against the syscall table for your architecture. The table lives at /usr/include/asm/unistd_64.h on x86_64. Remember that 32-bit compatibility syscalls (ia32) have different numbers and require separate filtering if your container might run 32-bit binaries.
# Method 1: strace - get unique syscall names for a process
strace -f -e trace=all -o /tmp/nginx-trace.txt nginx -t 2>&1
grep -oP '(?<=^)[a-z_0-9]+(?=\()' /tmp/nginx-trace.txt | sort -u > /tmp/nginx-syscalls.txt
wc -l /tmp/nginx-syscalls.txt
# We got 43 unique syscalls for a basic nginx instance
# Method 2: perf - low overhead production tracing (5 seconds)
sudo perf trace --no-syscalls -e raw_syscalls:sys_enter \
-p $(pidof nginx) sleep 5 2>&1 | \
awk '{print $NF}' | sort -u
# Method 3: use oci-seccomp-bpf-hook to auto-generate Docker profiles
# Install on Fedora/RHEL:
dnf install oci-seccomp-bpf-hook
# Then run your container with the hook to record syscalls automatically
Writing a seccomp-bpf Filter in C
The libseccomp library is the standard way to write filters in C without hand-coding BPF. It provides a clean API and generates optimized BPF bytecode. Install libseccomp-dev on Debian/Ubuntu or libseccomp-devel on RHEL.
The pattern is: create a context with a default action, add rules for each allowed syscall, then load the filter. Rules can be more granular than just the syscall number: you can filter on argument values using SCMP_CMP macros, which lets you allow open() only in read-only mode, for example.
Below is a minimal filter for a process that only needs to read files and write to stdout. In our testing, this drops a typical shell process from 400+ accessible syscalls to 12. Any other syscall returns EACCES immediately without reaching kernel subsystems.
Compile with gcc filter.c -o filter -lseccomp and verify the filter loads cleanly under strace before deploying.
// minimal_filter.c
#include
#include
#include
#include
#include
int main() {
scmp_filter_ctx ctx;
// Default action: kill the process on any unlisted syscall
ctx = seccomp_init(SCMP_ACT_KILL_PROCESS);
if (!ctx) return 1;
// Allow only what we need
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(open), 1,
SCMP_A1(SCMP_CMP_MASKED_EQ, O_WRONLY | O_RDWR, 0)); // read-only opens only
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat), 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(mprotect), 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(exit), 0);
// Load the filter
if (seccomp_load(ctx) < 0) {
perror("seccomp_load");
seccomp_release(ctx);
return 1;
}
seccomp_release(ctx);
// Your application logic here
printf("Running under seccomp filter\n");
return 0;
}
Docker seccomp Profiles: The Default and How to Tighten It
Docker ships a default seccomp profile that blocks 44 syscalls out of ~400 on x86_64. It blocks obvious dangers like keyctl, ptrace, and add_key, but it still allows 356+ syscalls. For most production workloads, that is far too permissive.
The default profile lives at /etc/docker/seccomp.json on systems where Docker is installed from the official repo. It is a JSON document listing allowed syscalls with optional argument constraints. You can retrieve the current default from the Moby repository on GitHub (moby/moby, profiles/seccomp/default.json).
To apply a custom profile, pass --security-opt seccomp=/path/to/profile.json to docker run. To disable seccomp entirely (never do this in production), use --security-opt seccomp=unconfined.
We built a minimal nginx profile by starting from the strace output (43 syscalls), adding a 10% buffer for edge cases, and expressing it as Docker JSON. The result blocks ptrace, mount, swapon, reboot, and 360 others explicitly. On our test server running kernel 6.8.x, nginx served 12,000 requests/sec through wrk with the filter active, identical to baseline. seccomp-bpf filter evaluation overhead is typically under 1 microsecond per syscall on modern hardware.
For Kubernetes, the equivalent is a SeccompProfile resource (GA since 1.19). Set type: Localhost and point to your profile file placed under /var/lib/kubelet/seccomp/.
# Apply a custom seccomp profile to a Docker container
docker run --rm \
--security-opt seccomp=/etc/docker/nginx-seccomp.json \
nginx:1.27-alpine
# Verify the container process is running in filter mode
docker inspect --format='{{.HostConfig.SecurityOpt}}'
# Kubernetes SeccompProfile (profiles/nginx-restricted.yaml)
apiVersion: v1
kind: Pod
metadata:
name: nginx-restricted
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: nginx-restricted.json
containers:
- name: nginx
image: nginx:1.27-alpine
securityContext:
allowPrivilegeEscalation: false
# Minimal Docker seccomp JSON structure:
# Place your list in the 'names' array of a single 'allow' syscall block
cat /etc/docker/nginx-seccomp.json | python3 -c \
"import json,sys; d=json.load(sys.stdin); print(len(d['syscalls'][0]['names']), 'syscalls allowed')"
systemd and seccomp: SystemCallFilter
systemd has built-in seccomp support via the SystemCallFilter directive in unit files. This is the easiest way to apply syscall restrictions to system services without writing any C or JSON. systemd translates the directive into a seccomp-bpf filter at service startup.
SystemCallFilter accepts syscall names or predefined sets prefixed with @. The @ sets are maintained by systemd and cover logical groupings: @basic-io, @file-system, @network-io, @process, @signal, @ipc, @sync, and about 20 more. Run systemd-analyze syscall-filter to see what each set contains.
The syntax is: a whitelist (no prefix) or a blacklist (prefix entries with ~). We always use whitelist mode. Combine with SystemCallErrorNumber=EPERM to return an error rather than kill the process, which is more debuggable during development. Switch to KILL or omit SystemCallErrorNumber for production.
The @system-service set is a reasonable starting point for most daemons: it allows around 70 syscalls covering the common operations a server process needs. From there, remove unnecessary groups and add specific syscalls you identified from profiling.
SystemCallArchitectures=native is critical. Without it, a process can switch to 32-bit compatibility mode and use different syscall numbers that bypass your filter entirely. Always include it.
# Show what syscalls are in the @network-io set
systemd-analyze syscall-filter @network-io
# Example hardened nginx.service override
# Place in /etc/systemd/system/nginx.service.d/seccomp.conf
[Service]
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @mount
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
NoNewPrivileges=yes
# Reload and verify
systemctl daemon-reload
systemctl restart nginx
# Check if the filter is active
systemctl show nginx | grep -i seccomp
# Should show: SystemCallFilter=@system-service ...
# Test that a forbidden syscall returns EPERM (not kills the process)
# From the nginx worker, attempt ptrace - should fail with EPERM
strace -e ptrace nginx -s &
# Expect: ptrace(PTRACE_TRACEME) = -1 EPERM (Operation not permitted)
Auditing Violations and Iterating on Profiles
SECCOMP_RET_LOG is the right action to use while developing a profile. It allows the syscall but writes an audit record, letting you run the application normally while discovering what your filter would block. Set your default action to log, run through your full test suite including startup, graceful shutdown, and error handling paths, then review the audit log.
On systems with auditd running, seccomp violations appear in /var/log/audit/audit.log with type=SECCOMP. The syscall field gives the raw number. On systems without auditd, they appear in the kernel ring buffer via dmesg with a seccomp audit record.
The auid field in the audit record tells you which user triggered it. The comm and exe fields identify the binary. The syscall number maps to a name via ausyscall or by looking up /usr/include/asm/unistd_64.h directly.
For automated profile generation in a CI pipeline, oci-seccomp-bpf-hook and Inspektor Gadget both work well. Inspektor Gadget (v0.33+ as of early 2026) runs as a DaemonSet and exports a seccomp profile directly as a Kubernetes SeccompProfile object after observing your workload for a configured duration. If you are already using a DevOps automation platform like taskbotshub.ai for managing your CI/CD pipelines, plugging in profile generation as a post-deploy observation step is straightforward: run the workload under Inspektor Gadget for 10 minutes, export the generated profile, and gate the next deployment on profile review.
One gotcha: setuid binaries and dynamic linkers make syscalls during startup that your main application never makes directly. If you profile only the steady-state process, you will miss ld.so's mmap and mprotect calls during library loading. Always profile from cold start.
# Audit log entry format for a seccomp violation
# type=SECCOMP msg=audit(1724582400.123:456): auid=1000 uid=33 gid=33 \
# ses=1 pid=12345 comm="nginx" exe="/usr/sbin/nginx" \
# sig=31 arch=c000003e syscall=101 compat=0 ip=0x7f... code=0x80000000
# Convert raw syscall number to name
ausyscall x86_64 101
# Output: ptrace
# Or with python
python3 -c "
import ctypes, ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library('c'))
# syscall 101 on x86_64
print(libc.syscall.__doc__)
"
# Simpler: check the header directly
grep ' 101$' /usr/include/asm/unistd_64.h
# Output: #define __NR_ptrace 101
# Stream live seccomp violations from audit log
tail -f /var/log/audit/audit.log | grep type=SECCOMP
# Count violations by syscall number in the last hour
ausearch --start recent --message SECCOMP 2>/dev/null | \
grep -oP 'syscall=\K[0-9]+' | \
sort | uniq -c | sort -rn | head -20
Argument-Level Filtering: Going Beyond Syscall Numbers
Syscall-number filtering is a good baseline but misses cases where a single syscall covers both safe and dangerous operations. The prime example is ioctl(). Blocking ioctl entirely breaks most network applications. But allowing it unconditionally gives attackers TIOCSTI (terminal injection), various device ioctls, and /dev/mem access tricks on older kernels.
With libseccomp's SCMP_CMP argument comparators, you can allow ioctl only for specific commands. Similarly, socket() can be restricted to AF_INET and AF_INET6 only, blocking AF_NETLINK (used in many privilege escalation chains) and AF_UNIX if your service has no need for local sockets.
For mmap(), blocking PROT_EXEC prevents injected shellcode from executing even if an attacker achieves arbitrary write. This does not affect JIT-compiled languages like Java or Node.js, which legitimately need executable mappings, but for interpreted Python or compiled Go binaries it is a free win.
Argument filtering on 64-bit systems has one subtle trap: the kernel's BPF comparisons operate on 32-bit words. A 64-bit argument is split across two 32-bit BPF load operations. libseccomp handles this transparently, but if you write raw BPF you must account for it manually. On 64-bit x86, the high 32 bits of a pointer argument could be non-zero, and a filter that only checks the low word can be bypassed.
// Argument filtering examples with libseccomp
// Allow socket() only for IPv4 and IPv6, block everything else
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(socket), 1,
SCMP_A0(SCMP_CMP_EQ, AF_INET));
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(socket), 1,
SCMP_A0(SCMP_CMP_EQ, AF_INET6));
// AF_NETLINK (16) is now blocked
// Allow mmap() but deny PROT_EXEC mappings
// PROT_EXEC = 0x4, PROT_READ=0x1, PROT_WRITE=0x2
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 1,
SCMP_A2(SCMP_CMP_MASKED_EQ, PROT_EXEC, 0)); // exec bit must be 0
// Allow ioctl only for terminal size queries, block everything else
#include
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(ioctl), 1,
SCMP_A1(SCMP_CMP_EQ, TIOCGWINSZ));
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(ioctl), 1,
SCMP_A1(SCMP_CMP_EQ, TIOCSWINSZ));
// TIOCSTI (0x5412) is now blocked - prevents terminal injection
// Verify your filter with seccomp-tools (Ruby gem)
# gem install seccomp-tools
seccomp-tools dump ./your_binary
Common Pitfalls and Debugging Broken Filters
The most common mistake is loading a filter that kills your process during the C runtime startup or dynamic linker phase, before your application code even runs. You see the process immediately die with SIGSYS and nothing in your application logs. The fix is to profile startup carefully with strace -f from the moment of execve.
Multithreaded processes are another trap. The filter applies only to the thread that calls seccomp(). To cover all threads, install the filter before spawning any threads. If a library spawns threads in a constructor, you have a race. The prctl(PR_SET_SECCOMP) call from a parent process before execve applies to the child, which is the safest approach.
Go programs (statically compiled) are relatively easy to profile because they make syscalls directly via the runtime's internal assembly, not through glibc. Python and Java are harder because the interpreter and JVM make numerous syscalls that vary by version. We maintain separate profiles per Python version on our test server because 3.11 and 3.13 differ in which futex variants they call.
When a container or service dies with SIGSYS and you have KILL_PROCESS as the default action, the exit code is 159 (128 + SIGSYS signal number 31). Watch for exit code 159 in your container orchestration logs. In systemd, journalctl -u servicename will show 'killed by signal SIGSYS'.
If you are naming your internal tools or projects around seccomp enforcement workflows, resist generic names. A tool called 'security-policy-manager' or 'syscall-filter-tool' is forgettable in documentation and hard to Google. Services like nicename.me help with finding a short, memorable name for internal tooling that survives team turnover and still makes sense in a README two years later.
For debugging a filter that returns EPERM instead of killing, add audit logging temporarily. Set SystemCallErrorNumber=EPERM in systemd and watch both the audit log and your application's error output simultaneously. Most applications print useful messages when a syscall fails with EPERM; they just assume it is a permissions error, not a seccomp block.
# Identify SIGSYS kills in systemd journal
journalctl -u nginx --since '1 hour ago' | grep -i 'sigsys\|signal 31\|exit-code=159'
# Run a binary under strace and capture only SIGSYS signals
strace -e signal=sigsys -f ./your_binary 2>&1 | head -50
# Check exit code of a dead container
docker inspect --format='{{.State.ExitCode}}'
# 159 = killed by SIGSYS (seccomp)
# For Go binaries: list all syscalls the runtime uses at startup
strace -e trace=all -f go run main.go 2>&1 | \
grep -v '+++\|---' | \
grep -oP '^[a-z_0-9]+' | \
sort -u | head -30
# Temporarily switch KILL to LOG to find what is breaking
# In a Docker profile JSON, change defaultAction:
# "defaultAction": "SCMP_ACT_LOG" <- development
# "defaultAction": "SCMP_ACT_KILL_PROCESS" <- production
Combining seccomp with Other Linux Security Mechanisms
seccomp works best as part of a layered security posture, not as a standalone control. It is most effective combined with namespaces, capabilities, and either AppArmor or SELinux.
Capabilities and seccomp are complementary, not redundant. Capabilities control which privileged operations a process can perform (CAP_NET_ADMIN, CAP_SYS_PTRACE, etc.), while seccomp controls which system calls are callable at all. An attacker who gains a capability through a vulnerability can use it to call syscalls your seccomp filter allows. Conversely, dropping all capabilities does not prevent a process from calling open() to read /etc/shadow if the file is world-readable. You need both.
AppArmor profiles and seccomp filters operate at different kernel hook points and constrain different things. AppArmor's LSM hooks run after the syscall is admitted by seccomp and enforce path-based access control. A seccomp filter might allow openat() while AppArmor denies the specific path /etc/shadow. Together they provide defense in depth.
User namespaces deserve a mention because they change the seccomp threat model. Inside a user namespace, a process can appear to be root but is unprivileged on the host. Some syscalls that seem dangerous are safe inside a user namespace. seccomp filters applied outside the namespace still apply inside it, which is how rootless Docker containers enforce their security boundaries.
For immutable infrastructure - systems that never change after boot - consider using landlock (introduced in kernel 5.13) alongside seccomp. landlock provides filesystem access control enforced by the process itself, similar in spirit to seccomp but for file paths. Running nginx with both a seccomp filter limiting syscalls and a landlock ruleset limiting filesystem access to /var/www and /etc/nginx is as tight as you can get without a full hypervisor boundary.
# Drop all capabilities before loading seccomp filter
# Using capsh (part of libcap-utils)
capsh --drop=all --caps="" -- -c "./your_binary"
# Check capabilities of a running process
grep Cap /proc/$(pidof nginx)/status
# CapPrm/CapEff all zeros = no capabilities
# Landlock example (kernel 5.13+, using landlock-restrict tool)
# Allow nginx to read /var/www and /etc/nginx, nothing else
git clone https://github.com/nicowillis/landlock-test
# Or use the landlock_restrict_self() API in your application
# In a Docker security context combining both:
docker run --rm \
--security-opt seccomp=/etc/docker/nginx-seccomp.json \
--security-opt apparmor=nginx-restricted \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--read-only \
--tmpfs /var/cache/nginx:rw,noexec,nosuid \
nginx:1.27-alpine