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
- 2. How a Filter Decides
- 3. Bypass 1 — The Missing Architecture Check
- 4. Bypass 2 — Semantic Over-Trust
- 5. Threat Model
- 6. Architecture of This Lab
- 7. Implementation Steps
- 8. Running and Expected Evidence
- 9. Writing a Real Policy
- 10. Hardening Tasks (Stretch)
- 11. Common Mistakes
- 12. Interview Questions
- 13. Resume Bullet
- 14. References
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:
| Action | Effect |
|---|---|
SECCOMP_RET_ALLOW | permit the syscall |
SECCOMP_RET_ERRNO | fail the syscall with an errno (the workload sees EPERM) |
SECCOMP_RET_TRAP | deliver SIGSYS (can be handled) |
SECCOMP_RET_LOG | allow but record it |
SECCOMP_RET_KILL_PROCESS | terminate 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:
- Pin the architecture:
if arch != AUDIT_ARCH_X86_64: KILL. (See §3 for why this is first and non-negotiable.) - Allow specific syscalls by number, optionally constrained on scalar/flag arguments
(e.g. allow
socketonly whenargs[0] == AF_INET). - Default-deny everything else (
ERRNOorKILL) — 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:
readis syscall 0,writeis 1,execveis 59.
But the same CPU can execute the i386 (32-bit) ABI, where:
- syscall 0 is
restart_syscall,writeis 4,execveis 11, and crucially the legacysocketcall/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, ...)→ checkargs[0] == 2. - It cannot constrain a pointer argument:
openat(dirfd, PATHNAME, ...)— the pathname is a pointer; seccomp cannot see"/etc/shadow"vs"/tmp/ok". Likewiseconnect(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:
compile_policy— package the policy fields into aProgram.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) elsedefault_action. The order matters — the arch check is first.audit_policy— emit findings:missing-arch-check(high) when the arch isn't pinned;default-allow(critical) when the default isALLOW;non-semantic-allow(medium) for each allowed pointer-target syscall;dangerous-syscall(medium) forptrace,process_vm_writev,keyctl,bpf,mount,unshare,clone.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"):
- Observe, don't guess: trace the workload (
strace -f,seccompaudit logs, or libseccomp'sSCMP_ACT_LOG) across startup, steady state, error, locale, DNS, and shutdown paths — one happy-path trace under-counts the needed calls. - Allowlist the observed set, pin the arch, default-deny.
- Regression-test it (this is the sibling Lab 02, Seccomp Regression Analyzer): after a dependency upgrade, separate approved new syscalls from unexplained ones.
- 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 i386socketcalldemultiplexes 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
openatallow 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
- 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.
- Can seccomp stop a process from opening
/etc/shadow? No —openat's pathname is a pointer seccomp can't dereference. seccomp can allow/deny theopenatsyscall; which file is authorized by filesystem permissions or an LSM. Confusing the two leaves the real control unenforced. - Allowlist vs denylist for seccomp? Always allowlist + default-deny. A denylist misses the call you didn't enumerate and the alternate-ABI equivalents.
- 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.
- 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), andDocumentation/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
SystemCallFilteras production seccomp consumers. - Phase 01 WARMUP Chapter 8 (the syscall boundary); Phase 06 WARMUP Chapters 3 and 6.