seccompiler
Firecracker's last line of defense against a compromised VMM is a seccomp-BPF
filter: a whitelist of the ~40 syscalls each thread is allowed to make, enforced
by the kernel, so that even if an attacker gains code execution inside the
firecracker process they cannot call ptrace, open arbitrary paths, or do
anything outside the tiny allowed set. Those filters are written as JSON (in
resources/seccomp/<arch>.json), and something has to turn that JSON into the
classic-BPF program the seccomp(2) syscall actually wants. That something is
seccompiler — a crate that originated as Firecracker's in-tree seccomp
compiler and was contributed to rust-vmm so other VMMs could reuse it. This is the
seccomp filtering deep dive's build tool.
After this chapter you can: explain the JSON→BPF pipeline; name the key types
(BpfProgram, BpfMap, SeccompFilter, Action, Rule, Condition) and
functions (compile_from_json, apply_filter); describe how Firecracker compiles
its filters at build time and applies them per-thread; and write a small
filter.
Note — two seccompilers, verify which. Firecracker keeps an in-tree
seccompilercrate (src/seccompiler/in the workspace) and the rust-vmmseccompilerexists externally; they are the same lineage. Which one your build uses is a "check it on your branch" question —rgbelow. The concepts are identical either way.
# Which seccompiler does your build use — the in-tree crate or the external dep?
rg -n "seccompiler" Cargo.toml src/*/Cargo.toml Cargo.lock
ls src/seccompiler/ 2>/dev/null && echo "in-tree seccompiler crate present"
cargo doc -p seccompiler --no-deps --open 2>/dev/null || echo "see src/seccompiler/"
docs.rs: docs.rs/seccompiler.
The pipeline: JSON → BPF → kernel
flowchart LR
J["resources/seccomp/x86_64.json\n(per-thread filters: vmm / api / vcpu)"]
J -->|compile_from_json| BM["BpfMap\n{ \"vmm\": BpfProgram, \"api\": ..., \"vcpu\": ... }"]
BM -->|baked into binary at build time| BIN["firecracker binary"]
BIN -->|"per thread, before it does real work"| AF["apply_filter(&BpfProgram)"]
AF -->|seccomp(2) SET_MODE_FILTER| K["kernel enforces the whitelist"]
The JSON describes, per thread category (vmm, api, vcpu), a
default_action and a list of syscall rules. seccompiler compiles that into a
BpfMap — a map from thread-category name to a BpfProgram (a Vec of BPF
instructions). Firecracker does this at build time and embeds the result in the
binary; at runtime each thread looks up its category's BpfProgram and installs it
with apply_filter just before it starts doing untrusted work.
The key types and functions
| Item | Is | Role |
|---|---|---|
BpfProgram | Vec<sock_filter> — a compiled classic-BPF program | what seccomp(2) installs |
BpfMap | HashMap<String, BpfProgram> | one program per thread category |
SeccompFilter | the in-memory model of one filter | a default_action + rules, before compilation |
Action | what to do on a match | Allow, Errno(n), KillThread, KillProcess, Trap, Log, Trace |
Rule / SeccompRule | one syscall's match condition | a syscall number + optional argument conditions |
Condition / SeccompCondition | an argument constraint | arg_index, an operator, a value |
compile_from_json | fn: JSON reader → BpfMap | the compile step (build time) |
apply_filter | fn: &BpfProgram → install it on the current thread | the apply step (runtime, per thread) |
The operators a Condition supports map directly to the JSON: eq, ge, gt,
le, lt, ne, masked_eq. That last one — masked_eq — is what lets a filter
say "allow mmap only with these protection/flag bits," which is how Firecracker
permits exactly the mmap shapes it needs and nothing else.
find ~/.cargo/registry/src -maxdepth 2 -type d -name 'seccompiler-*' \
-exec rg -n "pub type BpfProgram|pub type BpfMap|enum SeccompAction|struct SeccompFilter|struct SeccompRule|struct SeccompCondition|fn compile_from_json|fn apply_filter" {} + \
2>/dev/null || rg -n "BpfProgram|BpfMap|compile_from_json|apply_filter|SeccompFilter|SeccompAction" src/seccompiler/src/
The JSON shape
The filter language is small. Per thread category: a default_action (what happens
to any syscall not explicitly allowed — Firecracker uses trap or
kill_process), and a filter list of rules.
{
"vcpu": {
"default_action": "trap",
"filter_action": "allow",
"filter": [
{ "syscall": "read" },
{ "syscall": "write" },
{
"syscall": "ioctl",
"args": [
{ "index": 1, "type": "dword", "op": "eq", "val": 44672 }
]
}
]
},
"api": { "default_action": "trap", "filter_action": "allow", "filter": [ ] },
"vmm": { "default_action": "trap", "filter_action": "allow", "filter": [ ] }
}
The ioctl rule is the interesting one: it doesn't allow all ioctls, only ioctl
with a specific request number in arg1 (here a KVM_* request). That argument-
level filtering is exactly why Firecracker's vCPU thread can call KVM_RUN but not
arbitrary ioctls — the difference between a useful filter and a useless one.
Tip: Read the real filters on your branch — they are the canonical reference for "what syscalls does Firecracker actually need":
ls resources/seccomp/ jq 'keys' resources/seccomp/x86_64.json # the three categories jq '.vcpu.filter | length' resources/seccomp/x86_64.json
A small worked example
#![allow(unused)] fn main() { // Compile a JSON filter set and apply this thread's program. (Shape; types per // the pinned seccompiler version.) use seccompiler::{apply_filter, compile_from_json, BpfMap}; use std::io::Cursor; fn lock_down_this_thread(category: &str) -> Result<(), Box<dyn std::error::Error>> { let json = br#"{ "main": { "default_action": "trap", "filter_action": "allow", "filter": [ {"syscall":"read"}, {"syscall":"write"}, {"syscall":"exit_group"} ] } }"#; // 1. Compile JSON -> BpfMap (Firecracker does this at BUILD time). let map: BpfMap = compile_from_json(Cursor::new(&json[..]), seccompiler::TargetArch::x86_64)?; // 2. Look up this thread category's program and install it (RUNTIME). let program = map.get(category).ok_or("no such category")?; apply_filter(program)?; // seccomp(2) SET_MODE_FILTER on this thread // From here on, any syscall other than read/write/exit_group → trap. Ok(()) } }
The split is the whole point: compile once, at build time, into the binary;
apply per thread, at runtime, right before that thread touches anything
untrusted. Compiling at runtime would mean shipping the JSON and a compiler in
the production binary — more attack surface, slower start. Baking the BpfMap in
avoids both.
How Firecracker uses it
# Build-time compilation of resources/seccomp/*.json into the binary:
rg -n "compile_from_json|seccompiler|seccomp|BpfMap|include_bytes!|build.rs" build.rs src/firecracker/build.rs src/vmm/build.rs 2>/dev/null
find src -name build.rs | xargs rg -n "seccomp|compile_from_json|BpfMap" 2>/dev/null
# Runtime, per-thread application (the api/vmm/vcpu categories):
rg -n "apply_filter|install_filter|seccomp|get_filters|BpfThreadMap|vcpu.*filter|api.*filter" src/vmm/src/ src/firecracker/src/
| Phase | What Firecracker does | seccompiler piece |
|---|---|---|
| Build | a build step / build.rs runs compile_from_json on resources/seccomp/<arch>.json and embeds the BpfMap | compile_from_json, BpfMap |
| Thread start (api) | the API thread installs the "api" program before serving the socket | apply_filter |
| Thread start (vmm) | the VMM thread installs the "vmm" program before the event loop | apply_filter |
| Thread start (vcpu) | each vCPU thread installs the "vcpu" program before KVM_RUN | apply_filter |
The override knobs you'll see in seccomp filtering
— --seccomp-filter <path> to swap in a custom compiled filter, and --no-seccomp
(development only, never production) to skip it — are Firecracker plumbing around
the same apply_filter. The jailer does not apply seccomp; firecracker does,
itself, per thread, after the jailer has already dropped privileges. Defense in
depth: the jailer is one layer, seccomp another.
Warning:
--no-seccompremoves a defense-in-depth layer and is for local debugging only. A production deployment that disables seccomp has voluntarily given up one of the four pillars of the Firecracker threat model (KVM boundary + jailer + seccomp + Rust). Treat any PR or doc that suggests it for production as a red flag.
Reading exercise
# 1. Which seccompiler (in-tree vs external) and its version.
rg -n "seccompiler" Cargo.toml Cargo.lock
ls src/seccompiler 2>/dev/null
# 2. The crate's types and functions (pinned version).
cargo doc -p seccompiler --no-deps --open 2>/dev/null || rg -n "pub" src/seccompiler/src/lib.rs
# 3. The real filters and their three categories.
ls resources/seccomp/
jq 'keys' resources/seccomp/x86_64.json
# 4. Build-time compilation.
find src -name build.rs | xargs rg -n "seccomp|compile_from_json|BpfMap"
# 5. Runtime per-thread application.
rg -n "apply_filter|BpfThreadMap|get_filters|install" src/vmm/src/ src/firecracker/src/
# 6. The override flags.
rg -n "no-seccomp|seccomp-filter|--no-seccomp|SeccompConfig" src/firecracker/src/
Answer:
- Why does Firecracker compile its seccomp filters at build time and apply them at runtime, rather than compiling at startup?
- Name the three thread categories and explain why each gets a different filter.
- What does an argument-level
Condition(e.g.op: "eq"onioctlarg1) buy you that a plain syscall allow-list cannot? - Walk the pipeline: JSON →
compile_from_json→BpfMap→apply_filter→ kernel. Which step is build time, which is runtime, and what does each produce? - Does the jailer apply seccomp? Who does, and when in a thread's life?
- What exactly does
--no-seccompgive up, and why is it production-forbidden?
Common bugs and symptoms
| Symptom | Root cause | Where to look |
|---|---|---|
Firecracker dies with SIGSYS (trap) on a new feature | the feature calls a syscall not in the filter for that thread | add the syscall to the right category in resources/seccomp/<arch>.json |
New code works only with --no-seccomp | the filter is too strict for the new code path | the per-category filter; do not ship with seccomp off |
| Filter compiles but blocks too much | wrong thread category, or an args condition too narrow | the category placement; the Condition operators |
compile_from_json build error | malformed JSON / unknown syscall name / bad operator | the JSON syntax; the supported operator set |
| ioctl allowed too broadly | rule allows all ioctl instead of constraining arg1 | the args/masked_eq constraint on the ioctl rule |
Next: vmm-sys-util — the low-level glue under everything you've
read: EventFd (the Trigger, the ioeventfd, the API wake-up), the ioctl_with_*
macros behind kvm-ioctls, and the FAM-struct wrapper behind kvm-bindings.