Seccomp Filtering

Seccomp-BPF is Firecracker's syscall-level sandbox: a Berkeley Packet Filter program, installed on every thread, that inspects each syscall the VMM attempts and either allows it, denies it, or kills the process. Where the jailer confines the VMM at the process level (filesystem, namespaces, privileges), seccomp confines it at the kernel-interface level. A guest that finds a bug in Firecracker's device emulation and turns it into arbitrary VMM code execution still cannot make a syscall that is not on the allow-list — it cannot execve, cannot ptrace, cannot open arbitrary files. That is the last line of defense between the guest and the host.

This chapter covers the per-thread-category filters shipped in resources/seccomp/<arch>.json, the SyscallRule model (syscall + argument operators), the seccompiler crate that compiles JSON to BPF at build time, how the filter is loaded at runtime (--seccomp-filter / --no-seccomp), and exactly how a denied syscall manifests.

Note: Firecracker installs seccomp itself, not the jailer. The filter is compiled into the binary at build time (the JSON becomes BPF baked into the executable), and loaded by each thread as it starts. So by the time any vCPU enters KVM_RUN, its thread is already syscall-restricted.


The three filter categories

# The JSON source filters, one file per architecture.
find resources/seccomp -name "*.json"
rg -n "\"vmm\"|\"api\"|\"vcpu\"|default_action|filter_action" resources/seccomp/

Firecracker does not ship one filter — it ships three, one per thread class, because each thread class makes a different set of syscalls and the tightest filter is the per-thread one:

Filter categoryApplies toWhy a distinct filter
vmmThe VMM thread (event loop, device emulation, MMDS, snapshots)Needs the widest set: ioctl, read/write, epoll_*, mmap, networking via TAP.
apiThe API thread (HTTP server on the UDS)Needs socket/HTTP syscalls; never touches KVM.
vcpuEach vCPU thread (the KVM_RUN loop)Needs the fewest: mostly ioctl(KVM_RUN) and signal handling.

The JSON top level is a map from category name to a filter object. Each filter object has a default_action (what to do for syscalls not explicitly listed — typically trap or kill_thread) and a filter array of per-syscall rules. Read one filter object end to end:

# Pretty-print the vcpu filter for your arch and count its syscalls.
python3 -m json.tool resources/seccomp/$(uname -m)-unknown-linux-musl.json | sed -n '1,60p'
rg -n "syscall" resources/seccomp/$(uname -m)*.json | wc -l

The SyscallRule model: syscall plus argument operators

rg -n "SyscallRule|SeccompRule|SeccompCondition|SeccompAction|enum .*Operator|masked_eq|MaskedEq" src/seccompiler/src/

A rule is not just "allow syscall N." Seccomp-BPF can inspect a syscall's register arguments, and Firecracker uses this to allow, for example, ioctl only with specific request numbers (only KVM_RUN, KVM_SET_USER_MEMORY_REGION, etc. — not arbitrary ioctls). The JSON expresses this as a syscall name plus an optional list of argument conditions:

{
  "syscall": "ioctl",
  "args": [
    { "index": 1, "type": "dword", "op": "eq", "val": 44672 }
  ]
}

index is the argument position (0–5), type is its width, op is the comparison operator, and val is the value to compare against. The operators:

OperatorMeaning
eqargument == val
neargument != val
ge / gtargument >= val / > val
le / ltargument <= val / < val
masked_eq(argument & mask) == val — for flag/bitfield checks

A syscall entry with no args allows the syscall unconditionally. Multiple args conditions on one rule are AND-ed; multiple rules for the same syscall are OR-ed. This is how Firecracker pins ioctl to a small set of KVM/TUN request codes instead of opening the entire ioctl multiplexer.


seccompiler: JSON → BPF at build time

# The compiler crate (donated upstream to rust-vmm, but maintained in-tree).
find src/seccompiler -name "*.rs" | sort
rg -n "fn compile|to_bpf|BpfProgram|sock_filter|SECCOMP_RET|fn main" src/seccompiler/src/

seccompiler is a small compiler: it reads the JSON filter description and emits a BpfProgram (a vector of sock_filter instructions) for each category. It runs at build time — the produced BPF is embedded into the firecracker binary so there is no JSON to ship, parse, or tamper with at runtime in the default configuration. The same crate was donated to rust-vmm as the external seccompiler (see ../rust-vmm/seccompiler.md).

flowchart LR
    JSON["resources/seccomp/&lt;arch&gt;.json"] --> SC["seccompiler (build time)"]
    SC --> BPF["BPF programs per category"]
    BPF --> BIN["baked into the firecracker binary"]
    BIN --> Load["each thread: install_filter() on start"]
    Load --> Kernel["kernel attaches BPF to the thread"]
    Kernel --> Check["every syscall checked against the filter"]

At runtime, the loading happens in Firecracker's seccomp module:

rg -n "fn .*seccomp|install_filter|apply_filter|BpfThreadMap|get_filters|SeccompFilter|seccomp" src/vmm/src/seccomp.rs

Find src/vmm/src/seccomp.rs (anti-staleness: rg -n "seccomp" src/vmm/src/ if it has moved). It holds the embedded filters keyed by thread name and applies the right one as each thread spins up.


Loading, overriding, and disabling

rg -n "seccomp-filter|no-seccomp|seccomp_level|SeccompConfig|from_args" src/firecracker/src/
sed -n '1,60p' docs/seccomp.md
FlagEffectUse
(default, no flag)Loads the built-in compiled filters.Production.
--seccomp-filter <path>Loads a custom BPF filter file (produced by seccompiler-bin) instead of the built-in one.Advanced: you've extended the device model and need extra syscalls.
--no-seccompInstalls no filter.Debugging only.

Warning: --no-seccomp removes the entire syscall sandbox. It exists for debugging (e.g. running under strace or gdb where the filter interferes). Never use it in production — the docs and the maintainers will reject any production configuration that does.

If you add a syscall to the VMM (say, a new device that needs a syscall the vmm filter doesn't allow), you must add it to the JSON and rebuild — otherwise your new code will be killed the first time it makes that syscall. This is a common stumbling block when extending Firecracker, and it is by design: the filter forces you to declare your kernel-interface footprint explicitly.


How a denied syscall manifests

When a thread makes a syscall the filter does not allow, the kernel applies the filter's action. For Firecracker's filters this is typically SECCOMP_RET_TRAP, which delivers a SIGSYS signal to the offending thread. Firecracker installs a handler for SIGSYS that logs the bad syscall number and the faulting thread, then terminates — a fast, loud, fail-closed exit:

rg -n "SIGSYS|sys_seccomp|si_syscall|fn sigsys|signal_handler" src/vmm/src/signal_handler.rs
guest exploits a device bug → tries to make VMM call e.g. execve
        │
        ▼
seccomp BPF: execve not in allow-list → SECCOMP_RET_TRAP
        │
        ▼
kernel delivers SIGSYS to that thread
        │
        ▼
Firecracker's SIGSYS handler logs "bad syscall N" → process exits
        │
        ▼  the host is never compromised; the microVM is gone

This is the payoff of the whole design: even a complete VMM-code-execution exploit dead-ends at the seccomp boundary. The signal path itself is covered in signals-shutdown-and-reset.md.


Reading exercise

# 1. List the three filter categories and the per-arch JSON files.
find resources/seccomp -name "*.json"
rg -n "\"vmm\"|\"api\"|\"vcpu\"|default_action" resources/seccomp/

# 2. Inspect one rule that pins ioctl by argument (the KVM_RUN restriction).
rg -n -A6 "\"syscall\": \"ioctl\"" resources/seccomp/$(uname -m)*.json | head -40

# 3. Find the operator enum in seccompiler.
rg -n "Operator|masked_eq|MaskedEq|eq|ge|gt|le|lt|ne" src/seccompiler/src/

# 4. Find where Firecracker loads the compiled filters per thread.
rg -n "install_filter|apply_filter|get_filters|BpfThreadMap|seccomp" src/vmm/src/seccomp.rs

# 5. Find the SIGSYS handler that fires on a denied syscall.
rg -n "SIGSYS|si_syscall|seccomp" src/vmm/src/signal_handler.rs

# 6. Build, then run with --no-seccomp under strace to *see* the difference (dev box only).
#    Without --no-seccomp, a forbidden syscall ends in SIGSYS.

Answer:

  1. Why does Firecracker ship three filters instead of one, and which thread class gets the tightest?
  2. Take the ioctl rule with an args condition: what does it restrict, and why is restricting ioctl specifically so important for a VMM?
  3. Explain masked_eq and give a case where you'd need it rather than eq.
  4. When does JSON become BPF — build time or runtime? Where does the BPF live in the default config?
  5. What exactly happens, signal by signal, when the VMM attempts a denied syscall?
  6. You add a device that calls a new syscall and it dies immediately on first run. What did you forget, and how do you fix it?

Common bugs and symptoms

SymptomRoot causeWhere to look
VMM exits with SIGSYS / "bad syscall" logA code path makes a syscall not in the category's filterAdd the syscall to resources/seccomp/<arch>.json, rebuild
New device works with --no-seccomp, dies with itFilter doesn't allow the device's syscallsThe relevant category (usually vmm) JSON
ioctl-based feature blocked even though ioctl is allowedAn args condition pins ioctl to specific request numbers; yours isn't listedThe args/eq value on the ioctl rule
Custom filter file rejected at startupMalformed/incompatible BPF from --seccomp-filterRegenerate with the matching seccompiler-bin; check arch
Tests pass locally, fail in CI on a different archx86_64 vs aarch64 filters differ; syscall numbers differThe other arch's JSON; never hard-code syscall numbers
Crash under gdb/strace onlyThe filter traps ptrace/process_vm_*Use --no-seccomp for debugging sessions only

Validation: prove you understand this

  1. Name the three filter categories, the thread each protects, and why a per-thread filter is tighter than one global filter.
  2. Describe the SyscallRule model: syscall name, args conditions, the operator set, and how multiple conditions / multiple rules combine.
  3. Explain the role of the seccompiler crate and the exact moment JSON becomes BPF.
  4. Trace what happens from a denied syscall through to process exit, naming the BPF return action and the signal.
  5. Explain what --no-seccomp and --seccomp-filter each do and when (if ever) each is appropriate.
  6. You extend the device model with a syscall not currently allowed. Describe the failure you'll see and the precise change required to make it work in production.

Next: snapshotting.md — how a fully configured, jailed, seccomp-filtered microVM is frozen to disk and brought back to life.