Lab 04 — The seccomp-BPF Policy Compiler and Bypass Detector

Intensity: advanced. seccomp is the single most important syscall-level sandbox primitive on Linux — it is what shrinks a code-execution bug from "owns the kernel attack surface" to "can touch only the calls the workload actually needs." This lab builds a faithful model of the seccomp decision: you compile a high-level policy, evaluate a syscall against it (number + architecture + raw arguments → action), and detect the two bypasses that defeat real-world seccomp filters — the missing architecture check and semantic over-trust of pointer-target syscalls.

Prerequisites: Phase 01 (the syscall boundary, file descriptors as capabilities), Phase 06 WARMUP Chapter 6 (seccomp-BPF) and Chapter 3 (Linux process authority). Authorized-lab only — this reasons over policy data; it installs nothing.

Table of Contents


1. What seccomp Actually Is

The first principle (Phase 01). The system-call interface is the security boundary of an operating system: every dangerous thing a process can do — open a file, connect a socket, load a kernel module, trace another process — it does by asking the kernel through a syscall. A process running attacker-controlled code can do anything its syscalls allow.

seccomp ("secure computing mode") lets a process voluntarily install a filter that the kernel consults on every single syscall that process (and its children) makes. The filter is a small classic BPF program; it returns an action:

ActionEffect
SECCOMP_RET_ALLOWpermit the syscall
SECCOMP_RET_ERRNOfail the syscall with an errno (the workload sees EPERM)
SECCOMP_RET_TRAPdeliver SIGSYS (can be handled)
SECCOMP_RET_LOGallow but record it
SECCOMP_RET_KILL_PROCESSterminate the process

Once installed, the filter is irrevocable and inherited across fork/exec (when combined with no_new_privs). This is why it is the workhorse of container runtimes, browsers (Chrome's renderer), and microVM jailers: a compromised process cannot remove its own cage, and the cage covers the kernel attack surface directly.

2. How a Filter Decides

The filter program receives a struct seccomp_data with these fields it may inspect:

nr            -> the syscall NUMBER (e.g. 0 = read on x86-64)
arch          -> the AUDIT_ARCH_* of the calling ABI (X86_64, I386, AARCH64, ...)
instruction_pointer
args[0..5]    -> the six syscall argument REGISTERS (raw integers)

A correct allowlist filter does three things, in this order:

  1. Pin the architecture: if arch != AUDIT_ARCH_X86_64: KILL. (See §3 for why this is first and non-negotiable.)
  2. Allow specific syscalls by number, optionally constrained on scalar/flag arguments (e.g. allow socket only when args[0] == AF_INET).
  3. Default-deny everything else (ERRNO or KILL) — the deny-by-default spine that runs through this whole curriculum (Phase 00 → IAM → admission → here).

This lab models exactly that decision. A Policy compiles to a Program; evaluate runs the three steps against a SyscallEvent(syscall, arch, args) and returns the action.

3. Bypass 1 — The Missing Architecture Check

This is the bug most hand-written seccomp policies have, and it is genuinely subtle.

Syscall numbers are not portable across ABIs. On x86-64:

  • read is syscall 0, write is 1, execve is 59.

But the same CPU can execute the i386 (32-bit) ABI, where:

  • syscall 0 is restart_syscall, write is 4, execve is 11, and crucially the legacy socketcall/open/etc. have entirely different numbers.

If your filter matches on syscall number but does not first check arch, then a process can switch to the i386 ABI (via a far call to the 32-bit entry point) and invoke a syscall whose number you allowed but whose meaning is a different, dangerous call. Your allowlist for "read (0)" just permitted "restart_syscall (0)" — or worse, a number you denied on x86-64 maps to a call you didn't intend to expose. The architecture check must be the first instruction precisely so that no number comparison is ever reached for the wrong ABI.

In the lab, evaluate returns KILL for an event whose arch differs from the program's when the program pins the architecture, and a policy with pin_architecture=False lets an alternate-ABI syscall through — audit_policy flags this as a high-severity missing-arch-check. The test test_missing_arch_pin_lets_alternate_abi_through demonstrates the live bypass.

4. Bypass 2 — Semantic Over-Trust

seccomp reads registers, not memory. The BPF filter runs in a context where it cannot safely dereference user-space pointers (the memory could change between the check and the use — a TOCTOU race — and the kernel deliberately forbids it). Therefore:

  • It can constrain a scalar argument: socket(AF_INET, ...) → check args[0] == 2.
  • It cannot constrain a pointer argument: openat(dirfd, PATHNAME, ...) — the pathname is a pointer; seccomp cannot see "/etc/shadow" vs "/tmp/ok". Likewise connect(fd, SOCKADDR, ...) — the destination address is behind a pointer.

The consequence: allowing openat in seccomp is not "the process may open files I approve"; it is "the process may open any file." Path and destination authorization belong to a different layer — filesystem permissions, an LSM (AppArmor/SELinux/Landlock), or network policy (Phase 06 WARMUP, Chapter 6: seccomp is not semantic authorization). Treating a seccomp allow of a pointer-target syscall as if it were access control is a category error that leaves the real authorization unenforced.

audit_policy flags any allowed openat/open/connect/execve/mount/rename as a medium non-semantic-allow, with the remediation: enforce the target in the right layer.

5. Threat Model

Asset        : the host kernel attack surface reachable from a (possibly compromised) workload
Actor        : code running inside the sandbox, fully attacker-controlled after an RCE
Invariant 1  : a syscall the policy did not allow is denied (default-deny)
Invariant 2  : the policy cannot be evaded by switching ABI (architecture is pinned first)
Invariant 3  : pointer-target authorization is enforced by the proper layer, not assumed by seccomp
Demonstrated : evaluate() denies unlisted calls, kills wrong-ABI calls; audit_policy finds the gaps
Out of scope : installing a real filter; kernel exploitation

6. Architecture of This Lab

Policy(arch, allow=[Rule(syscall, args)], default_action, pin_architecture)
        │  compile_policy
        ▼
Program(arch, pins_arch, rules, default_action)
        │  evaluate(program, SyscallEvent(syscall, arch, args))
        ▼
   ALLOW | ERRNO | KILL ...

audit_policy(Policy) ──▶ [Finding(kind, detail, severity)]   # static review
is_hardened(Policy)  ──▶ bool                                  # pin + default-deny + no high/critical

7. Implementation Steps

Edit lab.py:

  1. compile_policy — package the policy fields into a Program.
  2. evaluate — the three-step decision: (a) if the program pins the arch and the event's arch differs, KILL; (b) match a rule by syscall name and all its arg constraints → ALLOW; (c) else default_action. The order matters — the arch check is first.
  3. audit_policy — emit findings: missing-arch-check (high) when the arch isn't pinned; default-allow (critical) when the default is ALLOW; non-semantic-allow (medium) for each allowed pointer-target syscall; dangerous-syscall (medium) for ptrace, process_vm_writev, keyctl, bpf, mount, unshare, clone.
  4. is_hardened — pin + default-deny + no high/critical findings.

8. Running and Expected Evidence

LAB_MODULE=solution pytest -q     # reference — all tests pass
pytest -q                          # your implementation

The suite proves: allowed calls pass; unlisted calls hit default-deny; arg constraints are enforced (AF_INET allowed, AF_PACKET denied); a wrong-ABI call is killed when the arch is pinned; the same call slips through when the arch is not pinned (the bypass); and the auditor flags all four weakness classes while passing a hardened policy.

9. Writing a Real Policy

The model maps directly to how you build a real profile (Phase 06 HITCHHIKERS, "seccomp"):

  1. Observe, don't guess: trace the workload (strace -f, seccomp audit logs, or libseccomp's SCMP_ACT_LOG) across startup, steady state, error, locale, DNS, and shutdown paths — one happy-path trace under-counts the needed calls.
  2. Allowlist the observed set, pin the arch, default-deny.
  3. Regression-test it (this is the sibling Lab 02, Seccomp Regression Analyzer): after a dependency upgrade, separate approved new syscalls from unexplained ones.
  4. Layer the real authorization (paths, destinations) in the filesystem/LSM/network — never assume seccomp did it.

10. Hardening Tasks (Stretch)

  • Add a socketcall / multiplexed-syscall model (the i386 socketcall demultiplexes socket operations through one number) and show why number-only filtering is even weaker on legacy ABIs.
  • Model SECCOMP_RET_TRAP + a supervisor (seccomp user-notification): add a privileged handler and reason about its race / availability / confused-deputy risk.
  • Compute a diff between two policies (the regression-analyzer interface) and label each added syscall as approved/unexplained.
  • Generate a minimal allowlist from a supplied syscall trace and flag any allowed call the trace never exercised (over-permission).

11. Common Mistakes

  • Putting the syscall checks before the arch check (or omitting the arch check). The arch guard must be first; otherwise number comparisons run for the wrong ABI.
  • Believing a seccomp openat allow controls which files. It does not — seccomp can't read the path pointer. Enforce paths with the filesystem/LSM.
  • Default-allow with a denylist. Denylists miss the syscall you didn't think of; seccomp must be a default-deny allowlist.
  • Auto-generating the allowlist from one trace. Error/locale/DNS/upgrade paths need more calls; an incomplete allowlist breaks the service in production (Phase 06 WARMUP, Chapter 6).
  • Allowing ptrace/process_vm_writev "for debugging" — they let one process read/write another's memory and routinely defeat the sandbox.

12. Interview Questions

  1. Why must a seccomp filter check the architecture first? Syscall numbers differ per ABI; a process can switch to (e.g.) the i386 ABI on x86-64, so a number-only filter would match the wrong call. The arch guard, evaluated first, kills foreign-ABI syscalls before any number comparison.
  2. Can seccomp stop a process from opening /etc/shadow? No — openat's pathname is a pointer seccomp can't dereference. seccomp can allow/deny the openat syscall; which file is authorized by filesystem permissions or an LSM. Confusing the two leaves the real control unenforced.
  3. Allowlist vs denylist for seccomp? Always allowlist + default-deny. A denylist misses the call you didn't enumerate and the alternate-ABI equivalents.
  4. What's the seccomp TOCTOU / compatibility tradeoff? seccomp sees raw args, so a pointer check can race the memory it points to (semantic auth belongs elsewhere); and too-tight a profile breaks error/locale/DNS/upgrade paths, so you build it from observed-plus-reviewed behavior with regression tests.
  5. How does seccomp compose with namespaces, capabilities, and LSMs? Each closes a different gap: namespaces virtualize visibility, capabilities split root, LSMs add mandatory path/label policy, seccomp shrinks the syscall surface. Defense in depth — no single one is "the sandbox."

13. Resume Bullet

Built a seccomp-BPF policy compiler and evaluator that models the kernel's number+architecture+argument decision, detects the missing-architecture-check ABI bypass and semantic over-trust of pointer-target syscalls, and gates policies on a default-deny, arch-pinned hardening standard.

14. References

  • Linux kernel documentation: seccomp(2), seccomp_unotify(2), and Documentation/userspace-api/seccomp_filter.rst.
  • libseccomp documentation; Kees Cook's seccomp talks and LWN seccomp articles.
  • moby/containerd default seccomp profile (a real-world allowlist to study).
  • Chrome sandbox / OpenSSH / systemd SystemCallFilter as production seccomp consumers.
  • Phase 01 WARMUP Chapter 8 (the syscall boundary); Phase 06 WARMUP Chapters 3 and 6.