Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/agent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ jobs:
# Compile the BPF programs to bytecode on the self-hosted runners — their image
# (../runner) bakes in nightly + rust-src + a prebuilt bpf-linker (ADR-0014), so the
# job is just the compile (the tiny eBPF crate; the LLVM-heavy bpf-linker build was
# done once at image-build time). BPF is architecture-neutral bytecode, CO-RE-
# relocated against the node's BTF at load.
# done once at image-build time). BPF is architecture-neutral bytecode; its kernel
# struct offsets are hand-verified and baked in at compile time (no CO-RE field
# relocation — rustc emits none), then re-checked against each node's live BTF at
# load by the userspace loader's preflight (ADR-0014's amendment).
ebpf:
runs-on: protector-runners
# Self-hosted + persistent: never execute forked-PR code (mirrors the docker job).
Expand Down Expand Up @@ -98,8 +100,10 @@ jobs:
# The image is built WITH the eBPF probe: agent/Dockerfile runs
# `cargo build --release -p protector-agent --features ebpf`, and its build.rs compiles
# the sibling protector-agent-ebpf crate to a BPF object (bpf-linker, nightly) embedded in
# the binary and CO-RE-relocated against the node's BTF at load (ADR-0014). The default
# (no-feature) build — a no-op observer — exists only for toolchain-free local dev/test.
# the binary, its kernel struct offsets baked in at compile time and re-verified against
# each node's live BTF at load by the userspace loader's preflight — NOT CO-RE-relocated
# (ADR-0014's amendment: rustc emits no BTF field relocations). The default (no-feature)
# build — a no-op observer — exists only for toolchain-free local dev/test.
- name: Build and push agent image
id: build-and-push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
Expand Down
10 changes: 7 additions & 3 deletions agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
# Builds the agent WITH the eBPF probe (`--features ebpf`): the builder stage carries
# the bpf toolchain (prebuilt bpf-linker + nightly + rust-src), and the userspace
# build.rs compiles the sibling protector-agent-ebpf crate to a BPF object and embeds
# it in the loader. The BPF object is architecture-neutral (CO-RE-relocated against the
# node's BTF at load); the elevated caps it needs are granted at RUNTIME by the
# DaemonSet's securityContext, never baked into the image.
# it in the loader. The BPF object bakes hand-verified kernel struct offsets rather than
# CO-RE-relocating them — rustc emits no BTF field relocations (ADR-0014's amendment on
# the load-time BTF preflight) — so it is the SAME object on every node/arch; the
# userspace loader re-verifies those baked offsets against each node's live BTF before
# attach (a check, not a relocation) and degrades gracefully on a mismatch. The elevated
# caps it needs are granted at RUNTIME by the DaemonSet's securityContext, never baked
# into the image.

# bookworm-based to match the bookworm-slim runtime's glibc 2.36.
# Via mirror.gcr.io (Docker Hub pull-through) to dodge the anonymous 429.
Expand Down
8 changes: 8 additions & 0 deletions agent/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@

#![no_std]

/// The single source of truth for every kernel struct field offset and BTF-visible enum
/// value the eBPF probes bake in (ADR-0014 amendment, load-time BTF preflight). The eBPF
/// crate's `offset_of!` guard (`vmlinux.rs`) asserts `bindings == table` at compile time;
/// the userspace loader's preflight (`agent/protector-agent/src/preflight`) asserts
/// `table == node-BTF` at load time — transitively `bindings == kernel`, with the number
/// living in exactly one place.
pub mod offsets;

/// Event-kind discriminators. Stable wire values; never renumber an existing one.
pub const KIND_CONNECT: u32 = 1;
/// A tmpfs file was opened (fentry on `security_file_open`). Carries the container path;
Expand Down
126 changes: 126 additions & 0 deletions agent/common/src/offsets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! The offset/enum table both the eBPF crate's compile-time guard and the userspace
//! loader's load-time BTF preflight read (ADR-0014 amendment). See the module doc in
//! `lib.rs` for why this exists: one number, checked twice (compile time against the
//! hand-laid bindings, load time against the running kernel's live BTF), never hand-kept
//! in sync between the two.

/// One `(struct, field, expected byte offset)` entry — a field the eBPF probes read via a
/// baked offset (`agent/protector-agent-ebpf/src/vmlinux.rs`). `kernel_struct` is the
/// struct's name as it appears in kernel BTF (e.g. `"file"`, not a Rust type path).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FieldOffset {
pub kernel_struct: &'static str,
pub field: &'static str,
/// Verified byte offset on the fleet's kernel (7.0.0 — see `vmlinux.rs`'s module doc
/// for the derivation of each field below).
pub offset: u32,
}

impl FieldOffset {
const fn new(kernel_struct: &'static str, field: &'static str, offset: u32) -> Self {
Self {
kernel_struct,
field,
offset,
}
}
}

/// Every field offset a probe bakes in. Order mirrors `vmlinux.rs`'s struct declarations
/// (`file` → `path` → `dentry` → `qstr` → `inode` → `super_block` → `cred`/`kuid_t` →
/// `linux_binprm`), so a diff against that file's `offset_of!` block reads in the same
/// order.
pub const FIELD_OFFSETS: &[FieldOffset] = &[
FieldOffset::new("file", "f_inode", 32),
FieldOffset::new("file", "f_flags", 40),
FieldOffset::new("file", "f_path", 64),
FieldOffset::new("path", "dentry", 8),
FieldOffset::new("dentry", "d_name", 32),
FieldOffset::new("qstr", "name", 8),
FieldOffset::new("inode", "i_sb", 40),
FieldOffset::new("inode", "i_ino", 64),
// Lives in an anonymous union immediately after i_ino (`union { const unsigned int
// i_nlink; unsigned int __i_nlink; }`) — both the compile-time `offset_of!` (a plain
// Rust field access through the flattened binding) and the load-time BTF walk (which
// must recurse into the anonymous union to find it) land on the SAME byte offset, +72.
FieldOffset::new("inode", "i_nlink", 72),
FieldOffset::new("super_block", "s_magic", 96),
FieldOffset::new("cred", "uid", 8),
FieldOffset::new("kuid_t", "val", 0),
FieldOffset::new("linux_binprm", "file", 64),
FieldOffset::new("linux_binprm", "filename", 96),
];

/// The BTF enum the module-load probe's `LOADING_MODULE` constant must match
/// (`agent/protector-agent-ebpf/src/main.rs`; `include/linux/kernel_read_file.h`'s `enum
/// kernel_load_data_id`). Unlike a struct offset this is never verifier-checked — a wrong
/// value is a plain integer compare that misclassifies silently rather than failing loud
/// — which is why the preflight checks it explicitly (ADR-0014 amendment).
pub const LOADING_MODULE_ENUM: &str = "kernel_load_data_id";
pub const LOADING_MODULE_VARIANT: &str = "LOADING_MODULE";
pub const LOADING_MODULE_VALUE: u32 = 2;

/// Look up `kernel_struct.field`'s expected byte offset in [`FIELD_OFFSETS`]. `const fn`
/// so the eBPF crate's `offset_of!` guard can assert `bindings == table` at compile time —
/// the identical lookup the userspace preflight performs against live BTF at load time.
/// Panics (a compile error in the `const` context it's used from) if the pair isn't in the
/// table — a coding mistake to fix by adding the entry, not a runtime condition.
pub const fn offset_of_table(kernel_struct: &str, field: &str) -> u32 {
let mut i = 0;
while i < FIELD_OFFSETS.len() {
let entry = FIELD_OFFSETS[i];
if str_eq(entry.kernel_struct, kernel_struct) && str_eq(entry.field, field) {
return entry.offset;
}
i += 1;
}
panic!("offset_of_table: no FIELD_OFFSETS entry for this (struct, field) — add it there first")
}

/// `const fn` byte-wise string equality — `&str`'s `PartialEq` isn't `const`, so
/// [`offset_of_table`] (evaluated at compile time by the eBPF crate's `offset_of!` guard)
/// needs its own.
const fn str_eq(a: &str, b: &str) -> bool {
let a = a.as_bytes();
let b = b.as_bytes();
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
if a[i] != b[i] {
return false;
}
i += 1;
}
true
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn offset_of_table_matches_the_declared_entries() {
for entry in FIELD_OFFSETS {
assert_eq!(
offset_of_table(entry.kernel_struct, entry.field),
entry.offset
);
}
}

#[test]
#[should_panic(expected = "no FIELD_OFFSETS entry")]
fn offset_of_table_panics_on_an_unknown_pair() {
offset_of_table("file", "not_a_real_field");
}

#[test]
fn str_eq_distinguishes_length_and_content() {
assert!(str_eq("file", "file"));
assert!(!str_eq("file", "files"));
assert!(!str_eq("file", "path"));
assert!(str_eq("", ""));
}
}
16 changes: 9 additions & 7 deletions agent/protector-agent-ebpf/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -717,13 +717,15 @@ fn try_ptrace_access_check(ctx: &FEntryContext) -> Result<(), i64> {
/// the enum is a stable, list-ordered generator macro — `LOADING_UNKNOWN`(0),
/// `LOADING_FIRMWARE`(1), `LOADING_MODULE`(2), `LOADING_KEXEC_IMAGE`(3),
/// `LOADING_KEXEC_INITRAMFS`(4), `LOADING_POLICY`(5), `LOADING_X509_CERTIFICATE`(6),
/// `LOADING_MAX_ID`(7). **ON-NODE BTF VERIFICATION PENDING:** confirm against
/// `bpftool btf dump … format c | grep -A8 'enum kernel_load_data_id'` on BOTH fleet arches
/// before this ships past a spike deploy (docs/ebpf-testing-on-nodes.md). Unlike a struct
/// offset, a wrong value here is NOT verifier-checked — it's a plain integer compare, so a
/// reorder (unlikely; this list has been stable since its 5.x introduction, but unconfirmed
/// on THIS fleet kernel) would misclassify silently rather than fail loud.
const LOADING_MODULE: u32 = 2;
/// `LOADING_MAX_ID`(7). Sourced from the SHARED table in `protector-agent-common`
/// (ADR-0014 amendment) rather than a bare literal, so the userspace loader's load-time
/// BTF preflight (`agent/protector-agent/src/preflight`) checks the SAME value against
/// each node's live BTF at every agent start. Unlike a struct offset, a wrong value here
/// is NOT verifier-checked — it's a plain integer compare, so a reorder (unlikely; this
/// list has been stable since its 5.x introduction) would misclassify silently rather than
/// fail loud, which is exactly why the preflight checks it explicitly and logs
/// expected-vs-actual on a mismatch rather than relying on this compile-time value alone.
const LOADING_MODULE: u32 = protector_agent_common::offsets::LOADING_MODULE_VALUE;

/// fentry on `security_kernel_load_data(enum kernel_load_data_id id, bool contents)` — the
/// kernel-module-load probe (Retire-Falco G2). Falco fires critical on
Expand Down
96 changes: 55 additions & 41 deletions agent/protector-agent-ebpf/src/vmlinux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,21 @@
//! degraded the two `bpf_d_path` probes (secret-read `file_open` + `file_write`) to
//! loaded=4/6 fleet-wide. Regenerate (re-verify the offsets) on any kernel struct change.
//!
//! # `linux_binprm.file` / `inode.i_nlink` — ON-NODE BTF VERIFICATION PENDING
//! # `linux_binprm.file` / `inode.i_nlink` — verified continuously, not just once
//!
//! Two fields added for the fileless-exec (anon-inode) probe were derived from kernel
//! *source* layout, not dumped from live BTF like everything else above: `linux_binprm.file`
//! (+64) and `inode.i_nlink` (+72). Each carries its own derivation in its struct's doc
//! comment. Both must be confirmed against `bpftool btf dump` on BOTH fleet arches — the
//! same process that produced the offsets above — before this probe ships past a spike
//! deploy (docs/ebpf-testing-on-nodes.md). A wrong offset here fails the SAME way a wrong
//! `f_path` offset would have: either a verifier rejection (probe degrades,
//! loud in the heartbeat) or, worse, a silently wrong bool if the misread pointer happens
//! to still verify — which is why this module keeps every derivation reasoning explicit
//! rather than asserting a bare number.
//! comment. Unlike the offsets above (dumped once from live BTF on both fleet arches),
//! these two were never manually confirmed against `bpftool btf dump` — but they don't
//! need to be, ONE-TIME, by hand: the userspace loader's load-time BTF preflight
//! (`agent/protector-agent/src/preflight`, ADR-0014 amendment) re-derives every field in
//! this module — these two included — against each node's live BTF before any probe
//! attaches, every time the agent starts. A wrong offset here fails the SAME way a wrong
//! `f_path` offset would have: either a verifier rejection (probe degrades, loud in the
//! heartbeat) or, worse, a silently wrong bool if the misread pointer happens to still
//! verify — which is why the preflight disables the struct-reading probes on a mismatch
//! (fail-closed) rather than trusting a compile-time assertion alone.

// Padding fields (and `mnt`, present only to place `dentry` at +8) are never read — they
// exist solely to position the fields the probes DO read at the right byte offset.
Expand Down Expand Up @@ -93,22 +96,22 @@ pub struct qstr {
/// the anon-inode discriminator — `0` for an unlinked inode (a memfd, or any file `rm`'d
/// while still executing), non-zero for a normal directory-linked file.
///
/// **ON-NODE BTF VERIFICATION PENDING for `i_nlink`:** derived from kernel
/// source, not dumped from live BTF like the fields above it. `i_nlink` is the first field
/// of an anonymous union (`union { const unsigned int i_nlink; unsigned int __i_nlink; }`)
/// immediately after `i_ino` in `struct inode` — no padding needed since `i_ino` (an
/// 8-byte `unsigned long`) already leaves the next field 8-aligned. +72 = +64 (`i_ino`'s
/// offset) + 8 (`i_ino`'s size). Must be confirmed against BOTH fleet arches' live BTF
/// (`bpftool btf dump … format c`) before this ships past a spike deploy — see
/// docs/ebpf-testing-on-nodes.md.
/// `i_nlink`'s offset was derived from kernel source, not dumped from live BTF like the
/// fields above it. `i_nlink` is the first field of an anonymous union (`union { const
/// unsigned int i_nlink; unsigned int __i_nlink; }`) immediately after `i_ino` in `struct
/// inode` — no padding needed since `i_ino` (an 8-byte `unsigned long`) already leaves the
/// next field 8-aligned. +72 = +64 (`i_ino`'s offset) + 8 (`i_ino`'s size). The load-time
/// BTF preflight re-derives this offset on every node at every agent start — including
/// recursing into the anonymous union — so a derivation error here is caught continuously,
/// not just once (see the module doc above and `agent/protector-agent/src/preflight`).
#[repr(C)]
#[derive(Copy, Clone)]
pub struct inode {
_pad0: [u8; 40],
pub i_sb: *mut super_block, // +40
_pad1: [u8; 16],
pub i_ino: u64, // +64 unsigned long
pub i_nlink: u32, // +72 ON-NODE BTF VERIFICATION PENDING (see doc above)
pub i_nlink: u32, // +72 — verified at every load, not just once (see doc above)
}

/// `struct super_block` — prefix through `s_magic` (+96), the tmpfs filter's discriminator.
Expand Down Expand Up @@ -150,38 +153,49 @@ pub struct cred {
/// `interpreter`(+56) + `file`(+64) + `cred`(+72) + `unsafe`(+80) + `per_clear`(+84) +
/// `argc`(+88) + `envc`(+92) = `filename` at +96 — which matches the INDEPENDENTLY
/// on-node-verified `filename` offset below exactly, a strong (but not certain) signal
/// this derivation tracks the real fleet layout. Must still be confirmed against BOTH
/// fleet arches' live BTF (`bpftool btf dump … format c`) before this ships past a spike
/// deploy — see docs/ebpf-testing-on-nodes.md.
/// this derivation tracks the real fleet layout. Like `inode.i_nlink` above, the
/// load-time BTF preflight re-derives this offset from live BTF on every node at every
/// agent start, so this derivation is checked continuously rather than trusted once — see
/// the module doc above and `agent/protector-agent/src/preflight`.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct linux_binprm {
_pad0: [u8; 64],
pub file: *mut file, // +64 ON-NODE BTF VERIFICATION PENDING (see doc above)
pub file: *mut file, // +64 — verified at every load, not just once (see doc above)
_pad1: [u8; 24],
pub filename: *const c_char, // +96
}

// Compile-time guard: pin every read field to its verified 7.0.0 byte offset (see the
// module header). These are the offsets the compiler bakes into `bpf_d_path` and the
// `bpf_probe_read_kernel` chases; if a future edit (padding slip, a reverted binding, a
// kernel struct change) moves one, the eBPF crate fails to BUILD here — loud at CI time
// rather than a silent misread or a verifier rejection only visible on a live node.
// `offset_of!` is const, so this costs nothing at runtime.
// Compile-time guard: pin every read field to the SHARED offset table in
// `protector-agent-common` (ADR-0014 amendment) — `bindings == table`, asserted here, plus
// the userspace loader's load-time preflight asserting `table == node-BTF`
// (agent/protector-agent/src/preflight), transitively proves `bindings == kernel` on every
// node at every start, not just at this compile. These are the offsets the compiler bakes
// into `bpf_d_path` and the `bpf_probe_read_kernel` chases; if a future edit (padding
// slip, a reverted binding, a kernel struct change) moves one without updating the shared
// table too, the eBPF crate fails to BUILD here — loud at CI time rather than a silent
// misread or a verifier rejection only visible on a live node. `offset_of!` and
// `offset_of_table` are both const, so this costs nothing at runtime.
const _: () = {
use core::mem::offset_of;
assert!(offset_of!(file, f_inode) == 32);
assert!(offset_of!(file, f_flags) == 40);
assert!(offset_of!(file, f_path) == 64);
assert!(offset_of!(path, dentry) == 8);
assert!(offset_of!(dentry, d_name) == 32);
assert!(offset_of!(qstr, name) == 8);
assert!(offset_of!(inode, i_sb) == 40);
assert!(offset_of!(inode, i_ino) == 64);
assert!(offset_of!(inode, i_nlink) == 72); // ON-NODE PENDING
assert!(offset_of!(super_block, s_magic) == 96);
assert!(offset_of!(cred, uid) == 8);
assert!(offset_of!(kuid_t, val) == 0);
assert!(offset_of!(linux_binprm, file) == 64); // ON-NODE PENDING
assert!(offset_of!(linux_binprm, filename) == 96);
// `offset_of!` yields `usize`; the shared table stores `u32` (plenty for any struct
// offset in these bindings) so it can be `no_std`-friendly without pulling in a
// pointer-width-specific type — cast at the comparison, not in the table.
const fn tbl(kernel_struct: &str, field: &str) -> usize {
protector_agent_common::offsets::offset_of_table(kernel_struct, field) as usize
}
assert!(offset_of!(file, f_inode) == tbl("file", "f_inode"));
assert!(offset_of!(file, f_flags) == tbl("file", "f_flags"));
assert!(offset_of!(file, f_path) == tbl("file", "f_path"));
assert!(offset_of!(path, dentry) == tbl("path", "dentry"));
assert!(offset_of!(dentry, d_name) == tbl("dentry", "d_name"));
assert!(offset_of!(qstr, name) == tbl("qstr", "name"));
assert!(offset_of!(inode, i_sb) == tbl("inode", "i_sb"));
assert!(offset_of!(inode, i_ino) == tbl("inode", "i_ino"));
assert!(offset_of!(inode, i_nlink) == tbl("inode", "i_nlink"));
assert!(offset_of!(super_block, s_magic) == tbl("super_block", "s_magic"));
assert!(offset_of!(cred, uid) == tbl("cred", "uid"));
assert!(offset_of!(kuid_t, val) == tbl("kuid_t", "val"));
assert!(offset_of!(linux_binprm, file) == tbl("linux_binprm", "file"));
assert!(offset_of!(linux_binprm, filename) == tbl("linux_binprm", "filename"));
};
Loading
Loading