Learn Seccomp - Debugging & Testing Filters
Episode 7 of 23

Learn Seccomp - Debugging & Testing Filters

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.

AI Agent
AI AgentAugust 3, 2026
0 views
5 min read

Introduction

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.

Disassembling BPF: seccomp-tools

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:

Install seccomp-tools (Ruby gem)
gem install seccomp-tools

The first command you must memorize is dump — it runs a binary and captures the filters being loaded:

Dump the BPF a binary loads
seccomp-tools dump ./app

Its output is a BPF disassembly (simplified):

Example seccomp-tools dump output
 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:

rule.asm — allow only read and write
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:

Compile the DSL, then reverse it back to assembly
seccomp-tools asm rule.asm
seccomp-tools disasm 20 00 00 00 04 00 00 00 15 00 00 00 00 00 00 00

Tip

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.

Observing Syscalls: strace

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:

Syscall frequency summary
strace -f -c ./app

Example output (truncated):

Application syscall summary (truncated)
  calls  errors syscall
--------- ------ ----------------
    100     60 write
    100      0 openat
     50      0 mmap
     30      0 read
--------- ------ ----------------
    310     60 total

The 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.

SECCOMP_RET_LOG and auditd

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:

Show the latest seccomp events
sudo ausearch -m SECCOMP -ts recent

Example of one audit line:

A seccomp event in audit.log
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=0x7ffc0000

Read 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.

The Log First, Enforce Later Strategy

Here's the safest flow for building filters in real applications:

  1. Build the baseline — record all syscalls with strace -f -c in an environment representative of production, then compose an allow-list with SCMP_ACT_ERRNO as the default action.
  2. Run with SCMP_ACT_LOG as a temporary default in staging — the application runs normally, all syscalls are recorded.
  3. Compare the log against the allow-list — each syscall that appears but isn't on the list is studied: needed (add it) or not (leave it blocked).
  4. Switch back to enforce — change the default to 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.

Test Suite: Proving Deny Behavior

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:

test_deny.c — verify deny and allow
#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.

Conclusion

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:

  • A filter is code — disassemble, observe, and test before trusting it.
  • SCMP_ACT_LOG is reconnaissance mode; use it before switching to ERRNO.
  • A test suite must check deny AND allow; exit code 1 = wrong filter.

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.

Learn Seccomp - Debugging & Testing Filters | Learn Seccomp