Disassembling BPF filters with seccomp-tools dump and asm, observing the real syscalls with strace, and using SECCOMP_RET_LOG and auditd to record violations. Including a deny-behavior test suite strategy and safe fallbacks when a filter is wrong.

In episode 6 you saw complex filters — user notification, argument filters, and layered action chains. The more complex a filter, the greater the chance of bugs: a miswritten rule, a wrong architecture, or a syscall the application turns out to still need. The problem is that seccomp filters are invisible code — they run in the kernel, and when they're wrong, the application just dies without explanation. Episode 7 gives you three debugging weapons: disassembling BPF filters, observing the syscalls an application actually makes, and testing deny behavior automatically. The principle is firm: a filter is code, and code has bugs — never trust it until you've proven it.
seccomp-tools is a toolkit from the CTF ecosystem that's very useful for day-to-day work. It uses ptrace to intercept the moment a filter is loaded into the kernel, then displays the BPF program in a human-readable form. Install it first:
gem install seccomp-toolsThe first command you must memorize is dump — it runs a binary and captures the filters being loaded:
seccomp-tools dump ./appIts output is a BPF disassembly (simplified):
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x00 0xc000003e if (A != 0xc000003e) goto 0003
0002: 0x20 0x00 0x00 0x00000000 A = syscall_nr
0003: 0x15 0x00 0x00 0x00000000 if (A != read) goto 0004
0004: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0005: 0x06 0x00 0x00 0x00000001 return ERRNO(1)Read it top to bottom: verify the architecture, read the syscall number, compare, then take the action. This way of reading is what separates people who understand filters from those who just copy examples.
Besides reading, you can also write and compile filters directly with the simple asm DSL. Create a rules file like this:
line: 0
line: 1
return ALLOW
return ERRNO(1)Meaning: if the syscall number equals 0 (read) or 1 (write), return ALLOW; otherwise ERRNO(1). Compile the DSL to BPF bytecode, then turn the bytecode back into assembly to verify:
seccomp-tools asm rule.asm
seccomp-tools disasm 20 00 00 00 04 00 00 00 15 00 00 00 00 00 00 00Tip
The asm/disasm pair is very useful when comparing the filter an application loads with the filter you think you wrote. If they differ, that difference is the source of the bug.
A correct filter must align with the syscalls the application actually makes. strace records every syscall via ptrace — for example strace -f -o /tmp/app.trace ./app to save a full trace — but for composing filters, the summary mode is more useful:
strace -f -c ./appExample output (truncated):
calls errors syscall
--------- ------ ----------------
100 60 write
100 0 openat
50 0 mmap
30 0 read
--------- ------ ----------------
310 60 totalThe errors column is a hidden alarm: a failing syscall doesn't necessarily mean it was blocked by seccomp, but it often signals something worth investigating. This summary becomes the right baseline for composing the allow-list in episode 8.
Note
strace and seccomp-tools both use ptrace. Run them from an environment where Yama isn't blocking (kernel.yama.ptrace_scope low), and remember: seccomp filters still apply to a straced process, so observing an already-filtered application will show EPERM as a valid form of observation.
Building a correct filter requires feedback, and the best feedback is logs. Since kernel 4.14, libseccomp provides the SCMP_ACT_LOG action: the syscall still runs, but every occurrence is recorded by the audit subsystem. This is perfect for trials — the application isn't blocked, and you see which syscalls occur.
Once auditd is active, all these records land in /var/log/audit/audit.log with type 1326. How to read it:
sudo ausearch -m SECCOMP -ts recentExample of one audit line:
type=1326 audit(1710000000.123:789): auid=1000 uid=1000 gid=1000
subj=unconfined pid=4567 comm="app" exe="/usr/bin/app"
arch=c000003e syscall=59 code=0x7ffc0000Read only what matters:
code=0x7ffc0000 — that's SECCOMP_RET_LOG. If the code differs, the action differs too (0x00050000 for ERRNO, 0x7fff0000 for ALLOW).syscall=59 — the syscall number for that architecture, not necessarily its name; map it with ausyscall 59 (59 is execve on x86_64), and match comm/exe to know which process made the call.Tip
If you don't want to install auditd, SCMP_ACT_LOG filters are also recorded in the kernel log — check with journalctl -k and grep for seccomp. Auditd just provides a format that's easier to search.
Here's the safest flow for building filters in real applications:
strace -f -c in an environment representative of production, then compose an allow-list with SCMP_ACT_ERRNO as the default action.SCMP_ACT_LOG as a temporary default in staging — the application runs normally, all syscalls are recorded.ERRNO, run again, and make sure no syscall is missing.Most importantly: provide an exit path. A wrong filter in production crashes the application instantly — keep a switch to run the application without a filter (an env var or --no-seccomp flag) as an emergency recovery, never skip staging, and start with LOG at the beginning rather than jumping straight to ERRNO.
Warning
The no-filter exit path is only a recovery tool, not a production configuration. If you keep the fallback on for a long time, it means your filter is wrong — fix it, don't keep it. A container that disables seccomp for convenience is a house without locked doors.
Log feedback is observational; even stronger is testing deterministically. The principle: a small program that installs the same filter as the production application, then invokes allowed and blocked syscalls and compares the errno. An example harness in C:
#include <seccomp.h>
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
static int pass = 0, fail = 0;
static void expect_errno(int got, int want) {
if (got == want) {
pass++;
} else {
fail++;
printf("errno = %d, expected %d\n", got, want);
}
}
int main(void) {
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(chmod), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(mount), 0);
seccomp_load(ctx);
errno = 0; chmod("/tmp/x", 0600); expect_errno(errno, EPERM);
errno = 0; mount(0, 0, 0, 0, 0); expect_errno(errno, EPERM);
errno = 0; getpid(); expect_errno(errno, 0);
printf("pass=%d fail=%d\n", pass, fail);
return fail ? 1 : 0;
}Note the structure: besides testing syscalls that must be blocked, the harness also tests syscalls that must work (here getpid) — a filter that blocks too much is just as dangerous as one that blocks too little. Compile with gcc -o test_deny test_deny.c -lseccomp then run ./test_deny. Since its output is an exit code (0 success, 1 failure), this harness can go straight into CI — every filter change must pass, and every test failure must be accounted for, not silently removed.
In episode 7 you held the three debugging keys for filters: disassembling BPF with seccomp-tools dump and composing it via asm/disasm, observing real syscall behavior with strace -c, and using SCMP_ACT_LOG with ausearch -m SECCOMP to record violations without blocking. You also know the log-first-enforce-later flow and the deterministic test suite that proves deny behavior.
The keys to take home:
SCMP_ACT_LOG is reconnaissance mode; use it before switching to ERRNO.All these tools feel wasted without one thing: a place to use them at scale. In episode 8, we move into Container Profiles (Docker/runc): the docker-default profile, the list of blocked syscalls, the OCI profile JSON format, and how to build custom profiles for your applications.