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
18 changes: 18 additions & 0 deletions agent/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
56 changes: 55 additions & 1 deletion agent/protector-agent-ebpf/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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::<PrivEvent>(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.)
Expand Down
77 changes: 75 additions & 2 deletions agent/protector-agent/src/observer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}

Expand All @@ -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,
},
}
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -369,6 +384,14 @@ mod ebpf {
let ev = unsafe { std::ptr::read_unaligned(data.as_ptr().cast::<FileEvent>()) };
Self::library_load(&ev)
}
KIND_PRIV_CHANGE => {
if data.len() < std::mem::size_of::<PrivEvent>() {
return None;
}
// SAFETY: kind says this is a PrivEvent of exactly this layout.
let ev = unsafe { std::ptr::read_unaligned(data.as_ptr().cast::<PrivEvent>()) };
Self::priv_change(&ev)
}
_ => None, // unknown kind (older/newer probe set) — skip
}
}
Expand Down Expand Up @@ -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<RawEvent> {
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/<pid>/cgroup`). The blocking read kept
Expand Down Expand Up @@ -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::<u8>(),
std::mem::size_of::<PrivEvent>(),
)
};
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,
}
);
}
}
}
13 changes: 13 additions & 0 deletions behavior/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}")
}
}
}

Expand All @@ -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}"),
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions engine/src/engine/reason/proof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
Loading