Skip to content

Commit 13255ef

Browse files
aymericDDclaude
andcommitted
feat(disk-failure): cgroupv2 filter and relative-path disruption
Adds two capabilities to the eBPF disk-failure injector: 1. Cgroupv2-based process filtering Expose CgroupV2Path() on cgroup.Manager (returns the absolute cgroupv2 unified hierarchy path on v2, empty string on v1). The injector passes this path as -cgroup-path to bpf-disk-failure, which populates a BPF_MAP_TYPE_CGROUP_ARRAY and uses bpf_current_task_under_cgroup() to match the target container and all its sub-cgroups (e.g. kubectl exec creates containerd exec-<id> sub-cgroups that the old PID filter missed). Gate disk-failure on HaveCgroupArrayMapType so load failures on kernels that lack it surface as a clear error. 2. Relative-path disruption (cd /dir && cat file) Previously only absolute openat paths were matched. Now the injector stats the filter path inside the container via /proc/<pid>/root and passes two inode/device pairs to the BPF program: - filter_dir_inode/filter_dir_dev: inode of the parent directory for basename-prefix matching ("cwd=/parent && rtk read dir/file") - filter_dir_inode2/filter_dir_dev2: inode of the path itself when it is a directory, for exact-CWD matching ("cwd=/dir && rtk read file") The BPF check_relative_path helper runs both checks and only fires when dirfd == AT_FDCWD, avoiding false matches on non-CWD dirfds. Device IDs are passed in kernel MKDEV encoding (major<<20|minor) rather than the glibc encode_dev format to match super_block.s_dev. ProcRoot is injected via config so tests point at a nonexistent path and remain deterministic across macOS and Linux CI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ce06beb commit 13255ef

10 files changed

Lines changed: 427 additions & 74 deletions

File tree

cgroup/manager.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ type Manager interface {
3030
IsCgroupV2() bool
3131
// RelativePath returns the controller relative path
3232
RelativePath(controller string) string
33+
// CgroupV2Path returns the absolute cgroupv2 unified hierarchy path for the cgroup,
34+
// or empty string on cgroupv1. Used to populate BPF_MAP_TYPE_CGROUP_ARRAY for
35+
// bpf_current_task_under_cgroup() ancestor checking (covers kubectl exec sub-cgroups).
36+
CgroupV2Path() string
3337
}
3438

3539
type instCGroupManager interface {
@@ -131,3 +135,12 @@ func (m manager) IsCgroupV2() bool {
131135
func (m manager) RelativePath(controller string) string {
132136
return strings.TrimPrefix(m.cgroups.Path(controller), m.mountPath)
133137
}
138+
139+
// CgroupV2Path returns the absolute cgroupv2 unified hierarchy path, or "" on cgroupv1.
140+
func (m manager) CgroupV2Path() string {
141+
if !m.isV2 {
142+
return ""
143+
}
144+
145+
return m.cgroups.Path("")
146+
}

cgroup/manager_mock.go

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ebpf/const-arm.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@
88

99
package ebpf
1010

11-
const SysOpenat = "__arm64_sys_openat"
11+
// do_sys_openat2 is used instead of __arm64_sys_openat because only do_sys_openat2
12+
// is tagged ALLOW_ERROR_INJECTION in the kernel (fs/open.c), which is required for
13+
// bpf_override_return to actually override the syscall return value.
14+
const SysOpenat = "do_sys_openat2"

ebpf/const-x64.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@
88

99
package ebpf
1010

11-
const SysOpenat = "__x64_sys_openat"
11+
// do_sys_openat2 is used instead of __x64_sys_openat because only do_sys_openat2
12+
// is tagged ALLOW_ERROR_INJECTION in the kernel (fs/open.c), which is required for
13+
// bpf_override_return to actually override the syscall return value.
14+
const SysOpenat = "do_sys_openat2"

ebpf/disk-failure/injection.bpf.c

Lines changed: 149 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,33 @@
88

99
const volatile pid_t target_pid = 0;
1010
const volatile pid_t exclude_pid;
11+
const volatile int use_cgroup_filter = 0;
1112
const volatile char filter_path[61];
13+
// Inode of filter_path's parent directory. When non-zero, enables filtering of
14+
// relative openat calls by comparing the process CWD inode against this value.
15+
// Works correctly inside containers because Kubernetes volumes are bind-mounted:
16+
// the host inode and the in-container inode are identical.
17+
const volatile u64 filter_dir_inode = 0;
18+
// Device ID paired with filter_dir_inode. Inodes are only unique within a device,
19+
// so checking both prevents false matches on bind-mounted or multi-filesystem targets.
20+
const volatile u32 filter_dir_dev = 0;
21+
// Second inode/device pair: set when filter_path is itself a directory. When the
22+
// CWD matches this inode/device, any relative open (except ".." escapes) is
23+
// in-scope. This handles "cd /mnt/data && cat file" alongside the parent+basename
24+
// case covered by filter_dir_inode (i.e. "cwd=/mnt && cat data/file").
25+
const volatile u64 filter_dir_inode2 = 0;
26+
const volatile u32 filter_dir_dev2 = 0;
27+
28+
// Populated from userspace with the container's cgroupv2 directory fd.
29+
// bpf_current_task_under_cgroup() matches the process itself AND any sub-cgroup
30+
// (e.g. containerd exec-<id> sub-cgroups created by kubectl exec).
31+
struct {
32+
__uint(type, BPF_MAP_TYPE_CGROUP_ARRAY);
33+
__uint(max_entries, 1);
34+
__type(key, u32);
35+
__type(value, u32);
36+
} target_cgroup SEC(".maps");
37+
1238
const volatile pid_t exit_code = ENOENT;
1339
const volatile int probability = 100;
1440

@@ -18,7 +44,7 @@ unsigned int disruptedHits = 0;
1844
struct data_t {
1945
u32 ppid;
2046
u32 pid;
21-
u32 tid;
47+
u32 tid;
2248
u32 id;
2349
char comm[100];
2450
};
@@ -30,12 +56,111 @@ struct {
3056
__type(value, u32);
3157
} events SEC(".maps");
3258

33-
SEC("kprobe/sys_openat")
59+
// AT_FDCWD sentinel value; openat resolves relative paths against the CWD only
60+
// when this value is passed as dirfd.
61+
#ifndef AT_FDCWD
62+
#define AT_FDCWD -100
63+
#endif
64+
65+
// check_basename_prefix returns 1 if rel_buf starts with the basename suffix of
66+
// filter_path (the part after the last '/').
67+
static __always_inline int check_basename_prefix(const char *rel_buf)
68+
{
69+
int last_slash = 0;
70+
for (int i = 0; i < 60; i++) {
71+
if (filter_path[i] == '\0') break;
72+
if (filter_path[i] == '/') last_slash = i;
73+
}
74+
for (int i = 0; i < 60; i++) {
75+
int fi = last_slash + 1 + i;
76+
if (fi >= 61) break;
77+
if (filter_path[fi & 0x3f] == '\0') break;
78+
if (rel_buf[i] != filter_path[fi & 0x3f]) return 0;
79+
}
80+
return 1;
81+
}
82+
83+
// check_relative_path returns 1 if a relative openat call should be disrupted.
84+
// Two checks are attempted in order:
85+
// 1. CWD inode == filter_dir_inode (parent of filter_path) AND rel_path starts
86+
// with the basename — handles "cwd=/parent && openat(AT_FDCWD, "dir/file")".
87+
// 2. CWD inode == filter_dir_inode2 (filter_path itself, set when it is a dir)
88+
// AND rel_path does not start with ".." — handles "cwd=/dir && openat(AT_FDCWD, "file")".
89+
// Only called when dirfd == AT_FDCWD; other dirfds are not supported.
90+
// Using the CWD inode rather than walking the dentry chain works inside containers
91+
// because Kubernetes volumes are bind-mounted: host inode == in-container inode.
92+
static int check_relative_path(int dirfd, const char *rel_path)
93+
{
94+
if (dirfd != AT_FDCWD) return 0;
95+
if (filter_dir_inode == 0 && filter_dir_inode2 == 0) return 0;
96+
97+
// Read CWD inode and device via task->fs->pwd.dentry->d_inode.
98+
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
99+
struct fs_struct *fs_ptr;
100+
bpf_probe_read_kernel(&fs_ptr, sizeof(fs_ptr), &task->fs);
101+
struct path pwd;
102+
bpf_probe_read_kernel(&pwd, sizeof(pwd), &fs_ptr->pwd);
103+
struct inode *inode_ptr;
104+
bpf_probe_read_kernel(&inode_ptr, sizeof(inode_ptr), &pwd.dentry->d_inode);
105+
u64 ino = 0;
106+
bpf_probe_read_kernel(&ino, sizeof(ino), &inode_ptr->i_ino);
107+
struct super_block *sb_ptr = NULL;
108+
bpf_probe_read_kernel(&sb_ptr, sizeof(sb_ptr), &inode_ptr->i_sb);
109+
u32 dev = 0;
110+
bpf_probe_read_kernel(&dev, sizeof(dev), &sb_ptr->s_dev);
111+
112+
char rel_buf[62] = {};
113+
bpf_probe_read(rel_buf, sizeof(rel_buf) - 1, rel_path);
114+
115+
// Check 1: parent inode + basename prefix.
116+
if (filter_dir_inode != 0 && ino == filter_dir_inode &&
117+
(filter_dir_dev == 0 || dev == filter_dir_dev) &&
118+
check_basename_prefix(rel_buf)) return 1;
119+
120+
// Check 2: exact directory inode — match any file that does not escape via "..".
121+
if (filter_dir_inode2 != 0 && ino == filter_dir_inode2 &&
122+
(filter_dir_dev2 == 0 || dev == filter_dir_dev2) &&
123+
!(rel_buf[0] == '.' && rel_buf[1] == '.')) return 1;
124+
125+
return 0;
126+
}
127+
128+
// do_filter_by_process returns 1 if the current process should be excluded (filtered out),
129+
// 0 if it should be disrupted.
130+
static __always_inline int do_filter_by_process(u32 pid, u32 ppid)
131+
{
132+
if (use_cgroup_filter) {
133+
return bpf_current_task_under_cgroup(&target_cgroup, 0) != 1 ? 1 : 0;
134+
} else if (target_pid != 0) {
135+
return (ppid != target_pid && pid != target_pid) ? 1 : 0;
136+
}
137+
return 0;
138+
}
139+
140+
// do_probability_check returns 1 if the event should be skipped due to probability sampling.
141+
static __always_inline int do_probability_check()
142+
{
143+
if (probability == 100) return 0;
144+
if (hits != 0) {
145+
unsigned long long scaled = disruptedHits * 100;
146+
if ((scaled / hits) > probability) {
147+
hits++;
148+
return 1;
149+
}
150+
}
151+
hits++;
152+
disruptedHits++;
153+
return 0;
154+
}
155+
156+
// Kprobe on do_sys_openat2 (not the arch-specific sys_openat wrapper) because
157+
// only do_sys_openat2 is tagged ALLOW_ERROR_INJECTION in fs/open.c, which is
158+
// required for bpf_override_return to actually modify the return value.
159+
SEC("kprobe/do_sys_openat2")
34160
int injection_disk_failure(struct pt_regs *ctx)
35161
{
36162
struct data_t data = {};
37163

38-
// Get data of the current process
39164
u32 ppid = 0;
40165
u32 pid = bpf_get_current_pid_tgid();
41166
if (pid == exclude_pid) {
@@ -45,62 +170,44 @@ int injection_disk_failure(struct pt_regs *ctx)
45170
u32 gid = bpf_get_current_uid_gid();
46171

47172
if (pid != 1) {
48-
// Get parent pid
173+
// Get parent pid (needed for cgroupv1 PID filter and exclude_pid check below)
49174
struct task_struct *task;
50175
struct task_struct *real_parent;
51176
task = (struct task_struct *)bpf_get_current_task();
52177
bpf_probe_read(&real_parent, sizeof(real_parent), &task->real_parent);
53178
bpf_probe_read(&ppid, sizeof(ppid), &real_parent->tgid);
54-
55-
// Allow only children and parent process.
56-
if (target_pid != 0 && ppid != target_pid && pid != target_pid) {
57-
return 0;
58-
}
59179
}
60180

181+
if (do_filter_by_process(pid, ppid)) return 0;
182+
61183
if (ppid == exclude_pid || tid == exclude_pid) {
62184
return 0;
63185
}
64186

65-
// Exclude this part of code if the following variables are not defined.
66-
// It allows the go program to compile without error.
67-
#if defined(__TARGET_ARCH_arm64) || defined(__TARGET_ARCH_x86)
68-
// Allow only file with the desired prefix.
69-
struct pt_regs *real_regs = (struct pt_regs *)PT_REGS_PARM1(ctx);
70-
char *path = (char *)PT_REGS_PARM2_CORE(real_regs);
187+
// do_sys_openat2(int dfd, const char __user *filename, struct open_how *how)
188+
int dirfd = (int)PT_REGS_PARM1_CORE(ctx);
189+
char *path = (char *)PT_REGS_PARM2_CORE(ctx);
71190
char cmp_path_name[62];
72191
bpf_probe_read(&cmp_path_name, sizeof(cmp_path_name), path);
73-
char cmp_expected_path[62];
74-
bpf_probe_read(cmp_expected_path, sizeof(cmp_expected_path), (const void *)filter_path);
75-
int filter_len = (int) (sizeof(filter_path) / sizeof(filter_path[0])) - 1;
76-
77-
if (filter_len > 62) {
78-
return 0;
79-
}
80-
81-
for (int i = 0; i < filter_len; ++i) {
82-
if (cmp_expected_path[i] == NULL)
83-
break;
84-
if (cmp_path_name[i] != cmp_expected_path[i])
85-
return 0;
86-
}
87-
#endif
88192

89-
if (probability != 100) {
90-
if (hits != 0) {
91-
unsigned long long scaled_disruptedHits = disruptedHits * 100;
92-
unsigned long long scaled_hits = hits;
93-
94-
if ((scaled_disruptedHits / scaled_hits) > probability) {
95-
hits++;
96-
return 0;
97-
}
193+
if (cmp_path_name[0] == '/') {
194+
// Absolute path: compare raw path argument against filter prefix directly.
195+
char cmp_expected_path[62];
196+
bpf_probe_read(cmp_expected_path, sizeof(cmp_expected_path), (const void *)filter_path);
197+
int filter_len = (int)(sizeof(filter_path) / sizeof(filter_path[0])) - 1;
198+
if (filter_len > 62) return 0;
199+
for (int i = 0; i < filter_len; ++i) {
200+
if (cmp_expected_path[i] == NULL) break;
201+
if (cmp_path_name[i] != cmp_expected_path[i]) return 0;
98202
}
99-
100-
hits++;
101-
disruptedHits++;
203+
} else {
204+
// Relative path: compare CWD inode against the filter. dirfd is passed so
205+
// that opens with a non-AT_FDCWD dirfd are skipped (CWD is irrelevant there).
206+
if (!check_relative_path(dirfd, path)) return 0;
102207
}
103208

209+
if (do_probability_check()) return 0;
210+
104211
data.ppid = ppid;
105212
data.pid = pid;
106213
data.tid = tid;
@@ -117,4 +224,3 @@ int injection_disk_failure(struct pt_regs *ctx)
117224

118225
return 0;
119226
}
120-

0 commit comments

Comments
 (0)