Seccomp profiles: heimdall-seccomp and heimdall-probe
Heimdall is a defensive sandbox. This document covers milestone 3 (spec §3): the profile types and BPF builder (heimdall-seccomp), the learning-mode probe and generator (heimdall-probe), and heimdall profile probe|gen|check.
heimdall-seccomp
| Part | What it does |
Profile / StaticProfile |
The allowlist of one workload, arch (x86_64, aarch64) and libc (glibc, musl). StaticProfile is the const form a generated .rs holds. |
bpf::build |
A pure-Rust classic-BPF builder (no libseccomp, no C). |
Mode::Enforce |
A call outside the profile is SECCOMP_RET_KILL_PROCESS. The kernel writes an audit record (type=1326 … sig=31 syscall=<nr>, seen on oden); the launcher reports the child as killed by seccomp (SIGSYS). |
Mode::Audit(TestBox) |
A call outside the profile is SECCOMP_RET_LOG (it runs and is logged). Only for staging on test boxes: a TestBox exists only when HEIMDALL_SECCOMP_AUDIT_TEST_BOX=1. |
notif |
The USER_NOTIF API the broker and the probe are built on (below). |
launch |
Starts a program with a filter installed just before exec. |
interp |
A classic-BPF interpreter, so a program is checked offline against Profile::evaluate. |
oci |
The same profile as OCI seccomp JSON (seccompProfile: Localhost). |
Rule semantics (the same in Profile::evaluate, the BPF program and the interpreter): the rules of one syscall are tried in order; a rule matches when its when is empty or any alternative matches; an alternative matches when every ArgCmp holds: (arg & mask) == value, on the low 32 bits for W32 (int parameters, whose upper half the kernel ignores). The first match wins; no match gives the mode's default. In every mode, a foreign arch and (on x86_64) every x32 call (nr >= 0x40000000) are killed.
The launcher. After fork the child puts the program's O_PATH fd at fd 1022 and (with a listener) a socket at fd 1021, both close-on-exec, sets no_new_privs, installs the filter, sends the listener over fd 1021 (SCM_RIGHTS) and calls execveat(1022, "", argv, envp, AT_EMPTY_PATH). Every filter built by the launcher starts with a two-rule prelude that allows exactly that execveat and that sendmsg; a worker's profile needs no bare execve (a test shows the worker's own execve is killed).
The USER_NOTIF API (for heimdall-broker)
use heimdall_seccomp::notif::{AddFd, Listener, Response};
let mut child = Launch::new(prog).profile(&p, &Mode::Enforce).listener(true).spawn()?;
let l: Listener = child.take_listener().unwrap();
Listener::check_kernel()?; // struct sizes match this crate
loop {
match l.wait(None)? { Readiness::Hangup => break, _ => {} }
let n = l.recv()?; // n.id, n.pid, n.data.{nr, arch, args}
// read the target's memory, decide, open the file yourself...
if !l.id_valid(n.id)? { continue; } // the target died / the call was interrupted
l.add_fd(n.id, file.as_fd(), AddFd { cloexec, ..AddFd::send() })?; // inject + answer
// or: l.respond(n.id, Response::Errno(libc::EACCES))?;
}
Response::Continuelets the kernel run the call on the target's own arguments: only for decisions on registers alone (the probe), never after checking memory the target can change (spec §4 TOCTOU).recvreturnsENOENTwhen the target died before the receive;respondreturnsENOENTwhen the call is gone. Neither is a supervisor error.send_fd/recv_fdmove a listener over a unix socket from a process that installed its own filter.- Kernel: USER_NOTIF 5.0,
CONTINUE5.5,ADDFD5.9,ADDFD_FLAG_SEND5.14.
heimdall-probe
- Learning mode (
learn): the filter's default isSECCOMP_RET_USER_NOTIF; the probe records every call and answersCONTINUE. No strace, no ptrace (no ptrace fallback is built: every kernel Heimdall targets has 5.5).clone3is answeredENOSYSby the filter itself, so the libc falls back toclone, whose flags a filter can check; generated profiles keep that rule. - Argument facts (
facts::SPECS): socket/socketpair domain, type and protocol; open/openat flags; ioctl request; mmap/mprotect/pkey_mprotect prot; clone flags; prctl option; fcntl command. Everything else is recorded as the bare call. - Merge and minimise (
generate): exact tuples while at mostexact_limit(16) distinct, else a reported bare allow; flag fields (openat, clone) become "no bit outside the observed union"; prot values are exact, andPROT_WRITE|PROT_EXECtogether is reported and left out unless the workload setsallow_wx. - Review: near-miss calls (seen only in
error_pathruns; kept in the profile, listed), W|X findings, bare allows, unknown calls, fixed rules, the vDSO note. In the.json(review) and the.rsheader. - Codegen:
profiles/<workload>/<arch>-<libc>.rs(aconst StaticProfile, fully qualified paths,include!-able),.json,.oci.json. No dates; sorted; byte-identical for the same observations. - Drift check (
check): a fresh probe must be a subset of the checked-in.json; each failure readsnew call sysinfo (kill_process), made by test basic_run..rsand.oci.jsonmust match what.jsongenerates.
Learning where a program really runs
heimdall profile probe-exec and probe-container (CLI.md) learn ONE program while something else drives it, with the same filter and prelude as learn, and merge into an evidence file gen can read (heimdall_probe::live::{Evidence, record}):
probe-exec: the program runs on the host as it would anyway (a daemon on a booted node, a plugin its host runs).probe-container: the program runs as aHeimdallRunnernative job inside Heimdall's own container. The runtime installs no seccomp profile; the executor, inside the job after Landlock, installs the learning filter withNEW_LISTENER(ExecSelf::install_listener, noTSYNC: the job's init is single-threaded), sends the listener over the prelude'ssendmsgonHANDOFF_FD, reports on its report fd andexecveats the program. The shim hands the listener and the program's pid to the runner over an abstract unix socket (the runner accepts only its shim's pid,SO_PEERCRED); the per-boot key never leaves the runner, and no result is signed. The calls the prelude allows (execveatonEXEC_FD,sendmsgonHANDOFF_FD,writeon the report fd) are not recorded, exactly as withLaunch, so a profile generated from this evidence fitsgenprof's enforcement unchanged.
A run of a daemon (Postgres under TPC-DS, a web server under HTTP load) is error-path free unless the driver makes it fail; name such runs --error-path so their calls show as near-misses in the review.
workload.toml
workload = "heimdall-wasm-host"
timeout_secs = 60 # per run
repeat = 3 # every run this many times; the union is kept
exact_limit = 16
allow_wx = false
[binary]
glibc = "{target}/release/heimdall"
musl = "{target}/x86_64-unknown-linux-musl/release/heimdall"
[[run]]
name = "good_input_is_accepted" # the test name check reports
args = ["run", "--job", "wasm", "--module", "{corpus}/good.wasm", "--out", "{tmp}/out"]
expect_exit = 0 # a broken corpus fails the probe
[[run]]
name = "tampered_input_is_refused"
error_path = true
args = ["..."]
expect_exit = 4
Placeholders: {dir}, {corpus} (<dir>/corpus), {tmp} (a fresh dir per run, also its cwd), {target} ($CARGO_TARGET_DIR or <profiles>/../target). Runs start from an empty environment plus PATH=/usr/bin:/bin and [env].
Tests and where they run
cargo test -p nordisk-heimdall-seccomp -p nordisk-heimdall-probe: builder ==evaluate(proptest through the interpreter), modes, OCI, minimise, codegen, check. No kernel needed.scripts/seccomp-tests.sh: the kernel tests (--test kernelof both crates,harness = false, the test binary is its own worker) in a privileged rootless podman container, then the drift check of every checked-in profile.
Checked-in profiles
| Workload | Arch / libc | Rules | Notes |
heimdall-wasm-host (heimdall run --job wasm, the verify-bundle job's worker) |
x86_64 glibc | 39 | no socket, no execve, no memfd, no W|X; near-miss rt_sigreturn (stack overflow, trap); sha256:bd1a5bcd…2816272 |
heimdall-wasm-host |
x86_64 musl (static release) | 35 | same; sha256:8369f2ec…5642d23 |
Profiles a program's own repository checks in (measured with heimdall profile probe-exec where the program really runs, generated with heimdall profile gen, MIT-0; lane prof, 2026-11-01, oden kernel 7.0):
| Program | Where | Arch / libc | Rules | Digest | Enforced by |
Holger's scan worker (holger-scan-worker, static): verify-bundle native + split guest, scans of 13 real packages (pip, maven, rust, npm, helm, debian, rpm, oci, raw) and made-up subjects |
holger jobs/runner/seccomp/holger-scan-worker/ |
x86_64 musl | 32 | sha256:6d802b5e…ba752a |
HeimdallRunner, Policy.seccomp = OciFile pinned (every job but Holger's probe) |
| clamd (ClamAV 1.5.4): PING, INSTREAM of EICAR and real packages, RELOAD, bad clients, SelfCheck | holger server/sandbox/seccomp/clamd/ |
x86_64 musl | 58 | sha256:4a714c3d…19f53 |
Holger's crun sandbox (linux.seccomp, plus crun's own close_range and execve after the filter) |
Both were run green under their profile on a real kernel, and a call outside each was killed (the scan worker: signed heimdall-seccomp-1; clamd: SIGSYS, exit 159, clamd still serving); the READMEs there list the corpus and how to measure again.
The crate nordisk-heimdall-profiles (profiles/; publishable from 0.3.0, because heimdall-container and heimdall-cli enforce its tables at runtime; the package holds only the generated .rs/.json/.oci.json and workload.toml, not the corpus) compiles every .rs and holds it to its .json and .oci.json; its enforce test runs each profile in enforcing mode on the real release binary over the whole corpus. The corpus guests are written by crates/heimdall-cli/tests/profile_corpus.rs (small WAT guests, corpus/) and guests/verify-bundle/tests/probe_corpus.rs (the REAL verify-bundle guest, single and split with N = 1 and N = 8 decoder instances, over good stored, good OpenZL, many-chunk OpenZL, tampered and two bomb bundles: verify-bundle/ and the marked block of workload.toml). With the real guest the rule count stayed the same: wasmtime's own threads already needed every clone, futex, madvise and mmap tuple the decoder workers use; mremap moved from near-miss to a normal path (a good bundle grows the guest's memory).
A generated .rs starts with // SPDX-License-Identifier: MIT-0 and says it is generated output of Heimdall, not covered by Heimdall's AGPL: a profile is data about the profiled program, so MIT, Apache and proprietary repos can check it in. MIT-0 rather than CC0-1.0: it is OSI-approved and accepted where code licences are checked (some policies refuse CC0 for code, for its patent clause); it asks for no attribution. The .json forms carry no licence text.
Enforcing the profiles at runtime
- Native jobs (heimdall-container): a job's
policy.seccomp(or the runner'sHeimdallConfig::seccomp) names a profile by workload + digest or as an OCI JSON file pinned by sha256; the executor installs it on top of the fixed profile right before the job's program starts. Details, refusals and the fallback: API.md, "Generated seccomp profiles".heimdall_seccomp::oci::from_ocireads an enforcing OCI JSON back into aProfile(fails closed on anything it cannot represent exactly). - Enforcing the wasm host profile (
heimdall run --job wasm): wasmtime is the only native code of a wasm job, so the smallest place for the filter is the process that runs the guest. The parentheimdall run(no filter, holds the per-boot key) starts itself again as a worker throughheimdall_seccomp::launch::Launchwith theheimdall-wasm-hostprofile of its arch and libc: the same prelude and start the probe andprofiles/tests/enforce.rsuse, a clean environment (PATH,RUST_BACKTRACE=0). The worker loads and runs the guest and prints the unsigned result; the parent re-checks the output files, signs, and statesGenerated { profile_digest }. SIGSYS = signed Refusedheimdall-seccomp-1. Debug builds default to--seccomp none(a debug binary makes calls the release profile does not have, e.g. std'sfcntl(F_GETFD)assertions); the probe corpus setsHEIMDALL_SECCOMP = "none"so it profiles the whole host in one process.
What is not built
- aarch64: the syscall table and the BPF builder support it (tests run the aarch64 filter through the interpreter); no aarch64 box probed a workload yet, so there is no aarch64 profile and no aarch64 kernel test.
- The ptrace fallback for kernels before 5.5.