Getting to know the core libseccomp API: seccomp_init, seccomp_rule_add, seccomp_load, seccomp_reset, and seccomp_export_bpf, then practicing your first C filter that blocks execve with an allow default action.

In episode 3 you learned about return actions and how to think about seccomp decisions. Now it's time to get your hands dirty: writing real filters with libseccomp. This is the most hands-on episode so far — you'll write, compile, and run your first C filter.
We'll cover five core API functions, then build one complete example: a filter that blocks execve with an allow default action. Through this example you'll see the entire lifecycle of a filter — from initialization, adding rules, loading into the kernel, to exporting its BPF.
Writing BPF by hand is possible, but why would you? Even for a rule like "deny execve on x86_64" you'd need to know that execve's syscall number is 59, write BPF instructions to compare it, and handle architecture differences — all manually.
libseccomp removes all that complexity. You write a declarative policy in C, and libseccomp generates the correct BPF program for the target architecture, including managing syscall number differences across architectures and adding an architecture-check prologue automatically.
The following five functions are the backbone of almost every filter you'll write:
| Function | Job |
|---|---|
seccomp_init(action) | Creates a new filter context with a given default action |
seccomp_rule_add(ctx, action, syscall, argc, ...) | Adds a rule: which syscall, what action, and (optionally) argument filters |
seccomp_load(ctx) | Composes the BPF and installs the filter into the kernel via the seccomp(2) syscall |
seccomp_reset(ctx, action) | Clears all rules and changes the default action, without creating a new context |
seccomp_export_bpf(ctx, fd) | Writes the composed BPF program to a file descriptor |
Two supporting functions also worth knowing: seccomp_release(ctx) to free memory, and seccomp_syscall_resolve_name(name) to translate a syscall name into a number.
The first decision when creating a filter is the default action — it determines your filter's philosophy:
SCMP_ACT_ALLOW as the default → deny-list. You block specific syscalls, everything else is free. Easy, but prone to forgetting to block new dangerous syscalls.SCMP_ACT_ERRNO(errno) as the default → allow-list. You allow specific syscalls, everything else is denied. Safer, but requires a complete syscall list.Default container profiles (Docker, runc) use an allow-list approach with SCMP_ACT_ERRNO as the default. For our example in this episode, we'll use a deny-list — it's shorter and focuses on one dangerous syscall.
Our goal: a program that installs a seccomp filter with SCMP_ACT_ALLOW as the default action, then blocks execve with SCMP_ACT_ERRNO(EPERM). After the filter is active, we try to call execve to see the result.
#include <seccomp.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
int main(void) {
scmp_filter_ctx ctx;
char *argv[] = {"/bin/ls", NULL};
char *envp[] = {NULL};
/* 1. Default action: allow all syscalls */
ctx = seccomp_init(SCMP_ACT_ALLOW);
if (ctx == NULL) {
perror("seccomp_init");
return 1;
}
/* 2. Rule: block execve with errno EPERM */
if (seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(execve), 0) != 0) {
perror("seccomp_rule_add");
return 1;
}
/* 3. Compose the BPF and install the filter into the kernel */
if (seccomp_load(ctx) != 0) {
perror("seccomp_load");
return 1;
}
seccomp_release(ctx);
printf("filter active: execve blocked\n");
/* 4. Try to run another program - must fail */
if (execve("/bin/ls", argv, envp) == -1) {
printf("execve denied: %s\n", strerror(errno));
}
return 0;
}Let's walk through the flow line by line:
seccomp_init(SCMP_ACT_ALLOW) creates a filter context with the default policy "allow everything". From here we'll carve out exceptions.
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(execve), 0) adds a rule: when the execve syscall is invoked, the filter returns ERRNO(EPERM) — the syscall is aborted with an "Operation not permitted" error. The last argument 0 means no argument filter (we'll learn about those in episode 5).
seccomp_load(ctx) composes the BPF program from all the rules and installs it into the kernel. After this call, the filter applies permanently to this process — it can't be undone.
seccomp_release(ctx) frees the context's memory since its job is done.
execve("/bin/ls", argv, envp) is the test: once the filter is active, this call will fail and errno will contain EPERM.
Now compile and run:
gcc -o deny-execve deny_execve.c -lseccomp./deny-execve
filter active: execve blocked
execve denied: Operation not permittedNote
Note the -lseccomp flag on the gcc command. It tells the linker to link the program against the libseccomp library. Without this flag, the program won't be able to find seccomp_init and friends.
Congratulations — you've just written and run your first seccomp filter! Your program now has a rule enforced by the kernel: no matter how badly the code executed after the filter activates, execve will never succeed.
One of libseccomp's strengths is the ability to export the composed BPF program. This is useful for auditing and debugging. Add the following block to your program before seccomp_load:
#include <fcntl.h>
/* Export the BPF program to a file before loading */
int bpf_fd = open("filter.bpf", O_CREAT | O_WRONLY, 0644);
if (bpf_fd >= 0) {
seccomp_export_bpf(ctx, bpf_fd);
close(bpf_fd);
}After that, inspect the filter.bpf file with seccomp-tools:
seccomp-tools disassemble filter.bpf
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x00 0xc000003e if (A != ARCH_X86_64) goto 0002
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x15 0x01 0x00 0x0000003b if (A == execve) goto 0005
0004: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0005: 0x06 0x00 0x00 0x00050000 return ERRNO(1)See how libseccomp generates the architecture-check prologue (lines 0000-0001) and the syscall number comparison (lines 0002-0003) automatically. This is what you don't have to write by hand — and why libseccomp is the industry standard.
To reinforce understanding, compare with the allow-list version. Change two key lines:
/* Default: deny everything with EPERM */
ctx = seccomp_init(SCMP_ACT_ERRNO(EPERM));
/* Allow the syscalls this program genuinely needs */
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);With this approach, the program may only write to stdout and exit — everything else is denied with EPERM. Safer, but requires more work to register all the syscalls it needs. That's the trade-off you'll always face: a deny-list is quick, an allow-list is safe.
In episode 4 you wrote your first seccomp filter:
seccomp_init(action) sets the default action and your filter's philosophy (deny-list vs allow-list).seccomp_rule_add adds per-syscall rules.seccomp_load composes the BPF and installs the filter into the kernel permanently.seccomp_reset allows reusing a context; seccomp_export_bpf for auditing and debugging.gcc -o program program.c -lseccomp and run.The most important thing to remember: after seccomp_load, the filter can't be undone — so design your policy carefully and test it in a VM (remember episode 0).
In the next episode 5, we level up: arg filters and architecture handling — filtering syscalls based on their arguments with SCMP_CMP (for example, restricting socket to only the AF_UNIX domain), handling multi-architecture x86_64/i386/arm64, and the dangers of x32 syscall multiplexing. See you then!