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]>


Reply via email to