Le 19/06/2026 à 5:09 AM, 'Kees Cook' via KUnit Development a écrit : > drivers/misc/lkdtm/cfi.c already exercises kCFI's forward-edge check > via a debugfs trigger, but it is awkward to run from automated CI and > is gated on LKDTM being built in. Add a self-contained kunit test that > performs the same kind of indirect call through a deliberately-cast > function pointer and validates that kCFI catches the mismatch, plus > coverage that well-typed indirect calls are left undisturbed. > > A deliberate kCFI violation is normally fatal: with > CONFIG_CFI_PERMISSIVE=n it kills the calling thread, and on architectures > where an in-kernel breakpoint is taken in NMI context (e.g. riscv) > it cannot even do that and panics the machine instead. To test the > check without depending on any of that, when CONFIG_CFI_KUNIT_TEST=y, > kernel/cfi.c grows a small hook, cfi_kunit_set_failure_hook(), consulted > by report_cfi_failure() on every kCFI trap. When the registered hook > counts the trap, the report is suppressed and BUG_TRAP_TYPE_WARN is > returned, so the arch trap handler skips the trapping instruction > and resumes the thread: the same "report and continue" path as > CFI_PERMISSIVE=y, but independent of how CFI_PERMISSIVE is configured. The > hook only ever claims a failure that fired during the test that armed > it (matched via the current task's kunit pointer), so any other CFI > failure behaves normally. It runs in trap context, possibly NMI-like, > so it stays lock-free. > > With that in place, the kunit kCFI test adds the following tests: > > - forward_proto: an indirect call through a "void (*)(int *)" pointer > to an "int (*)(int *)" callee, which must trip kCFI exactly once. > > - baseline: the same call with a matched prototype must not trip kCFI > and must increment its counter (to show it actually got called). > > - arity sweep: matched-prototype indirect tail calls across increasing > arity (1 to 7 arguments) must not trip kCFI and must return the right > values. kCFI's instrumentation uses scratch registers to perform the > typeid lookup and validation, which can compete with the argument > registers on register-starved ABIs (e.g. arm32's r0-r3, which also > forces a spill to the stack). A GCC arm32 kCFI codegen bug was > observed where the callee pointer never reached the call register in > a high-arity indirect tail call, leaving the kCFI prologue to read its > typeid from a stale register and trapping a perfectly well-typed call. > > The test lives in the new kernel/tests/ subdirectory rather than under > lib/tests/, since the code under test (kernel/cfi.c) lives in kernel/; > kernel/Makefile is taught to descend into tests/ when CONFIG_KUNIT is > set. The Kconfig sits next to "config CFI" and "config CFI_PERMISSIVE" > in arch/Kconfig and depends only on "KUNIT": the well-typed baseline > and arity cases exercise indirect-call codegen regardless of CFI, > while the mismatch case skips at runtime when CONFIG_CFI=n. The hook > makes that case work with CFI_PERMISSIVE either enabled or disabled. > > The mismatched-prototype call uses the same "(void *)" intermediate > cast that LKDTM uses, which is enough to silence -Wcast-function-type > on the intentional mis-cast. > > Build and boot tested with GCC 17.0.0 20260615 (with my > experimental kCFI series); all three kunit cases pass under qemu via > tools/testing/kunit/kunit.py for ARCH=x86_64 (CFI_PERMISSIVE both n and > y), ARCH=arm64, ARCH=arm, and ARCH=riscv. > > Assisted-by: Claude:claude-opus-4-8[1m] > Signed-off-by: Kees Cook <[email protected]> > ---
Finally got around to testing this. My biggest question is whether it's particularly important that this test works with CFI_PERMISSIVE disabled: it seems like it'd be a lot simpler if this just depended on CFI_PERMISSIVE=y. (And that'd avoid the security worries with Fedora/Android building with CONFIG_KUNIT=m.) > v2: fix sashiko feedback (rcu, smp, test run detection, header fix gone) > https://sashiko.dev/#/patchset/20260618194001.work.490-kees%40kernel.org > v1: https://lore.kernel.org/all/[email protected]/ > Cc: Sami Tolvanen <[email protected]> > Cc: Nathan Chancellor <[email protected]> > Cc: Arnd Bergmann <[email protected]> > Cc: Brendan Higgins <[email protected]> > Cc: David Gow <[email protected]> > Cc: Rae Moar <[email protected]> > Cc: [email protected] > Cc: [email protected] > --- > arch/Kconfig | 15 +++ > kernel/Makefile | 1 + > kernel/tests/Makefile | 5 + > include/linux/cfi.h | 17 +++ > kernel/cfi.c | 49 +++++++ > kernel/tests/cfi_kunit.c | 276 +++++++++++++++++++++++++++++++++++++++ > MAINTAINERS | 1 + > 7 files changed, 364 insertions(+) > create mode 100644 kernel/tests/Makefile > create mode 100644 kernel/tests/cfi_kunit.c > > diff --git a/arch/Kconfig b/arch/Kconfig > index e86880045158..c463b6f2960b 100644 > --- a/arch/Kconfig > +++ b/arch/Kconfig > @@ -983,6 +983,21 @@ config CFI_PERMISSIVE > > If unsure, say N. > > +config CFI_KUNIT_TEST > + tristate "KUnit test kCFI indirect-call type checks at runtime" if > !KUNIT_ALL_TESTS > + depends on KUNIT > + default KUNIT_ALL_TESTS > + help > + Builds a KUnit test that triggers kCFI type mismatches on real > + indirect calls and verifies that the violations are detected, and > + that well-typed indirect calls (including high-arity ones) are not > + disturbed. The test registers a hook in the kCFI failure path so > + its deliberate violations are counted and survived on its own > + threads, so it works with CFI_PERMISSIVE either enabled or disabled. > + > + For the fatal-trap behavior of a real violation, see LKDTM's "CFI_*" > + tests. > + > config HAVE_ARCH_WITHIN_STACK_FRAMES > bool > help > diff --git a/kernel/Makefile b/kernel/Makefile > index 6785982013dc..448de4fff75c 100644 > --- a/kernel/Makefile > +++ b/kernel/Makefile > @@ -59,6 +59,7 @@ obj-y += dma/ > obj-y += entry/ > obj-y += unwind/ > obj-$(CONFIG_MODULES) += module/ > +obj-$(CONFIG_KUNIT) += tests/ > > obj-$(CONFIG_KCMP) += kcmp.o > obj-$(CONFIG_FREEZER) += freezer.o > diff --git a/kernel/tests/Makefile b/kernel/tests/Makefile > new file mode 100644 > index 000000000000..70f1f9a5c502 > --- /dev/null > +++ b/kernel/tests/Makefile > @@ -0,0 +1,5 @@ > +# SPDX-License-Identifier: GPL-2.0 > +# > +# Makefile for tests of kernel/ functions. > + > +obj-$(CONFIG_CFI_KUNIT_TEST) += cfi_kunit.o > diff --git a/include/linux/cfi.h b/include/linux/cfi.h > index 0f220d29225c..e4e66a9423ca 100644 > --- a/include/linux/cfi.h > +++ b/include/linux/cfi.h > @@ -24,6 +24,18 @@ static inline enum bug_trap_type > report_cfi_failure_noaddr(struct pt_regs *regs, > return report_cfi_failure(regs, addr, NULL, 0); > } > > +#if IS_ENABLED(CONFIG_CFI_KUNIT_TEST) > +/* > + * Register a hook consulted by report_cfi_failure() on every kCFI trap. If > + * the hook returns true, the failure is treated as handled: the report is > + * suppressed and BUG_TRAP_TYPE_WARN is returned so the arch trap handler > + * skips the trapping instruction and resumes, regardless of CFI_PERMISSIVE. > + * This lets the kCFI KUnit test count deliberate violations on its own > + * threads without killing them. Pass NULL to unregister. > + */ > +void cfi_kunit_set_failure_hook(bool (*hook)(void)); > +#endif > + > #ifndef cfi_get_offset > /* > * Returns the CFI prefix offset. By default, the compiler emits only > @@ -58,6 +70,11 @@ extern u32 cfi_bpf_subprog_hash; > static inline int cfi_get_offset(void) { return 0; } > static inline u32 cfi_get_func_hash(void *func) { return 0; } > > +#if IS_ENABLED(CONFIG_CFI_KUNIT_TEST) > +/* No kCFI traps to hook when CONFIG_CFI=n; the test skips at runtime. */ > +static inline void cfi_kunit_set_failure_hook(bool (*hook)(void)) { } > +#endif > + > #define cfi_bpf_hash 0U > #define cfi_bpf_subprog_hash 0U > > diff --git a/kernel/cfi.c b/kernel/cfi.c > index 4dad04ead06c..8cb6a274c865 100644 > --- a/kernel/cfi.c > +++ b/kernel/cfi.c > @@ -8,12 +8,61 @@ > #include <linux/bpf.h> > #include <linux/cfi_types.h> > #include <linux/cfi.h> > +#include <linux/rcupdate.h> > > bool cfi_warn __ro_after_init = IS_ENABLED(CONFIG_CFI_PERMISSIVE); > > +#if IS_ENABLED(CONFIG_CFI_KUNIT_TEST) > +static bool (*cfi_kunit_failure_hook)(void); > + > +void cfi_kunit_set_failure_hook(bool (*hook)(void)) > +{ > + WRITE_ONCE(cfi_kunit_failure_hook, hook); > + > + /* > + * On unregister, wait for any in-flight cfi_kunit_handled() caller to > + * finish before the (possibly module-resident) hook can be freed. > + */ > + if (!hook) > + synchronize_rcu(); > +} > +EXPORT_SYMBOL_GPL(cfi_kunit_set_failure_hook); > + > +static bool cfi_kunit_handled(void) > +{ > + bool (*hook)(void); > + bool handled = false; It might make sense to check KUnit is running at all here: if (!static_branch_unlikely(&kunit_running)) return NULL; All of the other KUnit hooks (see, e.g, include/kunit/test-bug.h) do this, and while this is technically being checked within your hook when the current test is checked, you've already incurred cost the rcu lock and indirect function call by this point. > + > + /* > + * Runs in CFI trap context (NMI-like on some arches); RCU is watching > + * by this point, and the read-side section pairs with the > + * synchronize_rcu() on unregister to keep the hook alive across the > + * call. > + */ > + rcu_read_lock(); > + hook = READ_ONCE(cfi_kunit_failure_hook); > + if (hook) > + handled = hook(); > + rcu_read_unlock(); > + > + return handled; > +} > +#else > +static inline bool cfi_kunit_handled(void) { return false; } > +#endif > + > enum bug_trap_type report_cfi_failure(struct pt_regs *regs, unsigned long > addr, > unsigned long *target, u32 type) > { > + /* > + * Let a registered KUnit test consume and count its own deliberate > + * violations. If it claims the failure, suppress the report and tell > + * the arch handler to skip the trap and resume the thread, regardless > + * of CFI_PERMISSIVE. > + */ > + if (cfi_kunit_handled()) > + return BUG_TRAP_TYPE_WARN; > + > if (target) > pr_err("CFI failure at %pS (target: %pS; expected type: > 0x%08x)\n", > (void *)addr, (void *)*target, type); > diff --git a/kernel/tests/cfi_kunit.c b/kernel/tests/cfi_kunit.c > new file mode 100644 > index 000000000000..6a149326f26a > --- /dev/null > +++ b/kernel/tests/cfi_kunit.c > @@ -0,0 +1,276 @@ > +// SPDX-License-Identifier: GPL-2.0 > +/* > + * KUnit test for Kernel Control Flow Integrity (kCFI). > + * > + * Exercises properties of the compiler's KCFI indirect-call checks: > + * > + * Mirrors drivers/misc/lkdtm/cfi.c's CFI_FORWARD_PROTO test, but as a > + * self-contained kunit suite that drives kernel/cfi.c via the standard > + * indirect-call path. For the fatal-trap behavior of a real violation, see > + * LKDTM's "CFI_*" tests. > + */ > + > +#include <kunit/test.h> > +#include <kunit/test-bug.h> > +#include <linux/cfi.h> > + > +/* > + * The test case currently expecting to count kCFI traps, and its running > + * count. Only ever touched for the test that armed cfi_kunit_active, so a > + * single counter is safe without locking. > + */ > +static struct kunit *cfi_kunit_active; > +static int cfi_kunit_trap_count; > + > +/* > + * Consulted from report_cfi_failure() in kCFI trap context, which may be > + * NMI-like (e.g. riscv kernel breakpoints), so this must stay lock-free: it > + * only reads the current task's kunit pointer and touches module-static > + * counters. It claims the failure by counting it and asking the arch handler > + * to skip the trap and resume, but only when the trap fired on the very test > + * that armed us. Any other CFI failure is left to behave normally. > + */ > +static bool cfi_kunit_failure_hook(void) > +{ > + struct kunit *test = READ_ONCE(cfi_kunit_active); > + > + /* > + * Claim the failure only when a test is armed and it is the one > + * running on this thread. Without the NULL check, a real CFI violation > + * on a background thread (where kunit_get_current_test() is also NULL) > + * while no test is active would match and be wrongly suppressed. > + */ > + if (!test || kunit_get_current_test() != test) > + return false; > + > + WRITE_ONCE(cfi_kunit_trap_count, cfi_kunit_trap_count + 1); > + return true; > +} > + > +static int called_count; > + > +/* > + * Two same-arity, same-arg-type callees with deliberately different return > + * types so that kCFI's type-hash check at the call site catches the cast. > + */ > +static noinline void cfi_increment_void(int *counter) > +{ > + (*counter)++; > +} > + > +static noinline int cfi_increment_int(int *counter) > +{ > + (*counter)++; > + return *counter; > +} > + > +/* > + * The indirect call site. Type of the function pointer is what kCFI > + * compares against the hash baked into the callee's __cfi_<name> prefix. > + */ > +static noinline void cfi_indirect_call(void (*func)(int *)) > +{ > + func(&called_count); > +} > + > +/* > + * Increasing-arity callees. Each returns a position-weighted sum of its > + * arguments so that a dropped, reordered, or zeroed argument produces a > wrong > + * result rather than a coincidental match. Called with args (1, 2, 3, ...), > + * cfi_arityN() returns sum(i*i) for i in 1..N. > + */ > +static noinline int cfi_arity1(int a) > +{ > + return a; > +} > + > +static noinline int cfi_arity2(int a, int b) > +{ > + return a + 2 * b; > +} > + > +static noinline int cfi_arity3(int a, int b, int c) > +{ > + return a + 2 * b + 3 * c; > +} > + > +static noinline int cfi_arity4(int a, int b, int c, int d) > +{ > + return a + 2 * b + 3 * c + 4 * d; > +} > + > +static noinline int cfi_arity5(int a, int b, int c, int d, int e) > +{ > + return a + 2 * b + 3 * c + 4 * d + 5 * e; > +} > + > +static noinline int cfi_arity6(int a, int b, int c, int d, int e, int f) > +{ > + return a + 2 * b + 3 * c + 4 * d + 5 * e + 6 * f; > +} > + > +static noinline int cfi_arity7(int a, int b, int c, int d, int e, int f, int > g) > +{ > + return a + 2 * b + 3 * c + 4 * d + 5 * e + 6 * f + 7 * g; > +} > + > +/* > + * Tail-calling trampolines: each receives the callee as an opaque pointer > + * (defeating optimization) plus the arguments, then `return fn(args)` as > + * its final statement so the compiler lowers it to an indirect tail call. > + * Arity grows so the callee pointer and the kCFI scratch registers > + * increasingly contend with argument registers. > + */ > +static noinline int cfi_tail_call1(int (*fn)(int), int a) > +{ > + return fn(a); > +} > + > +static noinline int cfi_tail_call2(int (*fn)(int, int), int a, int b) > +{ > + return fn(a, b); > +} > + > +static noinline int cfi_tail_call3(int (*fn)(int, int, int), > + int a, int b, int c) > +{ > + return fn(a, b, c); > +} > + > +static noinline int cfi_tail_call4(int (*fn)(int, int, int, int), > + int a, int b, int c, int d) > +{ > + return fn(a, b, c, d); > +} > + > +static noinline int cfi_tail_call5(int (*fn)(int, int, int, int, int), > + int a, int b, int c, int d, int e) > +{ > + return fn(a, b, c, d, e); > +} > + > +static noinline int cfi_tail_call6(int (*fn)(int, int, int, int, int, int), > + int a, int b, int c, int d, int e, int f) > +{ > + return fn(a, b, c, d, e, f); > +} > + > +static noinline int cfi_tail_call7(int (*fn)(int, int, int, int, int, int, > int), > + int a, int b, int c, int d, int e, int f, > + int g) > +{ > + return fn(a, b, c, d, e, f, g); > +} > + > +#define CFI_MAX_ARITY 7 > + > +static void cfi_kunit_forward_proto_traps(struct kunit *test) > +{ > + int before_traps = READ_ONCE(cfi_kunit_trap_count); > + > + /* Only this case needs kCFI; the well-typed cases below run > regardless. */ > + if (!IS_ENABLED(CONFIG_CFI)) > + kunit_skip(test, "kCFI is not enabled (CONFIG_CFI=n)"); > + > + /* > + * Force a kCFI type mismatch: the call site expects a callee whose > + * __cfi_ prefix encodes "void (*)(int *)", but the actual callee's > + * prefix encodes "int (*)(int *)". The (void *) intermediate cast > + * follows drivers/misc/lkdtm/cfi.c and sidesteps -Wcast-function-type > + * on the deliberate mis-cast. > + * > + * kCFI must detect this. The failure hook counts the trap and lets us > + * survive it, so control returns here normally. > + */ > + cfi_indirect_call((void *)cfi_increment_int); > + > + KUNIT_EXPECT_EQ_MSG(test, READ_ONCE(cfi_kunit_trap_count), before_traps > + 1, > + "mismatched-prototype indirect call was not caught > by kCFI\n"); > +} > + > +static void cfi_kunit_baseline_matched_proto(struct kunit *test) > +{ > + int before_traps = READ_ONCE(cfi_kunit_trap_count); > + int before_calls = called_count; > + > + /* Matched prototype: must NOT trap and must increment the counter. */ > + cfi_indirect_call(cfi_increment_void); > + KUNIT_EXPECT_EQ(test, called_count, before_calls + 1); > + KUNIT_EXPECT_EQ_MSG(test, READ_ONCE(cfi_kunit_trap_count), before_traps, > + "well-typed indirect call spuriously tripped > kCFI\n"); > +} > + > +static void cfi_kunit_arity_matched_calls(struct kunit *test) > +{ > + /* expected[N] = sum(i*i) for i in 1..N */ > + static const int expected[CFI_MAX_ARITY + 1] = { > + 0, 1, 5, 14, 30, 55, 91, 140, > + }; > + int before_traps = READ_ONCE(cfi_kunit_trap_count); > + int results[CFI_MAX_ARITY + 1]; > + int i; > + > + results[1] = cfi_tail_call1(cfi_arity1, 1); > + results[2] = cfi_tail_call2(cfi_arity2, 1, 2); > + results[3] = cfi_tail_call3(cfi_arity3, 1, 2, 3); > + results[4] = cfi_tail_call4(cfi_arity4, 1, 2, 3, 4); > + results[5] = cfi_tail_call5(cfi_arity5, 1, 2, 3, 4, 5); > + results[6] = cfi_tail_call6(cfi_arity6, 1, 2, 3, 4, 5, 6); > + results[7] = cfi_tail_call7(cfi_arity7, 1, 2, 3, 4, 5, 6, 7); > + > + for (i = 1; i <= CFI_MAX_ARITY; i++) > + KUNIT_EXPECT_EQ_MSG(test, results[i], expected[i], > + "arity-%d matched indirect call returned > %d, expected %d\n", > + i, results[i], expected[i]); > + > + /* > + * None of the matched calls may trip kCFI. A spurious trap here is a > + * codegen bug, most likely the callee pointer never reaching the call > + * register under argument-register pressure. > + */ > + KUNIT_EXPECT_EQ_MSG(test, READ_ONCE(cfi_kunit_trap_count), before_traps, > + "a matched-prototype indirect call tripped kCFI > under register pressure (codegen bug)\n"); > +} > + > +static int cfi_kunit_init(struct kunit *test) > +{ > + WRITE_ONCE(cfi_kunit_trap_count, 0); > + WRITE_ONCE(cfi_kunit_active, test); > + return 0; > +} > + > +static void cfi_kunit_exit(struct kunit *test) > +{ > + WRITE_ONCE(cfi_kunit_active, NULL); > +} > + > +static int cfi_kunit_suite_init(struct kunit_suite *suite) > +{ > + cfi_kunit_set_failure_hook(cfi_kunit_failure_hook); > + return 0; > +} > + > +static void cfi_kunit_suite_exit(struct kunit_suite *suite) > +{ > + cfi_kunit_set_failure_hook(NULL); > +} > + > +static struct kunit_case cfi_kunit_cases[] = { > + KUNIT_CASE(cfi_kunit_baseline_matched_proto), > + KUNIT_CASE(cfi_kunit_arity_matched_calls), > + KUNIT_CASE(cfi_kunit_forward_proto_traps), > + {} > +}; > + > +static struct kunit_suite cfi_kunit_suite = { > + .name = "cfi", > + .init = cfi_kunit_init, > + .exit = cfi_kunit_exit, > + .suite_init = cfi_kunit_suite_init, > + .suite_exit = cfi_kunit_suite_exit, > + .test_cases = cfi_kunit_cases, > +}; > +kunit_test_suite(cfi_kunit_suite); > + > +MODULE_DESCRIPTION("KUnit tests for kCFI indirect-call type checks"); > +MODULE_LICENSE("GPL"); > diff --git a/MAINTAINERS b/MAINTAINERS > index c8d4b913f26c..8c704a24136b 100644 > --- a/MAINTAINERS > +++ b/MAINTAINERS > @@ -6252,6 +6252,7 @@ B: https://github.com/ClangBuiltLinux/linux/issues > T: git git://git.kernel.org/pub/scm/linux/kernel/git/kees/linux.git > for-next/hardening > F: include/linux/cfi.h > F: kernel/cfi.c > +F: kernel/tests/cfi_kunit.c > > CLANG-FORMAT FILE > M: Miguel Ojeda <[email protected]>

