Skip to content

Commit e760024

Browse files
committed
ROX-30296: track POSIX ACL changes via inode_set_acl LSM hook
1 parent d4bf87c commit e760024

12 files changed

Lines changed: 689 additions & 37 deletions

File tree

fact-ebpf/src/bpf/bound_path.h

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,29 @@ __always_inline static struct bound_path_t* _path_read(struct path* path, bound_
3838
return bound_path;
3939
}
4040

41+
/**
42+
* Read a filesystem-relative path from a bare dentry.
43+
*
44+
* This is for hooks that only provide a struct dentry without a
45+
* struct path (e.g. inode_set_acl). The resulting path can be used
46+
* for LPM trie matching and inode monitoring checks.
47+
*/
48+
__always_inline static struct bound_path_t* dentry_read(struct dentry* dentry) {
49+
struct bound_path_t* bound_path = get_bound_path(BOUND_PATH_MAIN);
50+
if (bound_path == NULL) {
51+
return NULL;
52+
}
53+
54+
bound_path->len = __d_path_from_dentry(dentry, bound_path->path, PATH_MAX);
55+
if (bound_path->len <= 0) {
56+
return NULL;
57+
}
58+
59+
bound_path->len = PATH_LEN_CLAMP(bound_path->len);
60+
61+
return bound_path;
62+
}
63+
4164
__always_inline static struct bound_path_t* path_read_unchecked(struct path* path) {
4265
return _path_read(path, BOUND_PATH_MAIN, true);
4366
}

fact-ebpf/src/bpf/d_path.h

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@
2424
*/
2525
#define PATH_LEN_CLAMP(len) ((len) & PATH_MAX_MASK)
2626

27+
// Context for __d_path_inner.
28+
//
29+
// Supports two modes:
30+
// Full path mode: mnt and root must both be set. Crosses mount
31+
// boundaries and terminates at the process root.
32+
// Dentry-only mode: mnt and root must both be NULL. Walks the
33+
// dentry chain to the filesystem root, producing a
34+
// filesystem-relative path.
2735
struct d_path_ctx {
2836
struct helper_t* helper;
2937
struct path* root;
@@ -38,39 +46,49 @@ static long __d_path_inner(uint32_t index, void* _ctx) {
3846
struct d_path_ctx* ctx = (struct d_path_ctx*)_ctx;
3947
struct dentry* dentry = ctx->dentry;
4048
struct dentry* parent = BPF_CORE_READ(dentry, d_parent);
41-
struct mount* mnt = ctx->mnt;
42-
struct dentry* mnt_root = BPF_CORE_READ(mnt, mnt.mnt_root);
4349

44-
if (dentry == ctx->root->dentry && &mnt->mnt == ctx->root->mnt) {
45-
// Found the root of the process, we are done
46-
ctx->success = true;
47-
return 1;
48-
}
50+
if (ctx->mnt != NULL) {
51+
// Full path mode: we have mount context and can cross mount
52+
// boundaries and detect the process root.
53+
struct mount* mnt = ctx->mnt;
54+
struct dentry* mnt_root = BPF_CORE_READ(mnt, mnt.mnt_root);
4955

50-
if (dentry == mnt_root) {
51-
struct mount* m = BPF_CORE_READ(mnt, mnt_parent);
52-
if (m != mnt) {
53-
// Current dentry is a mount root different to the previous one we
54-
// had (to prevent looping), switch over to that mount position
55-
// and keep walking up the path.
56-
ctx->dentry = BPF_CORE_READ(mnt, mnt_mountpoint);
57-
ctx->mnt = m;
58-
return 0;
56+
if (dentry == ctx->root->dentry && &mnt->mnt == ctx->root->mnt) {
57+
// Found the root of the process, we are done
58+
ctx->success = true;
59+
return 1;
5960
}
6061

61-
// Ended up in a global root, the path might need re-processing or
62-
// the root is not attached yet, we are not getting a better path,
63-
// so we assume we are correct and stop iterating.
64-
ctx->success = true;
65-
return 1;
62+
if (dentry == mnt_root) {
63+
struct mount* m = BPF_CORE_READ(mnt, mnt_parent);
64+
if (m != mnt) {
65+
// Current dentry is a mount root different to the previous one we
66+
// had (to prevent looping), switch over to that mount position
67+
// and keep walking up the path.
68+
ctx->dentry = BPF_CORE_READ(mnt, mnt_mountpoint);
69+
ctx->mnt = m;
70+
return 0;
71+
}
72+
73+
// Ended up in a global root, the path might need re-processing or
74+
// the root is not attached yet, we are not getting a better path,
75+
// so we assume we are correct and stop iterating.
76+
ctx->success = true;
77+
return 1;
78+
}
6679
}
6780

6881
if (dentry == parent) {
69-
// We escaped the mounts and ended up at (most likely) the root of
70-
// the device, the path we formed will be wrong.
82+
// Reached the root of the filesystem's dentry tree.
83+
//
84+
// In full path mode (mnt != NULL) this means we escaped the mounts
85+
// and the path may be wrong due to a race condition.
7186
//
72-
// This may happen in race conditions where some dentries go away
73-
// while we are iterating.
87+
// In dentry-only mode (mnt == NULL) this is the expected
88+
// termination: we've reached the filesystem root and have a
89+
// filesystem-relative path. This is correct for overlayfs
90+
// (containers) and for files on the root filesystem.
91+
ctx->success = (ctx->mnt == NULL);
7492
return 1;
7593
}
7694

@@ -140,6 +158,46 @@ __always_inline static long __d_path(const struct path* path, char* buf, int buf
140158
return buflen - ctx.offset;
141159
}
142160

161+
/**
162+
* Resolve a filesystem-relative path from a bare dentry.
163+
*
164+
* This is used when no struct path is available (e.g. inode_set_acl).
165+
* It walks the dentry chain up to the filesystem root, producing a
166+
* path relative to the filesystem's root dentry. This is correct for
167+
* overlayfs (containers) and for files on the root filesystem. It
168+
* cannot cross mount boundaries, so paths on nested host mounts (e.g.
169+
* a separate /var partition) will be relative to that mount's root.
170+
*/
171+
__always_inline static long __d_path_from_dentry(struct dentry* dentry, char* buf, int buflen) {
172+
if (buflen <= 0) {
173+
return -1;
174+
}
175+
176+
int offset = PATH_LEN_CLAMP(buflen - 1);
177+
struct d_path_ctx ctx = {
178+
.buflen = buflen,
179+
.helper = get_helper(),
180+
.offset = offset,
181+
.mnt = NULL,
182+
.root = NULL,
183+
};
184+
185+
if (ctx.helper == NULL) {
186+
return -1;
187+
}
188+
189+
ctx.helper->buf[offset] = '\0';
190+
ctx.dentry = dentry;
191+
192+
long res = bpf_loop(PATH_MAX, __d_path_inner, &ctx, 0);
193+
if (res <= 0 || !ctx.success) {
194+
return -1;
195+
}
196+
197+
bpf_probe_read_str(buf, buflen, &ctx.helper->buf[PATH_LEN_CLAMP(ctx.offset)]);
198+
return buflen - ctx.offset;
199+
}
200+
143201
__always_inline static long d_path(struct path* path, char* buf, int buflen, bool use_bpf_helper) {
144202
if (use_bpf_helper) {
145203
return bpf_d_path(path, buf, buflen);

fact-ebpf/src/bpf/events.h

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,45 @@ __always_inline static void submit_rmdir_event(struct submit_event_args_t* args)
144144

145145
__submit_event(args, path_hooks_support_bpf_d_path);
146146
}
147+
148+
__always_inline static void submit_acl_event(struct submit_event_args_t* args,
149+
const char* acl_name,
150+
struct posix_acl* kacl) {
151+
if (!reserve_event(args)) {
152+
return;
153+
}
154+
155+
args->event->type = FILE_ACTIVITY_ACL_SET;
156+
157+
// Determine ACL type from the xattr name.
158+
// "system.posix_acl_access" vs "system.posix_acl_default"
159+
char name_buf[32] = {0};
160+
long name_len = bpf_probe_read_kernel_str(name_buf, sizeof(name_buf), acl_name);
161+
if (name_len == 25 && __builtin_memcmp(name_buf, "system.posix_acl_default", 24) == 0) {
162+
args->event->acl.acl_type = FACT_ACL_TYPE_DEFAULT;
163+
} else {
164+
args->event->acl.acl_type = FACT_ACL_TYPE_ACCESS;
165+
}
166+
167+
if (kacl == NULL) {
168+
args->event->acl.count = 0;
169+
} else {
170+
unsigned int count = 0;
171+
bpf_probe_read_kernel(&count, sizeof(count), &kacl->a_count);
172+
if (count > FACT_MAX_ACL_ENTRIES) {
173+
count = FACT_MAX_ACL_ENTRIES;
174+
}
175+
args->event->acl.count = count;
176+
177+
for (unsigned int i = 0; i < FACT_MAX_ACL_ENTRIES && i < count; i++) {
178+
struct posix_acl_entry entry = {0};
179+
bpf_probe_read_kernel(&entry, sizeof(entry), &kacl->a_entries[i]);
180+
args->event->acl.entries[i].e_tag = entry.e_tag;
181+
args->event->acl.entries[i].e_perm = entry.e_perm;
182+
args->event->acl.entries[i].e_id = entry.e_uid.val;
183+
}
184+
}
185+
186+
// inode_set_acl does not support bpf_d_path (no struct path available)
187+
__submit_event(args, false);
188+
}

fact-ebpf/src/bpf/main.c

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,38 @@ int BPF_PROG(trace_d_instantiate, struct dentry* dentry, struct inode* inode) {
389389
return 0;
390390
}
391391

392+
SEC("lsm/inode_set_acl")
393+
int BPF_PROG(trace_inode_set_acl, struct mnt_idmap* idmap, struct dentry* dentry,
394+
const char* acl_name, struct posix_acl* kacl) {
395+
struct metrics_t* m = get_metrics();
396+
if (m == NULL) {
397+
return 0;
398+
}
399+
struct submit_event_args_t args = {.metrics = &m->inode_set_acl};
400+
401+
args.metrics->total++;
402+
403+
struct bound_path_t* bound_path = dentry_read(dentry);
404+
if (bound_path == NULL) {
405+
bpf_printk("Failed to read path from dentry");
406+
args.metrics->error++;
407+
return 0;
408+
}
409+
args.filename = bound_path->path;
410+
411+
struct inode* inode_ptr = BPF_CORE_READ(dentry, d_inode);
412+
args.inode = inode_to_key(inode_ptr);
413+
args.monitored = is_monitored(&args.inode, bound_path, NULL);
414+
415+
if (args.monitored == NOT_MONITORED) {
416+
args.metrics->ignored++;
417+
return 0;
418+
}
419+
420+
submit_acl_event(&args, acl_name, kacl);
421+
return 0;
422+
}
423+
392424
SEC("lsm/path_rmdir")
393425
int BPF_PROG(trace_path_rmdir, struct path* dir, struct dentry* dentry) {
394426
struct metrics_t* m = get_metrics();

fact-ebpf/src/bpf/types.h

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ typedef enum monitored_t {
5454
// For the time being we just keep a char.
5555
typedef char inode_value_t;
5656

57+
#define FACT_MAX_ACL_ENTRIES 32
58+
59+
// ACL type constants matching the xattr names
60+
#define FACT_ACL_TYPE_ACCESS 0
61+
#define FACT_ACL_TYPE_DEFAULT 1
62+
63+
struct acl_entry_t {
64+
short e_tag;
65+
unsigned short e_perm;
66+
unsigned int e_id;
67+
};
68+
5769
typedef enum file_activity_type_t {
5870
FILE_ACTIVITY_INIT = -1,
5971
FILE_ACTIVITY_OPEN = 0,
@@ -64,6 +76,7 @@ typedef enum file_activity_type_t {
6476
FILE_ACTIVITY_RENAME,
6577
DIR_ACTIVITY_CREATION,
6678
DIR_ACTIVITY_UNLINK,
79+
FILE_ACTIVITY_ACL_SET,
6780
} file_activity_type_t;
6881

6982
struct event_t {
@@ -90,6 +103,11 @@ struct event_t {
90103
inode_key_t inode;
91104
monitored_t monitored;
92105
} rename;
106+
struct {
107+
unsigned int count;
108+
unsigned int acl_type;
109+
struct acl_entry_t entries[FACT_MAX_ACL_ENTRIES];
110+
} acl;
93111
};
94112
};
95113

@@ -132,4 +150,5 @@ struct metrics_t {
132150
struct metrics_by_hook_t path_mkdir;
133151
struct metrics_by_hook_t d_instantiate;
134152
struct metrics_by_hook_t path_rmdir;
153+
struct metrics_by_hook_t inode_set_acl;
135154
};

fact-ebpf/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ impl metrics_t {
145145
self.path_mkdir = self.path_mkdir.accumulate(&other.path_mkdir);
146146
self.path_rmdir = self.path_rmdir.accumulate(&other.path_rmdir);
147147
self.d_instantiate = self.d_instantiate.accumulate(&other.d_instantiate);
148+
self.inode_set_acl = self.inode_set_acl.accumulate(&other.inode_set_acl);
148149
self
149150
}
150151
}

fact/src/bpf/mod.rs

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ mod checks;
2424

2525
const RINGBUFFER_NAME: &str = "rb";
2626

27+
/// Hooks that may not be available on all supported kernels. If these
28+
/// fail to load, FACT will log a warning and continue without them.
29+
const OPTIONAL_HOOKS: &[&str] = &["inode_set_acl"];
30+
2731
pub struct Bpf {
2832
obj: Ebpf,
2933

@@ -178,28 +182,38 @@ impl Bpf {
178182
let Some(hook) = name.strip_prefix("trace_") else {
179183
bail!("Invalid hook name: {name}");
180184
};
181-
match prog {
182-
Program::Lsm(prog) => prog.load(hook, btf)?,
185+
let result = match prog {
186+
Program::Lsm(prog) => prog.load(hook, btf),
183187
u => unimplemented!("{u:?}"),
188+
};
189+
if let Err(e) = result {
190+
if OPTIONAL_HOOKS.contains(&hook) {
191+
warn!("Optional hook {hook} not available on this kernel, skipping: {e}");
192+
continue;
193+
}
194+
return Err(e.into());
184195
}
185196
}
186197
Ok(())
187198
}
188199

189-
/// Attaches all BPF programs. If any attach fails, all previously
190-
/// attached programs are automatically detached via drop.
200+
/// Attaches all loaded BPF programs. Programs that were not loaded
201+
/// (e.g. optional hooks on unsupported kernels) are skipped.
202+
/// If any attach fails, all previously attached programs are
203+
/// automatically detached via drop.
191204
fn attach_progs(&mut self) -> anyhow::Result<()> {
192-
self.links = self
193-
.obj
194-
.programs_mut()
195-
.map(|(_, prog)| match prog {
205+
for (_, prog) in self.obj.programs_mut() {
206+
match prog {
196207
Program::Lsm(prog) => {
208+
if prog.fd().is_err() {
209+
continue;
210+
}
197211
let link_id = prog.attach()?;
198-
prog.take_link(link_id)
212+
self.links.push(prog.take_link(link_id)?);
199213
}
200214
u => unimplemented!("{u:?}"),
201-
})
202-
.collect::<Result<_, _>>()?;
215+
}
216+
}
203217
Ok(())
204218
}
205219

0 commit comments

Comments
 (0)