kernel.ftrace_enabled=0 silently kills BPF trampolines (fentry/fexit): attach still succeeds, the hook stops firing with no error, and re-enabling silently restores it with no indication anything happened. This is a regression, not a new gap: BPF trampolines were already protected against this the same way livepatch protects itself, via FTRACE_OPS_FL_PERMANENT on the shared trampoline ftrace_ops. That protection was silently dropped during a later refactoring and never restored, so this has been broken for years. Both the shared direct_ops (CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS arches) and the per-trampoline ops allocated on other arches (arm64, s390) are BPF-trampoline-private, not shared with any unrelated subsystem, so restoring the same unconditional flag is safe and needs no kernel/trace/ftrace.c changes -- its existing generic sysctl gate and attach-time refusal already scan the ops list and pick this up for free. Fixes: 00963a2e75a8 ("bpf: Support bpf_trampoline on functions with IPMODIFY (e.g. livepatch)") Cc: stable@vger.kernel.org Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Andrey Grodzovsky --- kernel/bpf/trampoline.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kernel/bpf/trampoline.c b/kernel/bpf/trampoline.c index 129d07db117e..3d5069091db7 100644 --- a/kernel/bpf/trampoline.c +++ b/kernel/bpf/trampoline.c @@ -224,6 +224,8 @@ void bpf_image_ksym_del(struct bpf_ksym *ksym) */ struct ftrace_ops direct_ops = { .ops_func = bpf_tramp_ftrace_ops_func, + /* Same protection livepatch gives its own ftrace_ops. */ + .flags = FTRACE_OPS_FL_PERMANENT, }; static int direct_ops_alloc(struct bpf_trampoline *tr) @@ -303,6 +305,8 @@ static int direct_ops_alloc(struct bpf_trampoline *tr) return -ENOMEM; tr->fops->private = tr; tr->fops->ops_func = bpf_tramp_ftrace_ops_func; + /* See the direct_ops initializer above for why. */ + tr->fops->flags |= FTRACE_OPS_FL_PERMANENT; return 0; } -- 2.34.1 kernel.ftrace_enabled=0 silently kills ftrace-based kprobes/kretprobes the same way it does BPF trampolines: arm succeeds, the probe stops firing with no error, and re-enabling silently restores it. Unlike trampolines this is not a regression -- ftrace-based kprobes have never carried this protection, so the bug is long-standing. kprobe_ftrace_ops/kprobe_ipmodify_ops are shared only among ftrace-based kprobe attachers, not with any other tracer, so the same unconditional FTRACE_OPS_FL_PERMANENT livepatch already uses applies cleanly here too, with no kernel/trace/ftrace.c changes needed. Known limitation: kprobe.multi/kretprobe.multi/kprobe.session (fprobe-backed, kernel/trace/fprobe.c) are not covered. Entry-only fprobes could take the same fix, but return-capturing fprobes route through the function-graph tracer's shared subops manager, which is also used by unrelated tracers (function_graph, irqsoff, wakeup latency, function profiler) -- marking it permanent would block ftrace_enabled=0 for those too. Left for a follow-up. Cc: stable@vger.kernel.org Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Andrey Grodzovsky --- kernel/kprobes.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/kernel/kprobes.c b/kernel/kprobes.c index bfc89083daa9..5a54511eee79 100644 --- a/kernel/kprobes.c +++ b/kernel/kprobes.c @@ -1122,14 +1122,15 @@ static struct kprobe *alloc_aggr_kprobe(struct kprobe *p) #endif /* CONFIG_OPTPROBES */ #ifdef CONFIG_KPROBES_ON_FTRACE +/* Same protection livepatch gives its own ftrace_ops. */ static struct ftrace_ops kprobe_ftrace_ops __read_mostly = { .func = kprobe_ftrace_handler, - .flags = FTRACE_OPS_FL_SAVE_REGS, + .flags = FTRACE_OPS_FL_SAVE_REGS | FTRACE_OPS_FL_PERMANENT, }; static struct ftrace_ops kprobe_ipmodify_ops __read_mostly = { .func = kprobe_ftrace_handler, - .flags = FTRACE_OPS_FL_SAVE_REGS | FTRACE_OPS_FL_IPMODIFY, + .flags = FTRACE_OPS_FL_SAVE_REGS | FTRACE_OPS_FL_IPMODIFY | FTRACE_OPS_FL_PERMANENT, }; static int kprobe_ipmodify_enabled; -- 2.34.1 Cover the FTRACE_OPS_FL_PERMANENT fix on BPF trampolines and ftrace-based kprobes/kretprobes: attach a fentry, fexit, kprobe, and kretprobe program in turn and confirm kernel.ftrace_enabled=0 is refused with EBUSY while attached, then succeeds once detached. Also confirm a new fentry/kprobe attach is itself refused while ftrace_enabled=0 is already in effect. kprobe.multi/kretprobe.multi/kprobe.session are intentionally not covered, matching the production change's scope. Skip the kprobe/kretprobe subtests when CONFIG_KPROBES_ON_FTRACE is off (e.g. arm64), read back via skel->kconfig->CONFIG_KPROBES_ON_FTRACE (same __kconfig idiom as progs/test_fill_link_info.c). Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Andrey Grodzovsky --- .../bpf/prog_tests/ftrace_permanent.c | 144 ++++++++++++++++++ .../selftests/bpf/progs/ftrace_permanent.c | 43 ++++++ 2 files changed, 187 insertions(+) create mode 100644 tools/testing/selftests/bpf/prog_tests/ftrace_permanent.c create mode 100644 tools/testing/selftests/bpf/progs/ftrace_permanent.c diff --git a/tools/testing/selftests/bpf/prog_tests/ftrace_permanent.c b/tools/testing/selftests/bpf/prog_tests/ftrace_permanent.c new file mode 100644 index 000000000000..dbe78009a291 --- /dev/null +++ b/tools/testing/selftests/bpf/prog_tests/ftrace_permanent.c @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-2.0 +/* Copyright (c) 2026 CrowdStrike */ +#include +#include "ftrace_permanent.skel.h" + +/* + * fentry/fexit/kprobe/kretprobe now carry FTRACE_OPS_FL_PERMANENT, so + * kernel.ftrace_enabled=0 must be refused with EBUSY while any of them + * is attached, and allowed again once detached. + * + * kprobe.multi/kretprobe.multi/kprobe.session are out of scope. + * kprobe/kretprobe subtests need CONFIG_KPROBES_ON_FTRACE (e.g. not on + * arm64) and are skipped otherwise. + */ + +#define FTRACE_ENABLED_PATH "/proc/sys/kernel/ftrace_enabled" + +static int read_ftrace_enabled(int *val) +{ + char buf[16] = {}; + int fd, n; + + fd = open(FTRACE_ENABLED_PATH, O_RDONLY); + if (fd < 0) + return -errno; + n = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (n <= 0) + return -EIO; + *val = atoi(buf); + return 0; +} + +/* Returns 0 on success, or -errno on write failure. */ +static int write_ftrace_enabled(int val) +{ + char buf[4]; + int fd, n, len, err = 0; + + fd = open(FTRACE_ENABLED_PATH, O_WRONLY); + if (fd < 0) + return -errno; + len = snprintf(buf, sizeof(buf), "%d", val); + n = write(fd, buf, len); + if (n < 0) + err = -errno; + close(fd); + return err; +} + +/* + * Attach @prog, assert kernel.ftrace_enabled=0 is refused while attached + * and stays at 1, then detach and assert the disable now succeeds. + */ +static void check_blocks_disable(struct bpf_program *prog, const char *name) +{ + struct bpf_link *link; + int val, err; + + link = bpf_program__attach(prog); + if (!ASSERT_OK_PTR(link, name)) + return; + + err = write_ftrace_enabled(0); + ASSERT_EQ(err, -EBUSY, "disable_refused"); + if (!ASSERT_OK(read_ftrace_enabled(&val), "read_back")) + goto detach; + ASSERT_EQ(val, 1, "still_enabled"); + +detach: + bpf_link__destroy(link); + + ASSERT_OK(write_ftrace_enabled(0), "disable_after_detach"); + ASSERT_OK(write_ftrace_enabled(1), "reenable"); +} + +/* Attach @prog while ftrace_enabled=0 and assert it is refused. */ +static void check_attach_while_disabled_refused(struct bpf_program *prog, const char *name) +{ + struct bpf_link *link; + + if (!ASSERT_OK(write_ftrace_enabled(0), "disable")) + return; + + link = bpf_program__attach(prog); + if (!ASSERT_ERR_PTR(link, name)) + bpf_link__destroy(link); + + ASSERT_OK(write_ftrace_enabled(1), "reenable"); +} + +void test_ftrace_permanent(void) +{ + struct ftrace_permanent *skel; + bool kprobes_on_ftrace; + int orig = 1; + + /* Save and always restore ftrace_enabled. */ + if (read_ftrace_enabled(&orig)) { + test__skip(); + return; + } + + skel = ftrace_permanent__open_and_load(); + if (!ASSERT_OK_PTR(skel, "skel_open_and_load")) + goto restore; + + kprobes_on_ftrace = skel->kconfig->CONFIG_KPROBES_ON_FTRACE; + + if (test__start_subtest("fentry_blocks_disable")) + check_blocks_disable(skel->progs.test_fentry, "attach_fentry"); + + if (test__start_subtest("fexit_blocks_disable")) + check_blocks_disable(skel->progs.test_fexit, "attach_fexit"); + + if (test__start_subtest("kprobe_blocks_disable")) { + if (kprobes_on_ftrace) + check_blocks_disable(skel->progs.test_kprobe, "attach_kprobe"); + else + test__skip(); + } + + if (test__start_subtest("kretprobe_blocks_disable")) { + if (kprobes_on_ftrace) + check_blocks_disable(skel->progs.test_kretprobe, "attach_kretprobe"); + else + test__skip(); + } + + if (test__start_subtest("fentry_attach_while_disabled_refused")) + check_attach_while_disabled_refused(skel->progs.test_fentry, "attach_fentry"); + + if (test__start_subtest("kprobe_attach_while_disabled_refused")) { + if (kprobes_on_ftrace) + check_attach_while_disabled_refused(skel->progs.test_kprobe, + "attach_kprobe"); + else + test__skip(); + } + + ftrace_permanent__destroy(skel); +restore: + write_ftrace_enabled(orig); +} diff --git a/tools/testing/selftests/bpf/progs/ftrace_permanent.c b/tools/testing/selftests/bpf/progs/ftrace_permanent.c new file mode 100644 index 000000000000..ca706d8edc23 --- /dev/null +++ b/tools/testing/selftests/bpf/progs/ftrace_permanent.c @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-2.0 +/* Copyright (c) 2026 CrowdStrike */ +#include "vmlinux.h" +#include +#include +#include + +char _license[] SEC("license") = "GPL"; + +extern bool CONFIG_KPROBES_ON_FTRACE __kconfig __weak; + +/* This function is here to have CONFIG_KPROBES_ON_FTRACE used and + * added to object BTF, so the userspace side can read it back via + * skel->kconfig->CONFIG_KPROBES_ON_FTRACE. + */ +int unused(void) +{ + return CONFIG_KPROBES_ON_FTRACE ? 0 : 1; +} + +SEC("fentry/bpf_fentry_test1") +int BPF_PROG(test_fentry, int a) +{ + return 0; +} + +SEC("fexit/bpf_fentry_test2") +int BPF_PROG(test_fexit, int a, __u64 b) +{ + return 0; +} + +SEC("kprobe/bpf_fentry_test3") +int test_kprobe(struct pt_regs *ctx) +{ + return 0; +} + +SEC("kretprobe/bpf_fentry_test4") +int BPF_KRETPROBE(test_kretprobe) +{ + return 0; +} -- 2.34.1