From aa0399aace3b25b7421bcb2eb73c2038400a0a4d Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Tue, 18 Nov 2025 13:52:11 +0100 Subject: [PATCH 1/4] ROX-30257: implement permission change tracking This is done via the path_chmod LSM hook. WIP: the old mode is not quite working yet. --- fact-ebpf/src/bpf/events.h | 49 +++++++++++++++++------ fact-ebpf/src/bpf/main.c | 38 ++++++++++++++++++ fact-ebpf/src/bpf/types.h | 8 ++++ fact-ebpf/src/lib.rs | 1 + fact/src/bpf/mod.rs | 6 +-- fact/src/event/mod.rs | 62 +++++++++++++++++++++++++++++- fact/src/metrics/kernel_metrics.rs | 9 +++++ 7 files changed, 158 insertions(+), 15 deletions(-) diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index ab027bb4..4e9e51bf 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -11,17 +11,12 @@ #include // clang-format on -__always_inline static void submit_event(struct metrics_by_hook_t* m, - file_activity_type_t event_type, - const char filename[PATH_MAX], - inode_key_t* inode, - bool use_bpf_d_path) { - struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); - if (event == NULL) { - m->ringbuffer_full++; - return; - } - +__always_inline static void __submit_event(struct event_t* event, + struct metrics_by_hook_t* m, + file_activity_type_t event_type, + const char filename[PATH_MAX], + inode_key_t* inode, + bool use_bpf_d_path) { event->type = event_type; event->timestamp = bpf_ktime_get_boot_ns(); inode_copy_or_reset(&event->inode, inode); @@ -46,3 +41,35 @@ __always_inline static void submit_event(struct metrics_by_hook_t* m, m->error++; bpf_ringbuf_discard(event, 0); } + +__always_inline static void submit_event(struct metrics_by_hook_t* m, + file_activity_type_t event_type, + const char filename[PATH_MAX], + inode_key_t* inode, + bool use_bpf_d_path) { + struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); + if (event == NULL) { + m->ringbuffer_full++; + return; + } + + __submit_event(event, m, event_type, filename, inode, use_bpf_d_path); +} + +__always_inline static void submit_mode_event(struct metrics_by_hook_t* m, + const char filename[PATH_MAX], + inode_key_t* inode, + umode_t mode, + umode_t old_mode, + bool use_bpf_d_path) { + struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); + if (event == NULL) { + m->ringbuffer_full++; + return; + } + + event->chmod.new = mode; + event->chmod.old = old_mode; + + __submit_event(event, m, FILE_ACTIVITY_CHMOD, filename, inode, use_bpf_d_path); +} diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 19b6ea49..bb9cfe7c 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -7,6 +7,7 @@ #include "maps.h" #include "events.h" #include "bound_path.h" +#include "vmlinux/x86_64.h" #include #include @@ -125,3 +126,40 @@ int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { m->path_unlink.error++; return 0; } + +SEC("lsm/path_chmod") +int BPF_PROG(trace_path_chmod, struct path* path, umode_t mode) { + struct metrics_t* m = get_metrics(); + if (m == NULL) { + return 0; + } + + m->path_chmod.total++; + + struct bound_path_t* bound_path = path_read(path); + if (bound_path == NULL) { + bpf_printk("Failed to read path"); + m->path_chmod.error++; + return 0; + } + + inode_key_t inode_key = inode_to_key(path->dentry->d_inode); + const inode_value_t* inode = inode_get(&inode_key); + + switch (inode_is_monitored(inode)) { + case NOT_MONITORED: + if (!is_monitored(bound_path)) { + m->path_chmod.ignored++; + return 0; + } + break; + + case MONITORED: + break; + } + + umode_t old_mode = BPF_CORE_READ(path, dentry, d_inode, i_mode); + submit_mode_event(&m->path_chmod, bound_path->path, &inode_key, mode, old_mode, true); + + return 0; +} diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index 17c1f43b..f7100c98 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -47,6 +47,7 @@ typedef enum file_activity_type_t { FILE_ACTIVITY_OPEN = 0, FILE_ACTIVITY_CREATION, FILE_ACTIVITY_UNLINK, + FILE_ACTIVITY_CHMOD, } file_activity_type_t; struct event_t { @@ -55,6 +56,12 @@ struct event_t { char filename[PATH_MAX]; inode_key_t inode; file_activity_type_t type; + union { + struct { + short unsigned int new; + short unsigned int old; + } chmod; + }; }; /** @@ -83,4 +90,5 @@ struct metrics_by_hook_t { struct metrics_t { struct metrics_by_hook_t file_open; struct metrics_by_hook_t path_unlink; + struct metrics_by_hook_t path_chmod; }; diff --git a/fact-ebpf/src/lib.rs b/fact-ebpf/src/lib.rs index 2251993d..5ba66dc5 100644 --- a/fact-ebpf/src/lib.rs +++ b/fact-ebpf/src/lib.rs @@ -111,6 +111,7 @@ impl metrics_t { let mut m = metrics_t { ..*self }; m.file_open = m.file_open.accumulate(&other.file_open); m.path_unlink = m.path_unlink.accumulate(&other.path_unlink); + m.path_chmod = m.path_chmod.accumulate(&other.path_chmod); m } } diff --git a/fact/src/bpf/mod.rs b/fact/src/bpf/mod.rs index 2a6b85e2..631404b5 100644 --- a/fact/src/bpf/mod.rs +++ b/fact/src/bpf/mod.rs @@ -8,7 +8,7 @@ use aya::{ }; use checks::Checks; use libc::c_char; -use log::{debug, error, info}; +use log::{error, info}; use tokio::{ io::unix::AsyncFd, sync::{mpsc, watch}, @@ -154,7 +154,8 @@ impl Bpf { fn load_progs(&mut self, btf: &Btf) -> anyhow::Result<()> { self.load_lsm_prog("trace_file_open", "file_open", btf)?; - self.load_lsm_prog("trace_path_unlink", "path_unlink", btf) + self.load_lsm_prog("trace_path_unlink", "path_unlink", btf)?; + self.load_lsm_prog("trace_path_chmod", "path_chmod", btf) } fn attach_progs(&mut self) -> anyhow::Result<()> { @@ -192,7 +193,6 @@ impl Bpf { Ok(event) => event, Err(e) => { error!("Failed to parse event: '{e}'"); - debug!("Event: {event:?}"); event_counter.dropped(); continue; } diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 205b6339..3b637c90 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -67,6 +67,7 @@ impl Event { FileData::Open(data) => &data.inode, FileData::Creation(data) => &data.inode, FileData::Unlink(data) => &data.inode, + FileData::Chmod(data) => &data.file.inode, } } @@ -75,6 +76,7 @@ impl Event { FileData::Open(data) => data.host_file = host_path, FileData::Creation(data) => data.host_file = host_path, FileData::Unlink(data) => data.host_file = host_path, + FileData::Chmod(data) => data.file.host_file = host_path, } } } @@ -85,7 +87,12 @@ impl TryFrom<&event_t> for Event { fn try_from(value: &event_t) -> Result { let process = Process::try_from(value.process)?; let timestamp = host_info::get_boot_time() + value.timestamp; - let file = FileData::new(value.type_, value.filename, value.inode)?; + let file = FileData::new( + value.type_, + value.filename, + value.inode, + value.__bindgen_anon_1, + )?; Ok(Event { timestamp, @@ -123,6 +130,7 @@ pub enum FileData { Open(BaseFileData), Creation(BaseFileData), Unlink(BaseFileData), + Chmod(ChmodFileData), } impl FileData { @@ -130,12 +138,21 @@ impl FileData { event_type: file_activity_type_t, filename: [c_char; PATH_MAX as usize], inode: inode_key_t, + extra_data: fact_ebpf::event_t__bindgen_ty_1, ) -> anyhow::Result { let inner = BaseFileData::new(filename, inode)?; let file = match event_type { file_activity_type_t::FILE_ACTIVITY_OPEN => FileData::Open(inner), file_activity_type_t::FILE_ACTIVITY_CREATION => FileData::Creation(inner), file_activity_type_t::FILE_ACTIVITY_UNLINK => FileData::Unlink(inner), + file_activity_type_t::FILE_ACTIVITY_CHMOD => { + let data = ChmodFileData { + file: inner, + new_mode: unsafe { extra_data.chmod.new }, + old_mode: unsafe { extra_data.chmod.old }, + }; + FileData::Chmod(data) + } invalid => unreachable!("Invalid event type: {invalid:?}"), }; @@ -161,6 +178,10 @@ impl From for fact_api::file_activity::File { let f_act = fact_api::FileUnlink { activity }; fact_api::file_activity::File::Unlink(f_act) } + FileData::Chmod(event) => { + let f_act = fact_api::FilePermissionChange::from(event); + fact_api::file_activity::File::Permission(f_act) + } } } } @@ -211,3 +232,42 @@ impl From for fact_api::FileActivityBase { } } } + +#[derive(Debug, Clone, Serialize)] +pub struct ChmodFileData { + file: BaseFileData, + new_mode: u16, + old_mode: u16, +} + +impl ChmodFileData { + pub fn new( + filename: [c_char; PATH_MAX as usize], + inode: inode_key_t, + new_mode: u16, + old_mode: u16, + ) -> anyhow::Result { + let file = BaseFileData::new(filename, inode)?; + + Ok(ChmodFileData { + file, + new_mode, + old_mode, + }) + } +} + +impl From for fact_api::FilePermissionChange { + fn from(value: ChmodFileData) -> Self { + let ChmodFileData { + file, + new_mode, + old_mode: _, + } = value; + let activity = fact_api::FileActivityBase::from(file); + fact_api::FilePermissionChange { + activity: Some(activity), + mode: new_mode as u32, + } + } +} diff --git a/fact/src/metrics/kernel_metrics.rs b/fact/src/metrics/kernel_metrics.rs index d089a1c8..720943a9 100644 --- a/fact/src/metrics/kernel_metrics.rs +++ b/fact/src/metrics/kernel_metrics.rs @@ -10,6 +10,7 @@ use super::{EventCounter, LabelValues}; pub struct KernelMetrics { file_open: EventCounter, path_unlink: EventCounter, + path_chmod: EventCounter, map: PerCpuArray, } @@ -25,13 +26,20 @@ impl KernelMetrics { "Events processed by the path_unlink LSM hook", &[], // Labels are not needed since `collect` will add them all ); + let path_chmod = EventCounter::new( + "kernel_path_chmod_events", + "Events processed by the path_chmod LSM hook", + &[], // Labels are not needed since `collect` will add them all + ); file_open.register(reg); path_unlink.register(reg); + path_chmod.register(reg); KernelMetrics { file_open, path_unlink, + path_chmod, map: kernel_metrics, } } @@ -78,6 +86,7 @@ impl KernelMetrics { KernelMetrics::refresh_labels(&self.file_open, &metrics.file_open); KernelMetrics::refresh_labels(&self.path_unlink, &metrics.path_unlink); + KernelMetrics::refresh_labels(&self.path_chmod, &metrics.path_chmod); Ok(()) } From a7d0dba924d87fb81ef48871c44278dd14ec2f62 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Tue, 18 Nov 2025 11:47:38 +0100 Subject: [PATCH 2/4] Attach to the chown LSM hook --- fact-ebpf/build.rs | 1 + fact-ebpf/src/bpf/events.h | 22 ++++++++++++++++++++ fact-ebpf/src/bpf/main.c | 42 ++++++++++++++++++++++++++++++++++++++ fact-ebpf/src/bpf/types.h | 8 ++++++++ 4 files changed, 73 insertions(+) diff --git a/fact-ebpf/build.rs b/fact-ebpf/build.rs index b9c35887..5a1a398a 100644 --- a/fact-ebpf/build.rs +++ b/fact-ebpf/build.rs @@ -49,6 +49,7 @@ fn generate_bindings(out_dir: &Path) -> anyhow::Result<()> { let bindings = bindgen::Builder::default() .header("src/bpf/types.h") .derive_default(true) + .impl_debug(true) .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) .default_enum_style(bindgen::EnumVariation::NewType { is_bitfield: false, diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index 4e9e51bf..a4c1225d 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -73,3 +73,25 @@ __always_inline static void submit_mode_event(struct metrics_by_hook_t* m, __submit_event(event, m, FILE_ACTIVITY_CHMOD, filename, inode, use_bpf_d_path); } + +__always_inline static void submit_owner_event(struct metrics_by_hook_t* m, + const char filename[PATH_MAX], + inode_key_t* inode, + unsigned long long uid, + unsigned long long gid, + unsigned long long old_uid, + unsigned long long old_gid, + bool use_bpf_d_path) { + struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); + if (event == NULL) { + m->ringbuffer_full++; + return; + } + + event->chown.new.uid = uid; + event->chown.new.gid = gid; + event->chown.old.uid = old_uid; + event->chown.old.gid = old_gid; + + __submit_event(event, m, FILE_ACTIVITY_CHOWN, filename, inode, use_bpf_d_path); +} diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index bb9cfe7c..481c5de8 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -163,3 +163,45 @@ int BPF_PROG(trace_path_chmod, struct path* path, umode_t mode) { return 0; } + +SEC("lsm/path_chown") +int BPF_PROG(trace_path_chown, struct path* path, unsigned long long uid, unsigned long long gid) { + struct metrics_t* m = get_metrics(); + if (m == NULL) { + return 0; + } + + m->path_chown.total++; + + struct bound_path_t* bound_path = path_read(path); + if (bound_path == NULL) { + bpf_printk("Failed to read path"); + m->path_chown.error++; + return 0; + } + + inode_key_t inode_key = inode_to_key(path->dentry->d_inode); + const inode_value_t* inode = inode_get(&inode_key); + + switch (inode_is_monitored(inode)) { + case NOT_MONITORED: + if (!is_monitored(bound_path)) { + m->path_chmod.ignored++; + return 0; + } + break; + + case MONITORED: + break; + } + + struct dentry* d = BPF_CORE_READ(path, dentry); + kuid_t kuid = BPF_CORE_READ(d, d_inode, i_uid); + kgid_t kgid = BPF_CORE_READ(d, d_inode, i_gid); + unsigned long long old_uid = BPF_CORE_READ(&kuid, val); + unsigned long long old_gid = BPF_CORE_READ(&kgid, val); + + submit_owner_event(&m->path_chown, bound_path->path, &inode_key, uid, gid, old_uid, old_gid, true); + + return 0; +} diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index f7100c98..3186cdec 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -48,6 +48,7 @@ typedef enum file_activity_type_t { FILE_ACTIVITY_CREATION, FILE_ACTIVITY_UNLINK, FILE_ACTIVITY_CHMOD, + FILE_ACTIVITY_CHOWN, } file_activity_type_t; struct event_t { @@ -61,6 +62,12 @@ struct event_t { short unsigned int new; short unsigned int old; } chmod; + struct { + struct { + unsigned int uid; + unsigned int gid; + } old, new; + } chown; }; }; @@ -91,4 +98,5 @@ struct metrics_t { struct metrics_by_hook_t file_open; struct metrics_by_hook_t path_unlink; struct metrics_by_hook_t path_chmod; + struct metrics_by_hook_t path_chown; }; From bbabdca8b48bb8a4090f1ab3592033109d238dcd Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Tue, 18 Nov 2025 15:42:10 +0100 Subject: [PATCH 3/4] Implement userland missing user/group mapping to string representation --- fact/src/bpf/mod.rs | 3 +- fact/src/event/mod.rs | 66 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/fact/src/bpf/mod.rs b/fact/src/bpf/mod.rs index 631404b5..a6f620d5 100644 --- a/fact/src/bpf/mod.rs +++ b/fact/src/bpf/mod.rs @@ -155,7 +155,8 @@ impl Bpf { fn load_progs(&mut self, btf: &Btf) -> anyhow::Result<()> { self.load_lsm_prog("trace_file_open", "file_open", btf)?; self.load_lsm_prog("trace_path_unlink", "path_unlink", btf)?; - self.load_lsm_prog("trace_path_chmod", "path_chmod", btf) + self.load_lsm_prog("trace_path_chmod", "path_chmod", btf)?; + self.load_lsm_prog("trace_path_chown", "path_chown", btf) } fn attach_progs(&mut self) -> anyhow::Result<()> { diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 3b637c90..a9b2ebc5 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -68,6 +68,7 @@ impl Event { FileData::Creation(data) => &data.inode, FileData::Unlink(data) => &data.inode, FileData::Chmod(data) => &data.file.inode, + FileData::Chown(data) => &data.file.inode, } } @@ -77,6 +78,7 @@ impl Event { FileData::Creation(data) => data.host_file = host_path, FileData::Unlink(data) => data.host_file = host_path, FileData::Chmod(data) => data.file.host_file = host_path, + FileData::Chown(data) => data.file.host_file = host_path, } } } @@ -131,6 +133,7 @@ pub enum FileData { Creation(BaseFileData), Unlink(BaseFileData), Chmod(ChmodFileData), + Chown(ChownFileData), } impl FileData { @@ -153,6 +156,16 @@ impl FileData { }; FileData::Chmod(data) } + file_activity_type_t::FILE_ACTIVITY_CHOWN => { + let data = ChownFileData { + file: inner, + new_uid: unsafe { extra_data.chown.new.uid }, + new_gid: unsafe { extra_data.chown.new.gid }, + old_uid: unsafe { extra_data.chown.old.uid }, + old_gid: unsafe { extra_data.chown.old.gid }, + }; + FileData::Chown(data) + } invalid => unreachable!("Invalid event type: {invalid:?}"), }; @@ -182,6 +195,10 @@ impl From for fact_api::file_activity::File { let f_act = fact_api::FilePermissionChange::from(event); fact_api::file_activity::File::Permission(f_act) } + FileData::Chown(event) => { + let f_act = fact_api::FileOwnershipChange::from(event); + fact_api::file_activity::File::Ownership(f_act) + } } } } @@ -271,3 +288,52 @@ impl From for fact_api::FilePermissionChange { } } } + +#[derive(Debug, Clone, Serialize)] +pub struct ChownFileData { + file: BaseFileData, + new_uid: u32, + new_gid: u32, + old_uid: u32, + old_gid: u32, +} + +impl ChownFileData { + pub fn new( + filename: [c_char; PATH_MAX as usize], + inode: inode_key_t, + new_uid: u32, + new_gid: u32, + old_uid: u32, + old_gid: u32, + ) -> anyhow::Result { + let file = BaseFileData::new(filename, inode)?; + + Ok(ChownFileData { + file, + new_uid, + new_gid, + old_uid, + old_gid, + }) + } +} +impl From for fact_api::FileOwnershipChange { + fn from(value: ChownFileData) -> Self { + let ChownFileData { + file, + new_uid, + new_gid, + old_uid: _, + old_gid: _, + } = value; + let activity = fact_api::FileActivityBase::from(file); + fact_api::FileOwnershipChange { + activity: Some(activity), + uid: new_uid, + gid: new_gid, + username: "".to_string(), + group: "".to_string(), + } + } +} From 223faed5d16cd9ed4c034db3ebd088f61914fa14 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Thu, 18 Dec 2025 11:57:14 +0100 Subject: [PATCH 4/4] [chmod] use custom d_path --- fact-ebpf/src/bpf/main.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 481c5de8..d8e5d934 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -159,7 +159,7 @@ int BPF_PROG(trace_path_chmod, struct path* path, umode_t mode) { } umode_t old_mode = BPF_CORE_READ(path, dentry, d_inode, i_mode); - submit_mode_event(&m->path_chmod, bound_path->path, &inode_key, mode, old_mode, true); + submit_mode_event(&m->path_chmod, bound_path->path, &inode_key, mode, old_mode, false); return 0; }