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 category | Applies to | Why a distinct filter |
|---|---|---|
vmm | The VMM thread (event loop, device emulation, MMDS, snapshots) | Needs the widest set: ioctl, read/write, epoll_*, mmap, networking via TAP. |
api | The API thread (HTTP server on the UDS) | Needs socket/HTTP syscalls; never touches KVM. |
vcpu | Each 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:
| Operator | Meaning |
|---|---|
eq | argument == val |
ne | argument != val |
ge / gt | argument >= val / > val |
le / lt | argument <= 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/<arch>.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
| Flag | Effect | Use |
|---|---|---|
| (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-seccomp | Installs no filter. | Debugging only. |
Warning:
--no-seccompremoves the entire syscall sandbox. It exists for debugging (e.g. running understraceorgdbwhere 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:
- Why does Firecracker ship three filters instead of one, and which thread class gets the tightest?
- Take the
ioctlrule with anargscondition: what does it restrict, and why is restrictingioctlspecifically so important for a VMM? - Explain
masked_eqand give a case where you'd need it rather thaneq. - When does JSON become BPF — build time or runtime? Where does the BPF live in the default config?
- What exactly happens, signal by signal, when the VMM attempts a denied syscall?
- 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
| Symptom | Root cause | Where to look |
|---|---|---|
VMM exits with SIGSYS / "bad syscall" log | A code path makes a syscall not in the category's filter | Add the syscall to resources/seccomp/<arch>.json, rebuild |
New device works with --no-seccomp, dies with it | Filter doesn't allow the device's syscalls | The relevant category (usually vmm) JSON |
ioctl-based feature blocked even though ioctl is allowed | An args condition pins ioctl to specific request numbers; yours isn't listed | The args/eq value on the ioctl rule |
| Custom filter file rejected at startup | Malformed/incompatible BPF from --seccomp-filter | Regenerate with the matching seccompiler-bin; check arch |
| Tests pass locally, fail in CI on a different arch | x86_64 vs aarch64 filters differ; syscall numbers differ | The other arch's JSON; never hard-code syscall numbers |
Crash under gdb/strace only | The filter traps ptrace/process_vm_* | Use --no-seccomp for debugging sessions only |
Validation: prove you understand this
- Name the three filter categories, the thread each protects, and why a per-thread filter is tighter than one global filter.
- Describe the
SyscallRulemodel: syscall name,argsconditions, the operator set, and how multiple conditions / multiple rules combine. - Explain the role of the
seccompilercrate and the exact moment JSON becomes BPF. - Trace what happens from a denied syscall through to process exit, naming the BPF return action and the signal.
- Explain what
--no-seccompand--seccomp-filtereach do and when (if ever) each is appropriate. - 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.