From cc721e9d7cf2a80d18f74888fb1995209f1b88b7 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 21 Jun 2026 11:35:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(agent):=20privilege-change=20probe=20?= =?UTF-8?q?=E2=80=94=20fentry=20on=20security=5Ftask=5Ffix=5Fsetuid=20(JEF?= =?UTF-8?q?-54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new eBPF probe captures privilege escalation to root and emits a new Behavior::PrivilegeChange { from_uid, to_uid } — Falco privilege-escalation parity (ADR-0014). - eBPF: fentry on security_task_fix_setuid(new, old, flags). Reads new->uid.val and old->uid.val via bpf_probe_read_kernel (cred -> kuid_t { val: u32 } chase, same pattern as is_tmpfs; never bpf_d_path per JEF-68). Emits ONLY on escalation (new_uid == 0 && old_uid != 0) to keep ring volume low and the signal meaningful. record_drop() on reserve failure. - common: KIND_PRIV_CHANGE = 5 (wire 4 reserved) + repr(C) PrivEvent { header, old_uid, new_uid }. - behavior: Behavior::PrivilegeChange; fingerprint_key "priv:{to_uid}"; summary "privilege change uid {from} -> {to}". - userspace: RawEvent::PrivChange + decode arm for KIND_PRIV_CHANGE + into_behavior; attach via the best-effort fentry table; stays on the decoupled-drain/worker path (JEF-64). - engine: proof.rs corroborates() => PrivilegeChange is NON-corroborating (per-objective corroboration is JEF-49). - test: KIND_PRIV_CHANGE decodes to PrivilegeChange { from, to }. Verifier-accept is on-node only (JEF-68); validated via cargo +nightly check, workspace build/test/clippy/fmt, and the Docker builder target (EXIT 0). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- agent/common/src/lib.rs | 18 ++++++ agent/protector-agent-ebpf/src/main.rs | 56 ++++++++++++++++++- agent/protector-agent/src/observer.rs | 77 +++++++++++++++++++++++++- behavior/src/lib.rs | 13 +++++ engine/src/engine/reason/proof.rs | 4 ++ 5 files changed, 165 insertions(+), 3 deletions(-) diff --git a/agent/common/src/lib.rs b/agent/common/src/lib.rs index 65996cd..49e82c1 100644 --- a/agent/common/src/lib.rs +++ b/agent/common/src/lib.rs @@ -19,6 +19,11 @@ pub const KIND_FILE_OPEN: u32 = 2; /// binary (fentry on `security_mmap_file`, PROT_EXEC). Carries the path; userspace emits /// a LibraryLoaded with the basename. Reuses [`FileEvent`] (kind discriminates). pub const KIND_LIBRARY_LOAD: u32 = 3; +/// A process gained root (fentry on `security_task_fix_setuid`). The eBPF side filters to +/// the escalation case (`new_uid == 0 && old_uid != 0`) so this is always a real +/// privilege gain; the [`PrivEvent`] body carries the old and new real UIDs. Userspace +/// emits a [`Behavior::PrivilegeChange`]. (Wire value `4` is intentionally skipped/reserved.) +pub const KIND_PRIV_CHANGE: u32 = 5; /// Max path bytes carried per [`FileEvent`]. Secret-mount paths are well under this; a /// longer path is truncated (the secret name still lands). Sized to keep the eBPF stack @@ -58,3 +63,16 @@ pub struct ConnEvent { /// Destination port, host byte order. pub dport: u16, } + +/// One observed privilege escalation to root (kind [`KIND_PRIV_CHANGE`]). The eBPF side +/// already filtered to the escalation case (`new_uid == 0 && old_uid != 0`), so every +/// PrivEvent is a real gain of root. `header` first so the shared prefix is at offset 0. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct PrivEvent { + pub header: EventHeader, + /// The process's real UID before the change (non-root). + pub old_uid: u32, + /// The process's real UID after the change (0 — root). + pub new_uid: u32, +} diff --git a/agent/protector-agent-ebpf/src/main.rs b/agent/protector-agent-ebpf/src/main.rs index 916c492..9d18e79 100644 --- a/agent/protector-agent-ebpf/src/main.rs +++ b/agent/protector-agent-ebpf/src/main.rs @@ -28,7 +28,8 @@ use aya_ebpf::{ // The event layouts + kind discriminators are shared verbatim with the userspace loader // via this one crate, so the kernel↔userspace byte contract can't drift (ADR-0014). use protector_agent_common::{ - ConnEvent, EventHeader, FileEvent, KIND_CONNECT, KIND_FILE_OPEN, KIND_LIBRARY_LOAD, PATH_CAP, + ConnEvent, EventHeader, FileEvent, PrivEvent, KIND_CONNECT, KIND_FILE_OPEN, KIND_LIBRARY_LOAD, + KIND_PRIV_CHANGE, PATH_CAP, }; /// Ring buffer of behavioral events (all kinds) drained by userspace. @@ -184,6 +185,59 @@ fn try_mmap_file(ctx: &FEntryContext) -> Result<(), i64> { Ok(()) } +/// fentry on `security_task_fix_setuid(struct cred *new, const struct cred *old, int flags)` +/// — the privilege-change probe (ADR-0014, Falco privilege-escalation parity). This LSM hook +/// runs on every credential change (setuid/setresuid/…), so we filter IN-KERNEL to the only +/// case worth a signal: a process *gaining* root (`new->uid.val == 0 && old->uid.val != 0`). +/// That keeps ring volume tiny and the signal meaningful — a non-root process becoming root. +/// Reads the cred `uid.val` fields with `bpf_probe_read_kernel` (never bpf_d_path — JEF-68). +/// Observe-only; a failed read drops the event, never errors the probe. +#[fentry(function = "security_task_fix_setuid")] +pub fn fix_setuid(ctx: FEntryContext) -> u32 { + let _ = try_fix_setuid(&ctx); + 0 +} + +fn try_fix_setuid(ctx: &FEntryContext) -> Result<(), i64> { + // arg0 = `struct cred *new`, arg1 = `const struct cred *old`. + let new: *const vmlinux::cred = unsafe { ctx.arg(0) }; + let old: *const vmlinux::cred = unsafe { ctx.arg(1) }; + if new.is_null() || old.is_null() { + return Ok(()); + } + // cred->uid is a kuid_t { val: u32 } — chase to the u32 with bpf_probe_read_kernel. + let mut new_uid: u32 = 0; + let mut old_uid: u32 = 0; + unsafe { + if read_kernel(&mut new_uid, core::ptr::addr_of!((*new).uid.val)) != 0 { + return Ok(()); + } + if read_kernel(&mut old_uid, core::ptr::addr_of!((*old).uid.val)) != 0 { + return Ok(()); + } + } + // Emit ONLY on escalation to root: a non-root process becoming root. Lateral or + // de-escalating credential changes (the bulk of setuid traffic) are dropped here. + if !(new_uid == 0 && old_uid != 0) { + return Ok(()); + } + let pid = (aya_ebpf::helpers::bpf_get_current_pid_tgid() >> 32) as u32; + if let Some(mut slot) = EVENTS.reserve::(0) { + slot.write(PrivEvent { + header: EventHeader { + kind: KIND_PRIV_CHANGE, + pid, + }, + old_uid, + new_uid, + }); + slot.submit(0); + } else { + record_drop(); // ring full — count the loss instead of silently skipping + } + Ok(()) +} + /// 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.) diff --git a/agent/protector-agent/src/observer.rs b/agent/protector-agent/src/observer.rs index 51ddbba..51272b2 100644 --- a/agent/protector-agent/src/observer.rs +++ b/agent/protector-agent/src/observer.rs @@ -51,7 +51,7 @@ mod ebpf { // kernel↔userspace byte contract can't drift (ADR-0014). use protector_agent_common::{ ConnEvent, EventHeader, FileEvent, KIND_CONNECT, KIND_FILE_OPEN, KIND_LIBRARY_LOAD, - PATH_CAP, + KIND_PRIV_CHANGE, PATH_CAP, PrivEvent, }; use protector_behavior::{Attribution, Behavior}; @@ -93,6 +93,13 @@ mod ebpf { FileRead { pid: u32, path: String }, /// Executable mmap: the library basename (e.g. `libssl.so.3`). LibraryLoad { pid: u32, name: String }, + /// Privilege escalation to root: the pre/post real UIDs (the eBPF side already + /// filtered to `new_uid == 0 && old_uid != 0`). + PrivChange { + pid: u32, + old_uid: u32, + new_uid: u32, + }, } impl RawEvent { @@ -101,7 +108,8 @@ mod ebpf { match self { RawEvent::Connect { pid, .. } | RawEvent::FileRead { pid, .. } - | RawEvent::LibraryLoad { pid, .. } => *pid, + | RawEvent::LibraryLoad { pid, .. } + | RawEvent::PrivChange { pid, .. } => *pid, } } @@ -120,6 +128,12 @@ mod ebpf { } RawEvent::FileRead { path, .. } => Behavior::FileRead { path }, RawEvent::LibraryLoad { name, .. } => Behavior::LibraryLoaded { name }, + RawEvent::PrivChange { + old_uid, new_uid, .. + } => Behavior::PrivilegeChange { + from_uid: old_uid, + to_uid: new_uid, + }, } } } @@ -299,6 +313,7 @@ mod ebpf { const FENTRY_PROBES: &[(&str, &str)] = &[ ("file_open", "security_file_open"), ("mmap_file", "security_mmap_file"), + ("fix_setuid", "security_task_fix_setuid"), ]; let btf = match Btf::from_sys_fs() { Ok(btf) => btf, @@ -369,6 +384,14 @@ mod ebpf { let ev = unsafe { std::ptr::read_unaligned(data.as_ptr().cast::()) }; Self::library_load(&ev) } + KIND_PRIV_CHANGE => { + if data.len() < std::mem::size_of::() { + return None; + } + // SAFETY: kind says this is a PrivEvent of exactly this layout. + let ev = unsafe { std::ptr::read_unaligned(data.as_ptr().cast::()) }; + Self::priv_change(&ev) + } _ => None, // unknown kind (older/newer probe set) — skip } } @@ -416,6 +439,17 @@ mod ebpf { name: name.to_string(), }) } + + /// Parse a privilege-change event into a raw PrivChange. The eBPF side already + /// filtered to the escalation-to-root case (`new_uid == 0 && old_uid != 0`), so + /// this just carries the UIDs through. Pure (no `/proc`). + fn priv_change(ev: &PrivEvent) -> Option { + Some(RawEvent::PrivChange { + pid: ev.header.pid, + old_uid: ev.old_uid, + new_uid: ev.new_uid, + }) + } } /// Read a pid's cgroup membership text (`/proc//cgroup`). The blocking read kept @@ -529,5 +563,44 @@ mod ebpf { other => panic!("expected Connect, got something else: {}", other.is_none()), } } + + #[test] + fn decode_priv_change_parses_uids() { + let ev = PrivEvent { + header: EventHeader { + kind: KIND_PRIV_CHANGE, + pid: 4321, + }, + old_uid: 1000, + new_uid: 0, + }; + let bytes = unsafe { + std::slice::from_raw_parts( + (&ev as *const PrivEvent).cast::(), + std::mem::size_of::(), + ) + }; + match EbpfObserver::decode(bytes) { + Some(RawEvent::PrivChange { + pid, + old_uid, + new_uid, + }) => { + assert_eq!(pid, 4321); + assert_eq!(old_uid, 1000); + assert_eq!(new_uid, 0); + } + _ => panic!("expected PrivChange"), + } + // And the raw event maps to the PrivilegeChange behavior with from/to uids. + let raw = EbpfObserver::decode(bytes).unwrap(); + assert_eq!( + raw.into_behavior(), + Behavior::PrivilegeChange { + from_uid: 1000, + to_uid: 0, + } + ); + } } } diff --git a/behavior/src/lib.rs b/behavior/src/lib.rs index f38df25..9da6d78 100644 --- a/behavior/src/lib.rs +++ b/behavior/src/lib.rs @@ -35,6 +35,12 @@ pub enum Behavior { /// never persists as graph state, so [`Self::summary`]/[`Self::fingerprint_key`] only /// see it defensively. FileRead { path: String }, + /// A process gained root — its real UID changed to 0 from a non-root UID (the eBPF + /// agent's privilege-change probe, fentry on `security_task_fix_setuid`; Falco + /// privilege-escalation-rule parity). Model evidence, not blanket corroboration: + /// legitimate workloads sometimes escalate (init/entrypoint), so wiring this to + /// corroborate a specific attack is JEF-49's job. + PrivilegeChange { from_uid: u32, to_uid: u32 }, } impl Behavior { @@ -58,6 +64,9 @@ impl Behavior { Behavior::SecretRead { secret } => format!("reads secret {secret}"), Behavior::LibraryLoaded { name } => format!("loaded library {name}"), Behavior::FileRead { path } => format!("opened file {path}"), + Behavior::PrivilegeChange { from_uid, to_uid } => { + format!("privilege change uid {from_uid} -> {to_uid}") + } } } @@ -75,6 +84,10 @@ impl Behavior { Behavior::SecretRead { secret } => format!("read:{secret}"), Behavior::LibraryLoaded { name } => format!("lib:{name}"), Behavior::FileRead { path } => format!("file:{path}"), + // Keyed on the gained UID only (always 0 today, but stable if the escalation + // predicate widens): repeated escalations to the same UID collapse to one + // fingerprint and don't bust the verdict cache. + Behavior::PrivilegeChange { to_uid, .. } => format!("priv:{to_uid}"), } } } diff --git a/engine/src/engine/reason/proof.rs b/engine/src/engine/reason/proof.rs index 9fbbd55..e6937d0 100644 --- a/engine/src/engine/reason/proof.rs +++ b/engine/src/engine/reason/proof.rs @@ -251,6 +251,10 @@ fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool { // FileRead never reaches here — the RuntimeAdapter refines it to SecretRead or // drops it before it becomes graph state. Behavior::FileRead { .. } => false, + // PrivilegeChange is NON-corroborating here: it's model evidence, not a per- + // objective "now" signal (legit entrypoints escalate too). Wiring it into a + // specific attack chain would be a JEF-49-style follow-up. + Behavior::PrivilegeChange { .. } => false, } }