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

nordisk/nornir

docs/v2/mimir/binary-graph-plan.md

The binary graph: plan

Mímir reads Rust programs from their MIR (code_mir, code_instances, exec-graph.md). This plan adds the layer that reads the COMPILED program: the machine code of an ELF file and of the shared libraries it loads. Programs written in C (iperf3, busybox, nginx, the C library itself) then get what Rust programs have: a call graph, per-function control flow, the system-call boundary, and the executor's order graph and possible set.

What it is for: defensive tooling for Heimdall's sandbox filters. Static binary analysis derives and checks seccomp profiles, as in sysfilter (DeMarinis et al., RAID 2020), Confine (Ghavamnia et al., RAID 2020) and B-Side (Thévenon et al., Middleware 2024). The graph says which system calls a program CAN make; Heimdall's probe says which ones it DID make; the cross-check (dwarves-tune-graph) names the difference. The graph never widens a filter on its own.

Words: a module is one ELF file (the program, libc.so.6, ld.so). A machine function is one function of a module's code. A block is a basic block of machine instructions. A site is one instruction that matters to the graph: a call, a jump out of the function, a syscall.

0. Decisions

D1. ONE graph. The binary layer EXTENDS the Rust graph. It is not a second graph format, a second store or a second executor.

D2. The curated C-library map stays, as the fallback and as the oracle. When the machine body of a C function is loaded, the executor walks it. When it is not (no library given), libc_map answers as today. A test compares the two on glibc and musl: every map entry against the machine graph of that function. A difference is a finding in one of them.

D3. Pure Rust, stable. No C disassembler (no capstone, no Binary Ninja code), no rayon, no petgraph. The producer is a library; the CLI is a thin front end.

D4. Per-module rows, keyed by build id. A shared library is analysed ONCE per build id (like the toolchain dump --std) and its rows are reused by every program that loads it. Linking (which import goes to which library) is a per-program step of the executor's load, not stored per module.

D5. Schema additions go through the exec-graph lane (it owns mimir-schema and mimir-exec): proposed and agreed in /work/exec-graph/out/FOR-BINARY.md before they merge.

1. What exists in Rust, and what we use

Surveyed 2026-11-07 (crates.io, docs.rs, the papers).

crate / tool what it gives licence maturity use? why
iced-x86 x86/x86-64 decoder, instruction info (registers read and written, flow control kind, branch targets, memory operands) MIT mature (1.21; used by dwarves-tune-graph already) yes: the x86-64 decoder
yaxpeax-x86 x86 decoder 0BSD maintained, smaller API for flow info no: iced-x86 gives the flow-control and register-use info we need
yaxpeax-arm AArch64 (and ARMv7) decoder, pure Rust 0BSD maintained, used in yaxpeax tools yes, for aarch64 (step 8), unless disarm64 proves better on the fixtures
disarm64 AArch64 decoder generated from ARM's machine-readable spec Unlicense OR MIT young (0.1.x), used by smda candidate for aarch64; decided on the aarch64 fixtures
bad64 AArch64 decoder: bindings to Binary Ninja's C disassembler Apache-2.0 mature no: C code built by cc (D3)
capstone-rs bindings to capstone MIT/BSD mature no: C library (D3)
object ELF/PE/Mach-O reader: sections, symbols, dynamic table, relocations MIT OR Apache-2.0 mature (mimir-binary uses 0.40) yes: the ELF reader
goblin ELF/PE/Mach-O reader MIT mature no: object already in the tree does the same
gimli DWARF reader, .eh_frame / CFI MIT OR Apache-2.0 mature (mimir-binary uses 0.34) yes: function bounds from .eh_frame FDEs (stripped binaries), DWARF lines
addr2line address → file:line over gimli MIT OR Apache-2.0 mature yes: the file, line of a site when the module has DWARF
rustc-demangle v0 and legacy Rust demangling MIT OR Apache-2.0 mature yes (already used): the join with code_instances by demangled symbol
smda (Rust port) recursive disassembly: functions, blocks, call edges; ELF via goblin, x86 via iced MIT early (0.6.x, May 2026); x86 jump tables partial, no syscall analysis, no value analysis no as a dependency: its function/block model is not keyed to our ids and it lacks the value pass; read for its function-discovery heuristics
xref-code x86-64 ELF static analysis CLI: xrefs, backward slicing, relative jump tables (lea + movsxd) MIT OR Apache-2.0 very young (0.4.0, Nov 2026), a CLI, uses rayon no: a CLI with rayon (D3); its jump-table pattern is the same one we implement
sagitta (GitHub) pure-Rust ELF/PE CFG recovery, jump tables unclear unverified no: maturity and licence unclear
cargo-call-stack stack usage + call graph of embedded Rust from LLVM IR + ELF MIT OR Apache-2.0 mature for its niche no: needs LLVM IR of the build; no C libraries, no syscalls
dwarves-tune-graph the cross-check of a possible set with a probe; its own ELF walk is a stub (reserved) MIT OR Apache-2.0 in tree yes, as the consumer (step 6): reads our mimir.possible-syscalls/1
nornir-introspect::size ELF symbol walk in tree in tree already copied into mimir-binary::elf; we extend that module
mimir-binary linked symbols joined onto node ids, v0 decode, FFI border in tree in tree yes: the producer lives here (it is the binary layer)
sysfilter binary call graph + syscall numbers (egalito, C++) GPL-3.0 research method only (address-taken widening)
Confine container syscall filters from binaries (Python, angr) research research method only (per-library summaries, libc wrappers)
B-Side syscall identification with symbolic execution and wrapper detection (Python, angr) no code published research method only (wrapper = number from a parameter; per-library interfaces)

What is missing in Rust, so we build it: a function and block model keyed to Mímir ids, PLT/GOT/IFUNC import resolution across modules, a small value analysis for system-call numbers and function-pointer arguments (wrappers in glibc and musl), jump-table recovery for GCC and Clang code, and the rows plus their derived view.

2. Dataflow

 mimir-binary --asm <elf> [--root <image root>]          once per module (build id); a library once per toolchain/image
   object: sections, symtab+dynsym, .rela.plt/.rela.dyn, DT_NEEDED, .init_array, e_entry
   gimli:  .eh_frame FDEs (function bounds), .debug_line (file:line)
   iced-x86: recursive descent per function ─► blocks, sites
   value pass (per function, §5) ─► syscall numbers, wrapper params, fn-pointer args
   ─────────────────────────────────────────► <module>.asm.arrow        code_asm       (1 row / machine function)
                                              <module>.asmmod.arrow     code_asm_mods  (1 row / module)
 program load (mimir_store::program)                                   per program
   DT_NEEDED breadth first, the loader's search order ─► the module set
   imports → the first module that exports the symbol (version-aware) ─► link table
   code_asm.inst = a code_instances key (Rust fn, by demangled symbol) ─► ONE instance, two bodies
 mimir-exec                                                            the same executor
   reach → values → order → entries over MIR bodies AND machine bodies
   exec.nodes  exec.edges  exec.marks   <bin>.possible.json  (mimir.possible-syscalls/1)
 derived view (code_asm::legacy)                                       on demand, for the room and the lenses
   fn / ffi nodes, CALLS, SYSCALLS → @syscall@<arch>.r1, CFG_SUCC

3. The tables

crates/mimir-schema/src/code_asm.rs, the same style as code_mir: one row holds a whole function as parallel arrays (Arrow List columns), a per-row string pool strs, content = a hash of the other columns (the lake's delta key).

code_asm, identity fn (the machine function's key, §3.1):

column type content
fn utf8 the key (§3.1)
module utf8 the module key (§3.1)
addr, size u64 the function's start (link-time address) and length
symbol utf8 the ELF symbol (mangled), empty for a function without one
inst utf8 the code_instances key of the Rust instance this is the machine code of (§6), empty = none
bound u8 where the bounds came from: symtab, dynsym, eh_frame FDE, call target, entry
blk_addr, blk_len list u64, list u32 per block: start, length in bytes
blk_term list u8 per block: fall-through, jump, branch, return, call, tail call, indirect jump, indirect call, syscall, halt, noreturn call
succ_end, succ list u32, list u32 per block the successor blocks (CSR: succ[succ_end[b-1]..succ_end[b]])
st_blk, st_kind, st_off list u32, list u8, list u32 per site: its block, its kind (direct call, import call, indirect call, tail call, indirect jump, syscall), its offset from addr
st_to list u32 a direct call: index into strs of the callee's key; an import: the import name (open64@GLIBC_2.2.5); NONE otherwise
st_val_end, st_val list u32, list u64 per site the value set the value pass gives: a syscall's numbers, an indirect call's target addresses (empty + st_flags top bit = any value)
st_param list u8 the value is a PARAMETER of the function (§5): 0 = no, 1..6 = rdi rsi rdx rcx r8 r9 (x0..x7 on aarch64), 16+k = stack slot k
st_line list u32 the DWARF line of the site (0 = none); files + st_file as in code_mir
jt_from / jt_n list u32 jump tables recovered per indirect jump (count of targets), for the report
content u64 hash of the other columns

code_asm_mods, identity module: path (as given), soname, build_id, arch, kind (exec, pie, shared, static, static-pie), interp, needed (list), runpath (list), entry, init / fini (lists of function keys from .init_array / .fini_array), exports (function keys, with symbol versions), imports (name, version, PLT or GOT slot), ifunc (symbol → resolver key), address_taken (function keys, §4.4), libc (glibc + version, musl + version, none: read from the module's own strings and sonames), stats (JSON: counts of the ledger, §9).

3.1 Keys

module key    <file name or soname>[elf]@<build id, 16 hex>       libc.so.6[elf]@3a1f0c9e2b7d4410
fn key        <module key>::<symbol, demangled-free>              libc.so.6[elf]@3a1f0c9e2b7d4410::__libc_open64
              <module key>::fn@<hex address>                      a function without a symbol (stripped busybox)
node id       <fn key or instance key>#<block>                    the same form as exec.nodes' node ids

The module key has the def key's shape (<package>[<target>]@<version>), so every reader that splits a def key splits it. A module without a build id uses the SHA-256 of its bytes, cut to 16 hex digits.

4. Calls

4.1 Functions and blocks

Function starts: STT_FUNC symbols of .symtab and .dynsym, the .eh_frame FDE starts, e_entry, the .init_array / .fini_array entries, and every direct call target found (recursive descent adds them). Bounds: the symbol size, else the FDE length, else the next start.

Blocks by recursive descent inside the bounds: a leader is the start, every branch target and every instruction after a branch, return, call of a noreturn function, hlt or ud2. Noreturn: a known list (exit, _exit, _Exit, abort, __stack_chk_fail, __assert_fail, __libc_fatal, __fortify_fail, pthread_exit, longjmp, siglongjmp, _Unwind_Resume) plus every function from which no ret is reachable (a fixed point).

4.2 Direct calls, tail calls

call rel32 → the function at the target. jmp whose target is outside the function's bounds and at a function start = a tail call (a call edge and a return).

4.3 Imports: PLT, GOT, IFUNC

call plt_stub(k)      .plt/.plt.sec slot k ── R_X86_64_JUMP_SLOT ──► import (name, version)
call [rip + got]      (-fno-plt)          ── R_X86_64_GLOB_DAT  ──► import
import (n, v)         resolved per program: the FIRST module in load order that exports n
                      (version v when the import names one, the default version otherwise)
IFUNC symbol n        STT_GNU_IFUNC: the call goes to what the resolver returns:
                      targets(n) = the function addresses the resolver's value pass gives in rax
                      (memcpy, strlen …: every implementation it can pick)

Load order is the dynamic loader's: the program, then DT_NEEDED breadth first, each searched in DT_RPATH (no DT_RUNPATH), LD_LIBRARY_PATH (not used: a filter is made for a fixed environment), DT_RUNPATH, /etc/ld.so.conf and its includes under the image root, then /lib, /usr/lib and the multiarch dirs (glibc), or musl's /etc/ld-musl-<arch>.path and its defaults. $ORIGIN is the module's directory. A library not found is in missing_libs; an import no module exports is in unresolved_imports.

4.4 Indirect calls and jumps

target(s)  = V(s) when the value pass gives a constant set of function addresses      (exact)
           = targets at the callers when V(s) = Param(p)                              (§5.3)
           = AT                                                                       (widened)
AT         = ⋃ over loaded modules of the functions whose address is taken:
               a lea/mov of a function start in REACHED code,
               a pointer to a function start in data (R_X86_64_RELATIVE, R_X86_64_64, absolute words
               in .data/.data.rel.ro of a non-PIE), a GLOB_DAT of a function symbol

A widened call is marked: its reach is direct = false in the possible set (the shape dwarves-tune-graph already reads). This is sysfilter's address-taken rule, cut to what reached code and the data can name, the same rule the Rust graph applies to fn pointers (§5.1 of exec-graph.md).

Jump tables (an indirect jmp inside a function). The GCC/Clang forms:

PIC:     lea rT,[rip+table]; movsxd rX,[rT+rI*4]; add rX,rT; jmp rX        entries: table + i32
non-PIC: jmp [table + rI*8]                                                entries: absolute u64
bound:   cmp rI, N-1; ja default   (the dominating compare on the index)   N entries

Entries are read while they point inside the function. A jump the patterns do not match goes to every block of its function that no other edge reaches and that starts after it (widened, counted in the ledger). This only changes the ORDER inside a function; the possible set is a function-level reach and does not depend on it.

5. The system-call boundary

5.1 Sites

x86-64: syscall (0f 05), number in rax. int 0x80 (the 32-bit ABI, other numbers) and sysenter are sites with the note 32-bit system call ABI and the <indirect> call. aarch64: svc #0, number in x8.

5.2 The value pass

Per function, a forward dataflow over its blocks, to the fixed point:

state      σ : Reg ∪ Slot → V            Reg = the 16 GPRs (31 X regs on aarch64), Slot = [entry rsp + 8k]
V          ⊥ | {c1..cn} (n ≤ 8) | Param(p) | ⊤        join: sets ∪ (a 9th value → ⊤), Param(p) ⊔ Param(q) = ⊤ (p ≠ q),
                                                      Param ⊔ set = ⊤
entry      σ(rdi..r9) = Param(1..6), σ([rsp+8k]) = Param(16+k) (k ≥ 1), σ(rsp) = Sp(0), rest ⊤
mov r, imm             σ(r) = {imm}            (32-bit forms zero-extend: mov eax, 39 → {39})
xor r, r / sub r, r    σ(r) = {0}
mov r, r'              σ(r) = σ(r')
lea r, [rip + d]       σ(r) = {address}
add/sub/and/or/shl r, imm   σ(r) = {op(c, imm)} per c; Param → ⊤
movzx / movsxd / cdqe  as a copy, cut to the width
push x / pop r         Sp(δ) moves by 8; the slot gets / gives σ(x)
sub rsp, n / add rsp, n     Sp(δ) moves by n
mov [rsp + k], x       σ(slot at δ + k) = σ(x);   mov r, [rsp + k]: σ(r) = σ(slot)
call f                 the caller-saved registers (rax rcx rdx rsi rdi r8–r11) = ⊤ after it; callee-saved kept
any other write to r   σ(r) = ⊤;   a write through any other pointer: slots unchanged (the stack is not aliased:
                       stated limit)

At a syscall site s: nr(s) = σ(rax). At an indirect call or jump: V(s) = σ(target operand). At a call of a function that is a wrapper (§5.3) on parameter p: the site's value is σ(p) at the call.

5.3 Wrappers: the number comes from a parameter

W(f) = { p | some site of f (a syscall, or a call of a wrapper g on q with σ(q) = Param(p)) has value Param(p) }

A function with W(f) ≠ ∅ is a wrapper. Its own syscall instruction is not a boundary node; every CALL of it is, with the numbers the caller passes. Wrappers of wrappers are handled by the fixed point over the summaries (a caller whose argument is itself Param(q) is a wrapper on q). The same rule resolves a FUNCTION POINTER handed down as a parameter (an indirect call through Param(p)): __libc_start_main(main, …), pthread_create(t, a, start, arg), qsort(…, cmp).

The wrappers this must find (read from the sources; the fixtures check them):

libc wrapper number in
glibc ≥ 2.41 __syscall_cancel(a1..a6, nr) → __internal_syscall_cancel(a1..a6, nr) → __syscall_cancel_arch(ch, nr, a1..a6) (assembly) stack slot 1, stack slot 1, rsi
glibc syscall(nr, …) (assembly: mov rax, rdi) rdi
glibc most wrappers: INTERNAL_SYSCALL inlined, mov eax, NR; syscall a constant
musl 1.2 __syscall_cp(nr, …) → __syscall_cp_c → __syscall_cp_asm(&cancel, nr, …) rdi, rdi, rsi
musl 1.2 __syscallN(n, …) (static inline): a constant at the site a constant
musl 1.2 syscall(n, …) → __syscall_ret(__syscall(n, …)) rdi

A number that stays ⊤ (a value from memory, a table, a computation) is a raw door note and the <indirect> call, exactly as in the Rust graph.

5.4 Worked example

tgkill in glibc 2.41, the call the bench lane's across-node iperf3 client died on (pthread_cancel of a stream thread blocked in send):

iperf_client_end            (libiperf.so.0)  call pthread_cancel@plt
  ─JUMP_SLOT─► pthread_cancel@@GLIBC_2.34      (libc.so.6)
    … call __pthread_kill_internal
       __pthread_kill_implementation:  mov edi, pid; mov esi, tid; mov edx, sig; mov eax, 234; syscall
                                       σ(rax) = {234} = tgkill

The site is a node libc.so.6[elf]@…::__pthread_kill_implementation#<b> with syscalls = [tgkill], the path _start → __libc_start_main → main → … → iperf_client_end → pthread_cancel → …, and the acceptance test (step 5) checks that tgkill is in iperf3's possible set.

A wrapper: musl open("f", O_RDONLY) in a static binary:

open:           mov rsi, rdi … mov edi, 2 (SYS_open); call __syscall_cp      σ(rdi) at the call = {2}
__syscall_cp:   jmp __syscall_cp_c                                           W = {rdi}   (tail call, rdi kept)
__syscall_cp_c: … mov rsi, rdi; lea rdi, [cancel]; call __syscall_cp_asm     σ(rsi) = Param(rdi): W = {rdi}
__syscall_cp_asm: mov rax, rsi; … syscall                                    σ(rax) = Param(rsi): W = {rsi}
⇒ the call site in `open` is the node, syscalls = {2 = open}

6. The join with the Rust graph

For a Rust program built with v0 symbols: a machine function whose symbol, demangled with rustc_demangle's {:#} form (hashes cut), equals the demangled symbol of a code_instances row gets that row's key in inst. That instance then has two bodies: the executor runs its MIR (the richer one) and keeps the machine body for what MIR cannot see:

Reported per program: machine functions, those joined 1:1, Rust instances without a machine function (inlined, or not linked), machine functions of Rust crates without an instance (the instance pass did not reach them: a finding).

7. The executor

mimir_store::program::Program::load reads *.asm.arrow and *.asmmod.arrow next to the dump's files. Each machine function that is not joined to a Rust instance becomes an instance of its own (key = its fn key, symbol = its ELF symbol, body = a machine body). The executor's build(i) turns a machine body into the same Blk values it builds from MIR: successors from succ, an effect per site (a call of an instance, a boundary site from a syscall instruction or a wrapper call, a widened indirect call, a noreturn). Reach, values (the machine sites carry their own sets from §5), the order relation (§5.3 of exec-graph.md) and the entries run unchanged. New entries: the program's e_entry (process), the loader's e_entry (kind loader: what ld.so does before the program), .init_array (kind init), .fini_array and atexit targets (kind exit), pthread_create's start argument (kind thread).

exec.nodes for a machine site: instance = the fn key (or the joined instance key), block = the machine block, file/line from DWARF (else the module and the offset in file, line 0), callee = syscall or the wrapper's name, symbol = the ELF symbol. No column is added; the schema of exec.nodes / exec.edges / exec.marks is unchanged. The possible set fills modules (one per loaded module with its reached functions), unnamed (constant numbers the table does not name), missing_libs, unresolved_imports, dlopen, indirect_sites.

8. Static and dynamic

static (musl -static, static-pie) dynamic (glibc, musl ld-musl)
modules the program the program, the loader, every DT_NEEDED library, transitively
imports none PLT / GOT / IFUNC, resolved per program (§4.3)
start-up _start → __libc_start_main in the program ld.so's entry (kind loader), then the program's _start
libc wrappers in the program's own code in libc.so.6 / ld-musl, analysed once per build id

9. Limits, said plainly

10. Build order (each step RED before GREEN)

  1. x86-64, static musl: functions, blocks, direct calls, syscall sites with constant numbers, musl's wrappers. Fixtures: a C program and a Rust program built -static against musl with a known set of system calls; the test records a real run with ptrace (the recorder of mimir-exec/tests/common) and checks: every call of the run is in the possible set, and the set holds no call outside the program's code.
  2. dynamic glibc: PLT/GOT/IFUNC, the module set, libc.so.6 and its wrappers (__syscall_cancel). Fixture: the same C program, dynamic.
  3. join: a Rust fixture with v0 symbols; count the 1:1 matches.
  4. the executor: machine bodies in Program and build; the fixtures' order graphs against the recorded runs (every neighbour pair allowed).
  5. real programs: iperf3, busybox, nginx from the bench's pinned images (tunnr scripts/ovn-bench/pods.toml: iperf3 sha256:d8767f28…, busybox 1.37@sha256:66a6306d…, nginx 1.27.4-alpine@sha256:4ff102c5…), and Heimdall's heimdall. Acceptance: tgkill is in iperf3's possible set; every call of the bench's probe evidence (pod-iperf3.json) is in it.
  6. the cross-check: dwarves-tune-graph --graph <possible.json> --profile <probe.json> on iperf3: a test in dwarves on the committed output.
  7. docs: binary-graph.md as built, linked from the Mímir README.
  8. aarch64: svc #0 / x8, adrp+ldr+br x17 PLT stubs, the aarch64 jump-table forms; the same fixtures cross-compiled.

11. Measured, to be reported

Per module: functions (by bound source), blocks, sites by kind, jump tables recovered and widened, indirect calls exact / via parameter / widened, syscall sites with constant / parameter / unknown numbers; time and peak RSS. Per program: modules, reached functions, possible calls (direct / widened), the probe's calls not in the set (must be 0), the set's calls the probe never saw.