Hardening Linux Services with seccomp-BPF: Allowlist vs Deny-list Decision Guide
Guide to choosing and validating seccomp‑BPF allowlist or deny‑list filters for Linux services, with a concrete libseccomp example and validation steps.
06 Jan 2026, 20:04 UTC

Problem and takeaway
Running a long‑running service that executes untrusted code (e.g., a plugin system, container entrypoint, or remote‑code‑execution endpoint) exposes the host to any syscall the code can invoke. Without filtering, a compromised component can use dangerous interfaces such as ptrace, keyctl, or openat on arbitrary files. The practical takeaway is to install a seccomp‑BPF filter after dropping privileges but before executing the untrusted code, using PR_SET_NO_NEW_PRIVS to prevent privilege escalation through the filter itself.
Decision and constraints
Decision: enable a seccomp‑BPF filter with PR_SET_NO_NEW_PRIVS before the untrusted workload starts. Constraints to consider:
- The filter must be loaded after any set‑uid/gid drop or capability reduction, otherwise the process could regain privileges via
execveof a set‑uid binary. - Test the filter on the exact target architecture and kernel version; syscall numbers differ between x86_64, aarch64, arm, etc.
- Filters are inherited across
forkandexecand cannot be removed by the same process; design upgrade paths that either keep the filter or restart the service with a new filter. - After glibc, kernel, or library updates, re‑test the filter because new syscalls may appear in normal execution paths.
Supported options comparison
| Option | Pros | Cons | Typical use |
|---|---|---|---|
| No filter | Simple, compatible with all binaries | No syscall mediation; attack surface unchanged | Trusted, tightly controlled environments |
| seccomp deny‑list | Easy to start; block only known dangerous syscalls (e.g., ptrace, keyctl) |
Incomplete; new or obscure dangerous syscalls slip through; bypassable via alternate paths | Quick hardening of legacy services where full allowlist is impractical |
| seccomp allowlist | Strong surface reduction; only listed syscalls succeed; default action kills or returns EPERM/SIGSYS |
Architecture‑specific; must be updated when binaries or libraries add syscalls; risk of breaking the service if a needed syscall is omitted | Untrusted workloads, container entrypoints, plugin systems |
| LSM (AppArmor/SELinux) | Mediates file paths, capabilities, network; complementary to seccomp | Separate policy language; requires policy maintenance; does not filter raw syscalls directly | Systems needing MAC in addition to syscall filtering |
Trade‑offs
An allowlist gives the lowest attack surface but couples the filter tightly to the binary’s syscall set; any change in the dynamic linker, glibc, or a newly used library function can cause a blocked syscall and crash the service. A deny‑list is faster to deploy and more tolerant of changes, yet it leaves a large residual surface and can be evaded by using alternative syscalls that achieve the same effect. LSM policies add breadth (file, capability, network) but do not replace the need to limit the raw syscall surface. In practice, many teams start with a deny‑list to eliminate the most obvious dangers, then migrate to an allowlist once the service’s syscall profile is stable.
Concrete implementation
The following example uses libseccomp to build an allowlist filter for an x86_64 binary. Adjust the syscall list for your architecture and workload.
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <binary> [args...]\n", argv[0]);
exit(EXIT_FAILURE);
}
/* 1. Prevent new privileges via setuid binaries */
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) {
perror("prctl(PR_SET_NO_NEW_PRIVS)");
exit(EXIT_FAILURE);
}
/* 2. Create a seccomp filter */
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL); /* default: kill */
if (!ctx) {
perror("seccomp_init");
exit(EXIT_FAILURE);
}
/* Allow a minimal set needed for a typical "hello world" program */
/* Read, write, exit, brk, mmap, munmap, mprotect, arch_prctl, set_tid_address */
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(exit), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 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(mprotect), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(arch_prctl), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(set_tid_address), 0);
/* Load the filter */
if (seccomp_load(ctx) != 0) {
perror("seccomp_load");
exit(EXIT_FAILURE);
}
seccomp_release(ctx);
/* 3. Execute the untrusted binary */
execvp(argv[1], &argv[1]);
/* If we get here, exec failed */
perror("execvp");
exit(EXIT_FAILURE);
}
Compile with:
gcc -Wall -O2 -o seccomp_wrapper seccomp_wrapper.c -lseccomp
Run the wrapper as an unprivileged user after dropping any set‑uid/gid privileges:
./seccomp_wrapper /path/to/untrusted_program arg1 arg2
Validation steps
- Check the Seccomp field: after the wrapper starts, read
/proc/self/status(or/proc/<pid>/statusof the child). TheSeccomp:line should show a non‑zero value (e.g.,Seccomp: 2for SECCOMP_MODE_FILTER). Note that the exact encoding is kernel‑version dependent; consultDocumentation/userspace-api/seccomp_filter.rstfor your kernel. - Strace comparison: run the wrapped program under
strace -f -e trace=alland compare the output to a baseline run without the filter. Syscalls not in the allowlist should appear as either= -1 EPERM (Operation not permitted)or trigger aSIGSYS(shown as--- SIGSYS {si_signo=SIGSYS, si_code=SECCOMP, si_call_addr=...} ---). Ensure that all syscalls required for normal operation succeed. - Functional test: verify that the service still performs its intended work (e.g., serves requests, processes data). If the service fails, examine the strace output to see which syscall was blocked and add it to the allowlist, then rebuild.
Limitations and practical checks
- The filter is architecture‑specific; you must build and test separate binaries or filter blobs for x86_64, aarch64, arm, etc.
- GlIBC may invoke additional syscalls after a library update (e.g., new
clock_gettimevariants). Schedule a regression test after any glibc, kernel, or library upgrade. - Because the filter cannot be removed by the same process, consider using a two‑stage launcher: the parent process loads the filter, then
execs the child; to update the filter, restart the whole service. - If you run the wrapper inside a container, ensure the container runtime does not already install a conflicting seccomp profile; otherwise the effective filter is the intersection of both, which may be more restrictive than intended.
Summary
Choose an allowlist seccomp‑BPF filter when you can enumerate the syscalls your workload needs and want the strongest reduction of the attack surface. Apply PR_SET_NO_NEW_PRIVS first to prevent privilege escalation via set‑uid binaries, load the filter with libseccomp, and validate using /proc/self/status and strace. Re‑test after any system or library change, and plan for the fact that the filter is persistent for the lifetime of the process.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.