Skip to content

Commit 17d94b4

Browse files
authored
ROX-30296: track POSIX ACL changes via inode_set_acl LSM hook (#878)
1 parent 0d7a73d commit 17d94b4

16 files changed

Lines changed: 748 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ possible include a PR number for easier tracking.
1111
* ROX-34502: reload mTLS certificates on each gRPC connection attempt (#788)
1212
* chore: add formatting and linting to integration test code (#783, #784)
1313
* feat: add code coverage with cargo-llvm-cov and Codecov upload (#745)
14+
* ROX-30296: adds ACL tracking via inode_set_acl LSM hook (#878)
1415

1516
## 0.3.0
1617

fact-ebpf/src/bpf/checks.c

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ int BPF_PROG(check_path_unlink_supports_bpf_d_path, struct path* dir, struct den
1313
bpf_printk("dir: %s", p->path);
1414
return 0;
1515
}
16+
17+
SEC("lsm/inode_set_acl")
18+
int BPF_PROG(check_inode_set_acl, struct mnt_idmap* idmap, struct dentry* dentry, const char* acl_name,
19+
struct posix_acl* kacl) {
20+
return 0;
21+
}

fact-ebpf/src/bpf/events.h

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,55 @@ __always_inline static void submit_xattr_event(struct submit_event_args_t* args,
160160

161161
__submit_event(args, false);
162162
}
163+
164+
__always_inline static void submit_acl_event(struct submit_event_args_t* args,
165+
const char* acl_name,
166+
struct posix_acl* kacl) {
167+
if (!reserve_event(args)) {
168+
return;
169+
}
170+
171+
args->event->type = FILE_ACTIVITY_ACL_SET;
172+
173+
// Determine ACL type from the xattr name.
174+
// "system.posix_acl_access" vs "system.posix_acl_default". name_buf
175+
// only needs to hold the longer of the two names, 25 bytes including
176+
// the null terminator, rounded up to the next power of two for
177+
// alignment.
178+
char name_buf[32] = {0};
179+
long name_len = bpf_probe_read_kernel_str(name_buf, sizeof(name_buf), acl_name);
180+
if (name_len == 25 && __builtin_memcmp(name_buf, "system.posix_acl_default", 24) == 0) {
181+
args->event->acl.acl_type = FACT_ACL_TYPE_DEFAULT;
182+
} else {
183+
args->event->acl.acl_type = FACT_ACL_TYPE_ACCESS;
184+
}
185+
186+
if (kacl != NULL) {
187+
unsigned int count = 0;
188+
bpf_probe_read_kernel(&count, sizeof(count), &kacl->a_count);
189+
if (count > FACT_MAX_ACL_ENTRIES) {
190+
count = FACT_MAX_ACL_ENTRIES;
191+
}
192+
args->event->acl.count = count;
193+
194+
for (unsigned int i = 0; i < FACT_MAX_ACL_ENTRIES && i < count; i++) {
195+
struct posix_acl_entry entry = {0};
196+
bpf_probe_read_kernel(&entry, sizeof(entry), &kacl->a_entries[i]);
197+
args->event->acl.entries[i].e_tag = (acl_tag_t)entry.e_tag;
198+
args->event->acl.entries[i].e_perm = entry.e_perm;
199+
// e_uid is only meaningful for USER/GROUP entries; the kernel
200+
// leaves it unset for USER_OBJ/GROUP_OBJ/MASK/OTHER, so we must
201+
// not read it for those tags.
202+
if (entry.e_tag == FACT_ACL_TAG_USER || entry.e_tag == FACT_ACL_TAG_GROUP) {
203+
args->event->acl.entries[i].e_id = entry.e_uid.val;
204+
} else {
205+
args->event->acl.entries[i].e_id = ACL_UNDEFINED_ID;
206+
}
207+
}
208+
} else {
209+
args->event->acl.count = 0;
210+
}
211+
212+
// inode_set_acl does not support bpf_d_path (no struct path available)
213+
__submit_event(args, false);
214+
}

fact-ebpf/src/bpf/main.c

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,31 @@ int BPF_PROG(trace_inode_removexattr, struct mnt_idmap* idmap, struct dentry* de
431431
return handle_xattr(&m->inode_removexattr, dentry, name, FILE_ACTIVITY_REMOVEXATTR);
432432
}
433433

434+
SEC("lsm/inode_set_acl")
435+
int BPF_PROG(trace_inode_set_acl, struct mnt_idmap* idmap, struct dentry* dentry,
436+
const char* acl_name, struct posix_acl* kacl) {
437+
struct metrics_t* m = get_metrics();
438+
if (m == NULL) {
439+
return 0;
440+
}
441+
struct submit_event_args_t args = {.metrics = &m->inode_set_acl};
442+
443+
args.metrics->total++;
444+
445+
args.inode = inode_to_key(dentry->d_inode);
446+
args.parent_inode = inode_to_key(BPF_CORE_READ(dentry, d_parent, d_inode));
447+
448+
args.monitored = inode_is_monitored(inode_get(&args.inode), inode_get(&args.parent_inode));
449+
450+
if (args.monitored == NOT_MONITORED) {
451+
args.metrics->ignored++;
452+
return 0;
453+
}
454+
455+
submit_acl_event(&args, acl_name, kacl);
456+
return 0;
457+
}
458+
434459
SEC("lsm/path_rmdir")
435460
int BPF_PROG(trace_path_rmdir, struct path* dir, struct dentry* dentry) {
436461
struct metrics_t* m = get_metrics();

fact-ebpf/src/bpf/types.h

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,50 @@ typedef enum monitored_t {
5858
// For the time being we just keep a char.
5959
typedef char inode_value_t;
6060

61+
// Generic xattr values are capped at XATTR_SIZE_MAX (64KiB), which bounds
62+
// a POSIX ACL xattr (4 byte header + 8 bytes per entry) to ~8191 entries
63+
// in theory:
64+
// https://github.com/torvalds/linux/blob/d2c9a99135da931377240942d44f3dea104cedb8/include/uapi/linux/limits.h#L16
65+
// In practice, individual filesystems impose much lower limits tied to
66+
// their own xattr/block storage (e.g. ext4 caps out in the low hundreds
67+
// for a typical 4K xattr block). 32 comfortably covers real-world ACLs
68+
// (a handful of named users/groups plus the standard entries); raise it
69+
// if we ever see it hit in practice.
70+
#define FACT_MAX_ACL_ENTRIES 32
71+
72+
// Sentinel used by the kernel for ACL entries that don't carry a uid/gid
73+
// (USER_OBJ, GROUP_OBJ, MASK, OTHER). Kernel defines this as (-1), which
74+
// is 0xFFFFFFFF once read as the unsigned e_id we store it in.
75+
// https://github.com/torvalds/linux/blob/d2c9a99135da931377240942d44f3dea104cedb8/include/uapi/linux/posix_acl.h#L21
76+
#define ACL_UNDEFINED_ID 0xFFFFFFFF
77+
78+
// ACL type, matching the xattr name the change came in on:
79+
// "system.posix_acl_access" vs "system.posix_acl_default". These are the
80+
// only two POSIX ACL xattrs the kernel supports, so a small enum is
81+
// sufficient here rather than keeping the full xattr name around.
82+
typedef enum acl_type_t {
83+
FACT_ACL_TYPE_ACCESS = 0,
84+
FACT_ACL_TYPE_DEFAULT = 1,
85+
} acl_type_t;
86+
87+
// Mirrors the kernel's ACL tag bit values so we can encode/compare
88+
// against them without magic numbers, both here and on the Rust side.
89+
// https://github.com/torvalds/linux/blob/d2c9a99135da931377240942d44f3dea104cedb8/include/uapi/linux/posix_acl.h#L28-L33
90+
typedef enum acl_tag_t {
91+
FACT_ACL_TAG_USER_OBJ = 0x01,
92+
FACT_ACL_TAG_USER = 0x02,
93+
FACT_ACL_TAG_GROUP_OBJ = 0x04,
94+
FACT_ACL_TAG_GROUP = 0x08,
95+
FACT_ACL_TAG_MASK = 0x10,
96+
FACT_ACL_TAG_OTHER = 0x20,
97+
} acl_tag_t;
98+
99+
struct acl_entry_t {
100+
acl_tag_t e_tag;
101+
unsigned short e_perm;
102+
unsigned int e_id;
103+
};
104+
61105
typedef enum file_activity_type_t {
62106
FILE_ACTIVITY_INIT = -1,
63107
FILE_ACTIVITY_OPEN = 0,
@@ -70,6 +114,7 @@ typedef enum file_activity_type_t {
70114
DIR_ACTIVITY_UNLINK,
71115
FILE_ACTIVITY_SETXATTR,
72116
FILE_ACTIVITY_REMOVEXATTR,
117+
FILE_ACTIVITY_ACL_SET,
73118
} file_activity_type_t;
74119

75120
struct event_t {
@@ -99,6 +144,11 @@ struct event_t {
99144
struct {
100145
char name[XATTR_NAME_MAX_LEN];
101146
} xattr;
147+
struct {
148+
unsigned int count;
149+
acl_type_t acl_type;
150+
struct acl_entry_t entries[FACT_MAX_ACL_ENTRIES];
151+
} acl;
102152
};
103153
};
104154

@@ -143,4 +193,5 @@ struct metrics_t {
143193
struct metrics_by_hook_t path_rmdir;
144194
struct metrics_by_hook_t inode_setxattr;
145195
struct metrics_by_hook_t inode_removexattr;
196+
struct metrics_by_hook_t inode_set_acl;
146197
};

fact-ebpf/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ impl_metrics_t!(
155155
path_mkdir,
156156
path_rmdir,
157157
d_instantiate,
158+
inode_setxattr,
159+
inode_removexattr,
160+
inode_set_acl,
158161
);
159162

160163
unsafe impl Pod for metrics_t {}

fact/src/bpf/checks.rs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use log::debug;
44

55
pub(super) struct Checks {
66
pub(super) path_hooks_support_bpf_d_path: bool,
7+
pub(super) supports_inode_set_acl: bool,
78
}
89

910
impl Checks {
@@ -12,15 +13,31 @@ impl Checks {
1213
.load(fact_ebpf::CHECKS_OBJ)
1314
.context("Failed to load checks.o")?;
1415

15-
let prog = obj
16-
.program_mut("check_path_unlink_supports_bpf_d_path")
17-
.context("Failed to find 'check_path_unlink_supports_bpf_d_path' program")?;
18-
let prog: &mut Lsm = prog.try_into()?;
19-
let path_hooks_support_bpf_d_path = prog.load("path_unlink", btf).is_ok();
20-
debug!("path_unlink_supports_bpf_d_path: {path_hooks_support_bpf_d_path}");
16+
let path_hooks_support_bpf_d_path = Self::probe_hook(
17+
&mut obj,
18+
"check_path_unlink_supports_bpf_d_path",
19+
"path_unlink",
20+
btf,
21+
);
22+
debug!("path_hooks_support_bpf_d_path: {path_hooks_support_bpf_d_path}");
23+
24+
let supports_inode_set_acl =
25+
Self::probe_hook(&mut obj, "check_inode_set_acl", "inode_set_acl", btf);
26+
debug!("supports_inode_set_acl: {supports_inode_set_acl}");
2127

2228
Ok(Checks {
2329
path_hooks_support_bpf_d_path,
30+
supports_inode_set_acl,
2431
})
2532
}
33+
34+
fn probe_hook(obj: &mut aya::Ebpf, prog_name: &str, hook: &str, btf: &Btf) -> bool {
35+
let Some(prog) = obj.program_mut(prog_name) else {
36+
return false;
37+
};
38+
let Ok(prog): Result<&mut Lsm, _> = prog.try_into() else {
39+
return false;
40+
};
41+
prog.load(hook, btf).is_ok()
42+
}
2643
}

fact/src/bpf/mod.rs

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const RINGBUFFER_NAME: &str = "rb";
2626

2727
pub struct Bpf {
2828
obj: Ebpf,
29+
checks: Checks,
2930

3031
tx: mpsc::Sender<Event>,
3132

@@ -64,6 +65,7 @@ impl Bpf {
6465
let paths = Vec::new();
6566
let mut bpf = Bpf {
6667
obj,
68+
checks,
6769
tx,
6870
paths,
6971
paths_config,
@@ -178,28 +180,40 @@ impl Bpf {
178180
let Some(hook) = name.strip_prefix("trace_") else {
179181
bail!("Invalid hook name: {name}");
180182
};
183+
184+
// Skip hooks that the kernel doesn't support
185+
if hook == "inode_set_acl" && !self.checks.supports_inode_set_acl {
186+
info!("Skipping {hook}: not supported on this kernel");
187+
continue;
188+
}
189+
181190
match prog {
182191
Program::Lsm(prog) => prog.load(hook, btf)?,
183192
u => unimplemented!("{u:?}"),
184-
}
193+
};
185194
}
186195
Ok(())
187196
}
188197

189-
/// Attaches all BPF programs. If any attach fails, all previously
190-
/// attached programs are automatically detached via drop.
198+
/// Attaches all loaded BPF programs. Programs that were not loaded
199+
/// (e.g. optional hooks on unsupported kernels) are skipped.
200+
/// If any attach fails, programs that were already attached during
201+
/// this call remain attached (they are not rolled back); callers
202+
/// should treat an `Err` here as fatal.
191203
fn attach_progs(&mut self) -> anyhow::Result<()> {
192-
self.links = self
193-
.obj
194-
.programs_mut()
195-
.map(|(_, prog)| match prog {
204+
self.links.clear();
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)