feat(agent): eBPF load-time BTF preflight, retire manual offset re-verification (ADR-0014 amendment) - #335
Conversation
…rification (ADR-0014 amendment) Adds a load-time BTF preflight to the userspace loader: before attaching any probe, it re-verifies every kernel struct field offset the eBPF crate bakes in (plus the LOADING_MODULE enum value) against the node's live BTF. - Single source of truth: a const (struct, field, expected-offset) table plus the LOADING_MODULE value in agent/common/src/offsets.rs. The eBPF crate's offset_of! guard (vmlinux.rs) now asserts bindings == table at compile time; the loader's preflight asserts table == node-BTF at load time. - A small self-contained BTF binary parser (agent/protector-agent/src/ preflight/btf.rs) — neither aya nor aya-obj's public API exposes struct- member offsets or enum values (both are pub(crate) upstream), so this parses the raw type section directly, recursing into anonymous unions/ structs (inode.i_nlink) with a bounded recursion depth against a malformed blob. Off-fleet testable against hand-built fixture BTF blobs (no live kernel required). - Fail-closed on struct-reading probes (file_open, file_write, mmap_file, fix_setuid, bprm_check), fail-open on struct-free probes (connect, ptrace_access_check, kernel_load_data). Every divergent field is logged expected-vs-actual; the LOADING_MODULE enum mismatch is logged but never gates a probe (not verifier-checked either way). Degrades gracefully, never crash-loops. - Resolves the ON-NODE-PENDING markers in vmlinux.rs (the preflight is now their continuous verification, not a one-time manual task). - Corrects the false "CO-RE-relocated against node BTF at load" claims in agent/Dockerfile, .github/workflows/agent.yml, and docs/ebpf-testing-on-nodes.md — the object bakes offsets and the loader checks them at load; it does not relocate. No PROTECTOR_*_ENABLE toggle: this is a correctness guard, not a feature. Closes JEF-328 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
…e-manual-offset-maintenance-real-co-re
|
Merged. Fast-follow (LOW, not a merge blocker) recorded from the integration security pass:
Deferred to a fast-follow rather than applied at merge time because: (1) it is unreachable today, (2) threading |
Summary
Implements JEF-328: a load-time BTF preflight for the eBPF agent's userspace loader, per the settled design in
docs/ideas/ebpf-offset-self-verification.mdand the ADR-0014 amendment (both landed in #331).The agent bakes hand-verified kernel struct offsets into its eBPF probes (no CO-RE — rustc emits no BTF field relocations). Before this change, the compile-time
offset_of!guard only proved the bindings were internally consistent — it couldn't catch a new kernel silently moving a field, which forbpf_probe_read_kernelchases (unlikebpf_d_path) means a stale offset reads garbage silently rather than failing the verifier.What's new:
(struct, field, expected-offset)table + theLOADING_MODULEenum value inagent/common/src/offsets.rs(sharedno_stdcrate). The eBPF crate'soffset_of!guard (vmlinux.rs) now assertsbindings == tableat compile time (no number lives in two places).agent/protector-agent/src/preflight): before attach, walks the node's live BTF and verifies every table entry's offset + the enum value, recursing into anonymous unions/structs (inode.i_nlinklives in one). Neitheraya::Btfnoraya-obj::Btf's public API exposes struct-member offsets or enum values (both arepub(crate)upstream in aya-obj 0.2.1 — confirmed by reading its source), so this is a small, self-contained direct parse of the raw BTF binary format, as the brief's risk section anticipated as the likely outcome.file_open,file_write,mmap_file,fix_setuid,bprm_check), fail-open on struct-free probes (connect,ptrace_access_check,kernel_load_data) — see theSTRUCT_DEPStable inagent/protector-agent/src/observer/ebpf/preflight_gate.rs. Every divergent field is logged expected-vs-actual (the regeneration data an operator needs); aLOADING_MODULEenum mismatch is logged but never gates a probe (not verifier-checked either way, per the design). Degrades gracefully — a missing/corrupt BTF blob fails closed on every struct-reading probe rather than crashing (ADR-0014).vmlinux.rs— the preflight is now their continuous, per-node verification, not a one-time manualbpftool btf dumptask.agent/Dockerfile,.github/workflows/agent.yml, anddocs/ebpf-testing-on-nodes.md— the object bakes offsets and the loader checks them at load; it does not relocate.No
PROTECTOR_*_ENABLEtoggle — this is a correctness guard, not a feature (repo convention).Testing
preflight/btf_tests.rs— the raw BTF parser against hand-built fixture blobs: plain struct field lookup, unknown struct/field, anonymous-union recursion (mirrorsinode.i_nlink), a typedef wrapping an anonymous member's type, enum variant lookup, big-endian header detection, truncated/bad-magic rejection, and a bounded-recursion regression test against a self-referential type (a defensive fix found during my own security self-review — an unbounded anonymous-member recursion could otherwise stack-overflow on a malformed blob, which would violate ADR-0014's "never crash-loop" invariant even though/sys/kernel/btf/vmlinuxisn't normally attacker-controlled).preflight/tests.rs— end-to-endcheck()/check_bytes()against a fixture that mirrors the fullFIELD_OFFSETStable: correct offsets pass clean, a moved offset (simulating the 6.8→6.11struct filereorg) is flagged with expected-vs-actual, the anon-union recursion is independently verified, an enum-value mismatch is flagged independently of field offsets, a struct missing entirely from BTF flags every field withactual: None, and unparseable BTF fails closed on every table entry.common/src/offsets.rs— the compile-timeoffset_of_tablelookup matches every declared entry.agent/protector-agent-ebpf'soffset_of!guard now consumes the shared table (cargo check --release/cargo clippy --release -- -D warningspass in that crate).Commands run (agent workspace, per its CI job being separate from the engine's):
All green — 72 unit tests pass across
protector-agent+protector-agent-common.Known local-environment limitation (not a regression):
cargo build --features ebpfcannot link on this macOS dev box (bpf-linkerfails to load its LLVM shared lib — reproduces identically on unmodifiedmainviagit stash) or evencargo check(ayaitself uses Linux-onlylibcnetlink APIs — this is the exact constraintdocs/ebpf-testing-on-nodes.mdalready documents: "eBPF can't be compiled or load-tested locally (macOS)"). I confirmed theebpf-feature code path (the newobserver/ebpf/preflight_gate.rssubmodule wiring, module-path resolution,pub(super)visibility) against a minimal isolated reproduction of the exact module-nesting pattern, which compiled and ran correctly..github/workflows/agent.yml'sebpfjob (Linux self-hosted runners with the bpf toolchain) is the real gate for that path.Scope notes
STRUCT_DEPSprobe→struct dependency table (observer/ebpf/preflight_gate.rs) is hand-maintained, cross-referenced against each probe's kernel-side function inprotector-agent-ebpf/src/main.rs— the same acknowledged tradeoff the design brief accepted (no automated way to derive it without parsing the eBPF bytecode itself, well out of scope).observer.rswas at 991 lines after the initial wiring (approaching the repo's 1,000-line file cap); split the preflight-gating table/logic into its own submodule (observer/ebpf/preflight_gate.rs) to bring it back to 904 lines, following the file's existing pattern of nested-module test files.Closes JEF-328
🤖 Generated with Claude Code