Skip to content
Open
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
30 changes: 28 additions & 2 deletions agent/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ pub const KIND_FILE_OPEN: u32 = 2;
/// a LibraryLoaded with the basename. Reuses [`FileEvent`] (kind discriminates).
pub const KIND_LIBRARY_LOAD: u32 = 3;
/// A process was exec'd (fentry on `security_bprm_check`). Carries the exec'd binary's
/// path, read from `linux_binprm->filename`; userspace emits a ProcessExec. Reuses
/// [`FileEvent`] (kind discriminates) — the runtime signal for "unexpected process
/// path, read from `linux_binprm->filename`, PLUS the anon-inode kernel fact (JEF-317,
/// Route A) read from `bprm->file->f_inode`; userspace emits a `ProcessExec`. Its own
/// [`ExecEvent`] body (not [`FileEvent`]) — the runtime signal for "unexpected process
/// spawned" (ADR-0014).
pub const KIND_EXEC: u32 = 4;
/// A process gained root (fentry on `security_task_fix_setuid`). The eBPF side filters to
Expand Down Expand Up @@ -53,6 +54,31 @@ pub struct FileEvent {
pub path: [u8; PATH_CAP],
}

/// One observed process exec (kind [`KIND_EXEC`]) — the same `header`/`len`/`path` shape
/// as [`FileEvent`], plus the pure-data anon-inode fact (JEF-317, Route A):
/// [`Self::exe_anon_inode`]. A dedicated struct rather than a [`FileEvent`] field, since
/// this fact is exec-specific — the file-open/library-load/file-write probes have no
/// `bprm` to read it from, so folding it into the shared `FileEvent` would mean carrying a
/// meaningless byte on every OTHER kind's wire event.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct ExecEvent {
pub header: EventHeader,
/// Valid bytes in `path` (≤ [`PATH_CAP`]).
pub len: u32,
/// The exec'd binary's path (`linux_binprm->filename`), not NUL-terminated past `len`.
pub path: [u8; PATH_CAP],
/// `1` if the exec'd binary's backing inode is anonymous — memfd/shmem-backed
/// (`inode->i_sb->s_magic` is the tmpfs/shmem magic) or unlinked (`i_nlink == 0`) —
/// rather than a normal, linked, on-disk file; `0` otherwise. A `u8`, not `bool`: a
/// kernel-written byte is not guaranteed a valid Rust `bool` bit pattern, and `no_std`
/// eBPF code writing this field directly must not rely on that guarantee. A KERNEL-
/// OBSERVABLE FACT (JEF-113), not a verdict — whether an anon-inode exec is alarming
/// is engine policy, conservatively scoped (see `engine::observe::exec_class` /
/// `engine::reason::proof::corroborate`), NOT decided here.
pub exe_anon_inode: u8,
}

/// The fixed prefix of every event in the ring buffer. `repr(C)`, at offset 0 of each
/// body, so userspace can read `kind` (and `pid`/`cgroup_id`) before it knows which body
/// follows.
Expand Down
120 changes: 94 additions & 26 deletions agent/protector-agent-ebpf/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ use aya_ebpf::{
// dedup key/window/decision (JEF-65) live here too so the kernel probe and the userspace
// tests share one definition and can't drift.
use protector_agent_common::{
should_coalesce, ConnEvent, ConnKey, EventHeader, FileEvent, PrivEvent, WriteKey,
should_coalesce, ConnEvent, ConnKey, EventHeader, ExecEvent, FileEvent, PrivEvent, WriteKey,
DEDUP_MAP_CAP, DEDUP_WINDOW_NS, KIND_CONNECT, KIND_EXEC, KIND_FILE_OPEN, KIND_FILE_WRITE,
KIND_LIBRARY_LOAD, KIND_PRIV_CHANGE, PATH_CAP,
};
Expand Down Expand Up @@ -414,11 +414,27 @@ fn try_fix_setuid(ctx: &FEntryContext) -> Result<(), i64> {

/// fentry on `security_bprm_check(struct linux_binprm *bprm)` — the process-exec probe
/// (ADR-0014, JEF-53). This LSM hook fires on every `execve` once the new binary is
/// resolved, so `bprm->filename` is the path the kernel is about to exec. Emits a
/// [`FileEvent`] (kind [`KIND_EXEC`]) carrying that path; userspace turns it into a
/// `ProcessExec`. Observe-only. NOTE: the attach point is `security_bprm_check` (the
/// exported LSM call, in BTF — like the other `security_*` probes); the un-prefixed
/// `bprm_check_security` is NOT a BTF function on 6.8 (verified on-node: JEF-53 deploy).
/// resolved, so `bprm->filename` is the path the kernel is about to exec. Emits an
/// [`ExecEvent`] (kind [`KIND_EXEC`]) carrying that path plus the anon-inode fact
/// (JEF-317, below); userspace turns it into a `ProcessExec`. Observe-only. NOTE: the
/// attach point is `security_bprm_check` (the exported LSM call, in BTF — like the other
/// `security_*` probes); the un-prefixed `bprm_check_security` is NOT a BTF function on
/// 6.8 (verified on-node: JEF-53 deploy). Attached via **fentry, not `lsm/*`**: the fleet
/// does not carry `bpf` in its active LSM list (`CONFIG_LSM` omits it, no `lsm=` on the
/// kernel cmdline — confirmed on-node over SSH on both arches), so an `lsm/` program would
/// never attach here; fentry on the `security_*` function works regardless of the active
/// LSM list, which is why every probe in this file uses it.
///
/// JEF-317 (fileless exec / memfd_create parity with Falco), Route A: an EARLIER version
/// of this signal classified the exec *path's shape* (`/dev/fd/<n>` etc.) — withdrawn by
/// security review, because the kernel synthesizes that identical string for a benign
/// `fexecve()` of an on-disk file too, and runc copies itself into a memfd and re-execs on
/// ~every container start (the CVE-2019-5736 mitigation), so path shape alone forged
/// corroboration on routine behavior. The real signal is the *inode*, not the path: a
/// memfd/anonymous-fd exec's backing file lives on a shmem/tmpfs superblock and/or is
/// unlinked (`i_nlink == 0`), which a normal on-disk, directory-linked executable is not.
/// [`exe_is_anon_inode`] reads `bprm->file->f_inode` to determine that, straight from the
/// kernel's own resolution — no path parsing at all.
#[fentry(function = "security_bprm_check")]
pub fn bprm_check(ctx: FEntryContext) -> u32 {
let _ = try_bprm_check(&ctx);
Expand All @@ -431,19 +447,21 @@ fn try_bprm_check(ctx: &FEntryContext) -> Result<(), i64> {
if bprm.is_null() {
return Ok(());
}
emit_exec_path(bprm);
emit_exec_path(bprm, exe_is_anon_inode(bprm));
Ok(())
}

/// Emit the exec'd binary's path as a [`KIND_EXEC`] event. `bprm->filename` is a kernel
/// `char *` (the resolved exec path), so — like the library-load probe — read the string
/// directly with `bpf_probe_read_kernel_str`. NOT `bpf_d_path`: `security_bprm_check`
/// isn't on the kernel's d_path allowlist, so the verifier would reject it (JEF-68).
fn emit_exec_path(bprm: *const vmlinux::linux_binprm) {
let mut ev = FileEvent {
/// Emit the exec'd binary's path (plus the anon-inode fact, JEF-317) as a [`KIND_EXEC`]
/// [`ExecEvent`]. `bprm->filename` is a kernel `char *` (the resolved exec path), so —
/// like the library-load probe — read the string directly with `bpf_probe_read_kernel_str`.
/// NOT `bpf_d_path`: `security_bprm_check` isn't on the kernel's d_path allowlist, so the
/// verifier would reject it (JEF-68).
fn emit_exec_path(bprm: *const vmlinux::linux_binprm, exe_anon_inode: bool) {
let mut ev = ExecEvent {
header: make_header(KIND_EXEC),
len: 0,
path: [0u8; PATH_CAP],
exe_anon_inode: exe_anon_inode as u8,
};
// Read the `char *filename` pointer out of the binprm, then the string it points to.
let mut name_ptr: *const u8 = core::ptr::null();
Expand All @@ -469,14 +487,46 @@ fn emit_exec_path(bprm: *const vmlinux::linux_binprm) {
} else {
PATH_CAP as u32
};
if let Some(mut slot) = EVENTS.reserve::<FileEvent>(0) {
if let Some(mut slot) = EVENTS.reserve::<ExecEvent>(0) {
slot.write(ev);
slot.submit(0);
} else {
record_drop(); // ring full — count the loss instead of silently skipping
}
}

/// Whether the exec'd binary's backing inode is anonymous (JEF-317, Route A): a
/// memfd/shmem-backed file (`inode->i_sb->s_magic` is the tmpfs magic — `memfd_create` is
/// shmem-backed under the hood) OR an unlinked file (`inode->i_nlink == 0` — covers a
/// memfd, which is never linked into any directory, AND the separate "delete the binary
/// while it's still executing" technique on a normal filesystem). Reads
/// `bprm->file->f_inode`: `bprm->file` is the ALREADY-OPENED executable (opened before
/// this hook fires — see the doc on [`bprm_check`]), so this is the SAME file the kernel
/// is about to run, not a TOCTOU-able separate lookup. A failed read = "not anonymous"
/// (fail closed on the flag, matching [`is_tmpfs`]/[`inode_ino`]'s existing convention).
///
/// PURE DATA (JEF-113): this reports a kernel fact only. Whether an anon-inode exec is
/// alarming — and the runc-memfd-reexec false-positive risk that makes this conservative
/// — is engine policy, not decided here.
fn exe_is_anon_inode(bprm: *const vmlinux::linux_binprm) -> bool {
unsafe {
let mut file: *mut vmlinux::file = core::ptr::null_mut();
if read_kernel(&mut file, core::ptr::addr_of!((*bprm).file)) != 0 || file.is_null() {
return false;
}
let Some(inode) = inode_of(file as *const vmlinux::file) else {
return false;
};
let mut nlink: u32 = 1; // fail closed: a failed read must not read as "unlinked"
if read_kernel(&mut nlink, core::ptr::addr_of!((*inode).i_nlink)) == 0 && nlink == 0 {
return true;
}
// Reuse the SAME `inode` already fetched above (not `is_tmpfs(file)`, which would
// re-walk `file->f_inode` a second time for the same fact).
superblock_magic(inode) == Some(TMPFS_MAGIC)
}
}

/// bpf_d_path the file's path into a [`FileEvent`] of `kind` and submit it. Shared by the
/// secret-read (file_open) probe — it needs the full path so the engine can match it to a
/// Secret mount. (Library-load uses [`emit_lib_name`]: bpf_d_path is disallowed in its hook.)
Expand Down Expand Up @@ -567,24 +617,45 @@ fn emit_lib_name(file: *const vmlinux::file) {
}
}

/// Whether `file` lives on a tmpfs — `file->f_inode->i_sb->s_magic == TMPFS_MAGIC`. The
/// pointer chase uses bpf_probe_read_kernel (fixed offsets from the node-BTF vmlinux),
/// the same safe pattern as the connect probe. A failed read = "not tmpfs" (drop).
fn is_tmpfs(file: *const vmlinux::file) -> bool {
/// Read `file->f_inode` — the pointer chase every inode-fact reader below starts from
/// ([`is_tmpfs`], [`inode_ino`], [`exe_is_anon_inode`]). `None` on a failed read or a null
/// inode; every caller treats that as "the fact I wanted isn't available" (fail closed for
/// an alarm-shaped bool, fail open for the dedup key — each caller's own choice).
unsafe fn inode_of(file: *const vmlinux::file) -> Option<*mut vmlinux::inode> {
unsafe {
let mut inode: *mut vmlinux::inode = core::ptr::null_mut();
if read_kernel(&mut inode, core::ptr::addr_of!((*file).f_inode)) != 0 || inode.is_null() {
return false;
return None;
}
Some(inode)
}
}

/// Read `inode->i_sb->s_magic` — the superblock magic every magic-comparing reader below
/// starts from ([`is_tmpfs`], [`exe_is_anon_inode`]). `None` on any failed read.
unsafe fn superblock_magic(inode: *mut vmlinux::inode) -> Option<u64> {
unsafe {
let mut sb: *mut vmlinux::super_block = core::ptr::null_mut();
if read_kernel(&mut sb, core::ptr::addr_of!((*inode).i_sb)) != 0 || sb.is_null() {
return false;
return None;
}
let mut magic: u64 = 0;
if read_kernel(&mut magic, core::ptr::addr_of!((*sb).s_magic).cast()) != 0 {
return false;
return None;
}
magic == TMPFS_MAGIC
Some(magic)
}
}

/// Whether `file` lives on a tmpfs — `file->f_inode->i_sb->s_magic == TMPFS_MAGIC`. The
/// pointer chase uses bpf_probe_read_kernel (fixed offsets from the node-BTF vmlinux),
/// the same safe pattern as the connect probe. A failed read = "not tmpfs" (drop).
fn is_tmpfs(file: *const vmlinux::file) -> bool {
unsafe {
let Some(inode) = inode_of(file) else {
return false;
};
superblock_magic(inode) == Some(TMPFS_MAGIC)
}
}

Expand All @@ -594,10 +665,7 @@ fn is_tmpfs(file: *const vmlinux::file) -> bool {
/// then emits without deduping (fail open), never dropping a real write for a bookkeeping miss.
fn inode_ino(file: *const vmlinux::file) -> Option<u64> {
unsafe {
let mut inode: *mut vmlinux::inode = core::ptr::null_mut();
if read_kernel(&mut inode, core::ptr::addr_of!((*file).f_inode)) != 0 || inode.is_null() {
return None;
}
let inode = inode_of(file)?;
let mut ino: u64 = 0;
if read_kernel(&mut ino, core::ptr::addr_of!((*inode).i_ino).cast()) != 0 {
return None;
Expand Down
55 changes: 50 additions & 5 deletions agent/protector-agent-ebpf/src/vmlinux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@
//! which on 7.0.0 lands in the `f_wb_err`/`f_ep` region — the verifier rejection that
//! 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 (JEF-317)
//!
//! 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 (JEF-324): 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.

// 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 @@ -75,15 +88,27 @@ pub struct qstr {
pub name: *const u8, // +8 const unsigned char *
}

/// `struct inode` — prefix through `i_ino` (+64). `i_sb` (+40) reaches the superblock
/// (tmpfs magic); `i_ino` (+64) is the file-write dedup key.
/// `struct inode` — prefix through `i_nlink` (+72). `i_sb` (+40) reaches the superblock
/// (tmpfs magic); `i_ino` (+64) is the file-write dedup key; `i_nlink` (+72, JEF-317) is
/// 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` (JEF-317):** 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.
#[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_ino: u64, // +64 unsigned long
pub i_nlink: u32, // +72 ON-NODE BTF VERIFICATION PENDING (JEF-317, see doc above)
}

/// `struct super_block` — prefix through `s_magic` (+96), the tmpfs filter's discriminator.
Expand Down Expand Up @@ -111,11 +136,29 @@ pub struct cred {
}

/// `struct linux_binprm` — prefix through `filename` (+96), the resolved exec path
/// (`char *`) the process-exec probe emits.
/// (`char *`) the process-exec probe emits. `file` (+64, JEF-317) is the ALREADY-OPENED
/// executable's `struct file*` — by the time `security_bprm_check` fires, `bprm_execve()`
/// (fs/exec.c) has already opened it (`do_open_execat`/`bprm->file = …`), before
/// `exec_binprm()` → `search_binary_handler()` → `security_bprm_check()` is reached — so
/// this read is safe at this hook, no ordering hazard.
///
/// **ON-NODE BTF VERIFICATION PENDING for `file` (JEF-317):** derived from kernel source
/// layout (`struct linux_binprm` in linux/binfmts.h), not dumped from live BTF like
/// `filename` below (already verified on-node, JEF-53). Derivation: `vma`(+0) +
/// `vma_pages`(+8) + `mm`(+16) + `p`(+24) + `argmin`(+32) + the four-bit `unsigned int`
/// bitfield (+40, padded to +48 for the next pointer's alignment) + `executable`(+48) +
/// `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.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct linux_binprm {
_pad0: [u8; 96],
_pad0: [u8; 64],
pub file: *mut file, // +64 ON-NODE BTF VERIFICATION PENDING (JEF-317, see doc above)
_pad1: [u8; 24],
pub filename: *const c_char, // +96
}

Expand All @@ -135,8 +178,10 @@ const _: () = {
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); // JEF-317, 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); // JEF-317, ON-NODE PENDING
assert!(offset_of!(linux_binprm, filename) == 96);
};
7 changes: 6 additions & 1 deletion agent/protector-agent/src/coalesce/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,15 @@ fn distinct_behaviors_all_survive() {
"p1",
Behavior::ProcessExec {
path: "/bin/bash".into(),
exe_anon_inode: false,
},
3,
)); // exec:bash
c.offer(obs(
"p1",
Behavior::ProcessExec {
path: "/usr/bin/python".into(),
exe_anon_inode: false,
},
4,
)); // exec:python
Expand Down Expand Up @@ -132,13 +134,15 @@ fn exec_churn_collapses_by_basename() {
"p1",
Behavior::ProcessExec {
path: "/usr/bin/bash".into(),
exe_anon_inode: false,
},
1,
));
c.offer(obs(
"p1",
Behavior::ProcessExec {
path: "/bin/bash".into(),
exe_anon_inode: false,
},
2,
));
Expand Down Expand Up @@ -215,7 +219,8 @@ fn max_size_forces_a_flush() {
c.offer(obs(
"p1",
Behavior::ProcessExec {
path: "/bin/sh".into()
path: "/bin/sh".into(),
exe_anon_inode: false,
},
3
))
Expand Down
Loading
Loading