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 seccompiler crate (src/seccompiler/ in the workspace) and the rust-vmm seccompiler exists externally; they are the same lineage. Which one your build uses is a "check it on your branch" question — rg below. 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

ItemIsRole
BpfProgramVec<sock_filter> — a compiled classic-BPF programwhat seccomp(2) installs
BpfMapHashMap<String, BpfProgram>one program per thread category
SeccompFilterthe in-memory model of one filtera default_action + rules, before compilation
Actionwhat to do on a matchAllow, Errno(n), KillThread, KillProcess, Trap, Log, Trace
Rule / SeccompRuleone syscall's match conditiona syscall number + optional argument conditions
Condition / SeccompConditionan argument constraintarg_index, an operator, a value
compile_from_jsonfn: JSON reader → BpfMapthe compile step (build time)
apply_filterfn: &BpfProgram → install it on the current threadthe 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/
PhaseWhat Firecracker doesseccompiler piece
Builda build step / build.rs runs compile_from_json on resources/seccomp/<arch>.json and embeds the BpfMapcompile_from_json, BpfMap
Thread start (api)the API thread installs the "api" program before serving the socketapply_filter
Thread start (vmm)the VMM thread installs the "vmm" program before the event loopapply_filter
Thread start (vcpu)each vCPU thread installs the "vcpu" program before KVM_RUNapply_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-seccomp removes 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:

  1. Why does Firecracker compile its seccomp filters at build time and apply them at runtime, rather than compiling at startup?
  2. Name the three thread categories and explain why each gets a different filter.
  3. What does an argument-level Condition (e.g. op: "eq" on ioctl arg1) buy you that a plain syscall allow-list cannot?
  4. Walk the pipeline: JSON → compile_from_json → BpfMap → apply_filter → kernel. Which step is build time, which is runtime, and what does each produce?
  5. Does the jailer apply seccomp? Who does, and when in a thread's life?
  6. What exactly does --no-seccomp give up, and why is it production-forbidden?

Common bugs and symptoms

SymptomRoot causeWhere to look
Firecracker dies with SIGSYS (trap) on a new featurethe feature calls a syscall not in the filter for that threadadd the syscall to the right category in resources/seccomp/<arch>.json
New code works only with --no-seccompthe filter is too strict for the new code paththe per-category filter; do not ship with seccomp off
Filter compiles but blocks too muchwrong thread category, or an args condition too narrowthe category placement; the Condition operators
compile_from_json build errormalformed JSON / unknown syscall name / bad operatorthe JSON syntax; the supported operator set
ioctl allowed too broadlyrule allows all ioctl instead of constraining arg1the 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.