Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

docs/v2/graph-filter-parts/part-2.md

Part 2 of 5 of docs/v2/graph-filter.md (generated; edit the source)

3. Graph mode (new)

Step 2. The static analysis gives the graph

The graph is Mímir's lossless graph, and this design assumes it exists. It is defined in the graph plan (nornir docs/v2/mimir/exec-graph-plan.md at 03662cb4). This design uses:

"1:1 with the code" means the Rust code. A C function is one node whose λ is a set (plan D4); section 10 says what follows from that.

Step 1. The profiler checks the graph

The profiler runs the program as today and also records, per thread, which call follows which (section 7). A pair (a, b) means: b is the next call after a in the same thread. Base-set and vDSO calls are not seen and are in no pair. Two numbers, on nodes and on pairs:

Obs  = classes observed          Static  = ⋃ λ(s) over reached sites
Obs₂ = pairs observed            Static₂ = { (a, b) : ∃ (s, s') ∈ Next, a ∈ λ(s), b ∈ λ(s') }
                                           ∪ { (a, b) : a, b ∈ λ(s), s can make several calls }

soundness   S  = |Obs  ∩ Static|  / |Obs|       S₂ = |Obs₂ ∩ Static₂| / |Obs₂|     must be 1.0
precision   P  = |Obs  ∩ Static|  / |Static|    P₂ = |Obs₂ ∩ Static₂| / |Static₂|  rises with coverage

An empty denominator is "no data", never 1. "Before anywhere in the run" would not work as the definition of a pair: in a loop, every call comes before every other after one round.

Every difference is a finding:

finding meaning resolution
observed, not in the graph (S < 1 or S₂ < 1) the graph is wrong an analyzer fix, with the observed run as its test. The graph is never edited by hand.
in the graph, never observed (P < 1 or P₂ < 1) a path no run drove, or analyzer imprecision add a run; if no run can make it, it stays listed as imprecision
an order differs one of the two above if the order is really free (async), the fixed graph says so itself, because the executor's code is in the graph

The goal: the static analysis gets so good that profiling only confirms it, 100 %. S = S₂ = 1.0 is required before a graph is enforced. P and P₂ reach 1.0 only for code the runs fully cover.

Step 3. The graph filter

The three parts.

part where what it does with a call
seccomp floor classic BPF, first at the entry never → kill. always → ALLOW. brokered → USER_NOTIF to the supervisor.
graph follower eBPF, at the entry right after seccomp runs decide on the thread's stack: a valid step advances the position, anything else is an invalid step
supervisor user space, outside the job brokers the brokered set; signs the record

The floor is the filter of section 2: always = I ∪ F, never = everything else. The brokered set is what the broker serves today: the open calls (openat, openat2, and on x86_64 open and creat), socket, connect, bind, listen, accept, accept4, io_uring_setup, sends with MSG_FASTOPEN, and exec (crates/heimdall-broker/src/filter.rs:27-44, 64-96). Today the broker opens files and sockets itself, lets exec continue, and refuses the others (broker.rs:409-427).

The decision. A position is the set of sites the thread's last call can have come from. It is a set because the kernel side sees a call's class and not its site, and two sites can make the same call. A thread's state is a stack of positions: its own entry at the bottom, and one frame on top for each signal handler that is running on it.

frame f = (e, P, last)     an entry e, the position P on it, the class of the frame's last call
state of thread t          ⟨f₀ … f_k⟩, at most 8 frames;  f₀ = (the thread's entry, {ε}, none)
H                          the signal-handler entries of the graph

step_e(P, c) = { s' : c ∈ λ(s') ∧ ( (ε ∈ P ∧ s' ∈ First(e)) ∨ ∃ s ∈ P : (s, s') ∈ Next
                                    ∨ (s' ∈ P ∧ s' can make several calls) ) }

decide(⟨f₀ … f_k⟩, event), on the top frame f_k:
  call c ∈ I                     valid; no position moves; rt_sigreturn pops f_k when k > 0
  call c, step(P_k, c) ≠ ∅       valid; P_k := step(P_k, c), last_k := c             (next)
  call c = last_k                valid; nothing moves             (again: the self-edge s → s)
  signal delivered, handler h    h ∈ H and k < 7: push (h, {ε}, none);  otherwise invalid
  thread created                 the parent's clone is a next step to a spawn site of the graph;
                                 the child starts as ⟨(the entry that site spawns, {ε}, none)⟩
  anything else                  an INVALID STEP

allowed(stack) = I ∪ { c : decide(stack, call c) is valid }

Enforce and Audit are a field of the policy.

The kill is late by at most one call. At the entry tracepoint the follower cannot stop the call. The invalid call itself runs, and the thread never returns to user space. The floor guarantees that this one call is a call the static filter allows anyway. Where an LSM hook exists for the operation (open, connect, bind, exec, mmap), the refusal comes before the call: the follower marks the thread at the entry, and the hook refuses while the mark is set.

Four properties, each a property test:

(G1) every run of the code is valid: each frame's calls follow its entry's graph (pairs ⊆ Static₂),
     while handlers start between any two calls, nest and return, and an interrupted call comes again
(G2) graph mode is never wider than static mode: the floor answers first, and allowed(stack) ⊆ I ∪ Static
(G3) the base set is never invalid, at any stack, and it moves no position (rt_sigreturn only pops)
(G4) Audit changes nothing in the program: no call fails, and no call waits for user space

What falls out of the graph.

This replaces the earlier design of the two deleted plans: named phases, a stacked filter per mark, and counted "pledge budgets", with no order inside a phase. That design was a workaround for a lossy graph that had no order. With the 1:1 graph, phases and budgets are not separate mechanisms. They fall out of following the graph.

Dataflow, graph mode

 code ─► Mímir (lossless MIR, instances) ─► mimir-exec ─► (2) THE GRAPH  exec.nodes, exec.edges
                                                               │   B, λ, First, Next; arg facts
                    ┌──────────────────────────────────────────┤
                    ▼                                          ▼
 runs ─► (1) PROFILER  per thread: pairs (a, b)        import + validate, digest
              │  Obs, Obs₂                                     │
              ▼                                                ▼
         graph check: S, P, S₂, P₂, findings ─────►  accepted only with S = S₂ = 1.0
              │ findings                                       │ loader: states, maps
              ▼                                                ▼
   analyzer fix + test  /  add a run        (3) AT THE SYSTEM-CALL ENTRY, for every call
   (back to Mímir or to the runs)
                                             seccomp floor (static mode's filter)
                                                never    → KILL
                                                brokered → USER_NOTIF ─► supervisor: open, inject fd
                                                always   → ALLOW
                                                    │ every call the floor let through
                                                    ▼
                                             graph follower (eBPF): stack(t), decide
                                                valid   → the position advances
                                                invalid → Enforce: kill    Audit: log, count, run
                                                    │
                                                    ▼
                    signed record: mode, Enforce or Audit, floor digest, graph digest, violations

Next: part 3 of 5, raw: https://www.gunnar.rs/nordisk/heimdall/raw/docs/v2/graph-filter-parts/part-3.md