On 7/8/26 4:00 AM, Aaron Tomlin wrote:
> Currently, the "module_blacklist=" command-line parameter only applies
> to loadable modules. If a module is built-in, the parameter is silently
> ignored. This patch extends the blacklisting functionality to built-in
> modules by intercepting their initialisation routines during early boot.
> 
> To preserve the existing user-space ABI, "module_blacklist=" is kept
> as a legacy alias pointing to the same module_denylist variable.
> 
> To achieve this, we introduce a new ".initcall.modnames" memory section.
> For each built-in module, we use a standard C structure (i.e., struct
> initcall_modname) to map its initcall function pointer to its associated
> KBUILD_MODNAME string.
> 
> During boot, do_one_initcall() cross-references the initcall function
> pointer against this table. If a match is found and the module is
> present in the denylist, the initcall is skipped.
> 
> To make the denylist functional on monolithic kernels, the command-line
> parameter parsing and the module_is_denylisted() lookup function are
> decoupled from the loadable module subsystem and moved to init/main.c.
> This enables "module_denylist=" and "module_blacklist=" to intercept
> built-in modules even on kernels built with CONFIG_MODULES=n.
> 
> Design Considerations and Trade-offs:
> 
>     1.  LTO and CFI Compatibility vs. PREL32
> 
>         Previous iterations of this patch attempted to use top-level
>         inline assembly to generate 32-bit relative offsets (PREL32) to
>         save memory. However, raw inline assembly operates blindly
>         outside of the C compiler's visibility. When compiled with
>         CONFIG_LTO_CLANG or CONFIG_CFI_CLANG, the compiler applies
>         symbol renaming and generates Control Flow Integrity stubs.
>         The raw assembly string-matching fails to track these changes,
>         resulting in undefined references or runtime address mismatches.
> 
>         To resolve this, we strictly use standard C structures to hold
>         the function pointers. This natively allows the compiler to
>         resolve LTO renaming and map CFI stubs correctly. We trade the
>         minor spatial optimisation of PREL32 (using absolute 64-bit
>         pointers instead) to guarantee architectural safety under modern
>         compiler protections. Because this metadata is placed in an
>         ".init" section and freed entirely after boot, the temporary
>         memory overhead is negligible.
> 
>     2.  Prevention of UAF (Use-After-Free) via temporal and spatial
>         boundaries:
> 
>         Because do_one_initcall() is a shared path invoked by both the
>         early boot process and runtime module loading (i.e.,
>         do_init_module()), we must prevent loadable modules from
>         attempting to scan the ".initcall.modnames" section after it has
>         been reclaimed by free_initmem().
> 
>         To ensure safety, we employ a two-fold validation check:
>         - A temporal check using 'system_state >= SYSTEM_FREEING_INITMEM'
>           to immediately return NULL once init memory is freed.
>         - A spatial check using 'is_kernel_text()' and
>           'is_kernel_inittext()' to confirm the function resides in core
>           kernel text.
> 
>         Since dynamically loaded modules reside in separately allocated
>         module memory outside these ranges, they bypass the table lookup
>         entirely. This makes the lookup lockless, race-free, and safe
>         from UAF vulnerabilities.
> 
> Signed-off-by: Aaron Tomlin <[email protected]>
> ---
> Changes since v3:
> 
>  - Renamed the external function prototype and internal helper to
>    module_is_denylisted(), while updating the backing variable in
>    main.c to module_denylist. To preserve user-space compatibility
>    while adopting modern terminology, separate core_param entries have
>    been introduced, allowing both the preferred module_denylist=
>    parameter and the legacy module_blacklist= parameter to resolve to
>    the same underlying variable (Andrew Morton)
> 
>  - I introduced the __initcall_fn_ptr() macro helper to dynamically
>    resolve the initcall pointer configuration:
>     - For architectures with relative 32-bit relocations
>       (CONFIG_HAVE_ARCH_PREL32_RELOCATIONS=y), it resolves to the
>       relocation stub pointer  __initcall_stub(fn, __iid, id)
>     - For architectures without PREL32 relocations, it resolves
>       directly to the function pointer fn
> 
>  - Decoupled the module_denylist parameter parsing and the
>    module_is_denylisted() function from CONFIG_MODULES, moving the
>    logic to init/main.c. This ensures the denylist works for built-in
>    modules even on monolithic kernels built without loadable module
>    support (CONFIG_MODULES=n)
> 
>  - Removed the conditional stub implementation of
>    module_is_denylisted() in module.h and replaced it with a single,
>    unconditional declaration outside of the #ifdef CONFIG_MODULES block.
>    This prevents compiler warnings about missing prototypes and ensures
>    visibility under a monolithic configuration
> 
>  - Replaced the initmem_freed state variable and its synchronisation
>    logic in kernel_init() with race-free spatial boundary checks using
>    is_kernel_text() and is_kernel_inittext() in initcall_get_modname()
> 
>  - Aligned the .initcall_modnames table with relocations by assigning
>    .initcall_fn using the __initcall_stub() helper in
>    ___define_initcall(). This ensures the lookup matches the actual stub
>    pointer passed to do_one_initcall() when
>    CONFIG_HAVE_ARCH_PREL32_RELOCATIONS is enabled. Passed the preprocessor
>    __iid argument to ____define_initcall_modname once to avoid double
>    evaluation of __COUNTER__ (which caused build failures with LTO)
> 
>  - Updated initcall_get_modname() in main.c to resolve the function
>    pointer fn using dereference_function_descriptor(fn) prior to
>    checking the .text and .init.text boundaries, and dereference both
>    fn and p->initcall_fn in the comparison loop to support descriptor-based
>    architectures (e.g., PPC64)
> 
>  - Linked to v3: 
> https://lore.kernel.org/lkml/[email protected]/
> 
> Changes since v2:
> 
>  - Avoided relative 32-bit offsets (PREL32) with inline assembly, opting
>    instead for standard C structures with absolute pointers. This fixes LTO
>    and CFI compatibility issues (e.g., under Clang) where raw inline assembly
>    fails to track compiler-generated symbols and CFI stubs
> 
>  - Placed module name strings into the ".init.rodata" section via a dedicated
>    static array to ensure they are freed from memory after boot
> 
>  - Avoided Use-After-Free (UAF) bugs post-boot when loading dynamic modules:
>    - Added an 'initmem_freed' flag, marked as '__ro_after_init', set after
>      free_initmem() to skip table lookups for dynamically loaded modules
>    - Added a blacklist check in do_init_module() for dynamic modules
> 
>  - Simplified the linker script using the BOUNDED_SECTION_PRE_LABEL() macro
>    to define the ".initcall.modnames" section boundary
> 
>  - Added a dummy/stub implementation of module_is_blacklisted() when
>    CONFIG_MODULES is disabled to avoid build errors
> 
>  - Linked to v2: 
> https://lore.kernel.org/lkml/[email protected]/
> 
> Changes since v1:
> 
>  - Pivoted entirely from exposing built-in initcalls and their blacklist
>    status via a debugfs interface to directly extending the existing
>    "module_blacklist=" and new "module_blacklist=" to intercept built-in
>    modules at boot (Petr Pavlu)
> 
>  - Implemented 32-bit relative offsets (CONFIG_HAVE_ARCH_PREL32_RELOCATIONS)
>    to store the mappings, preventing binary bloat and preserving KASLR
>    efficacy
> 
>  - Linked to v1: 
> https://lore.kernel.org/lkml/[email protected]/
> ---
>  include/asm-generic/vmlinux.lds.h |  4 ++-
>  include/linux/init.h              | 26 +++++++++++++--
>  include/linux/module.h            |  2 ++
>  init/main.c                       | 53 +++++++++++++++++++++++++++++++
>  kernel/module/main.c              | 24 ++------------
>  5 files changed, 84 insertions(+), 25 deletions(-)
> 
> diff --git a/include/asm-generic/vmlinux.lds.h 
> b/include/asm-generic/vmlinux.lds.h
> index 5659f4b5a125..fc863595743e 100644
> --- a/include/asm-generic/vmlinux.lds.h
> +++ b/include/asm-generic/vmlinux.lds.h
> @@ -734,7 +734,9 @@
>       EARLYCON_TABLE()                                                \
>       LSM_TABLE()                                                     \
>       EARLY_LSM_TABLE()                                               \
> -     KUNIT_INIT_TABLE()
> +     KUNIT_INIT_TABLE()                                              \
> +     . = ALIGN(8);                                                   \

Is ALIGN(8) sufficient, or is STRUCT_ALIGN() needed instead?

> +     BOUNDED_SECTION_PRE_LABEL(.initcall.modnames, initcall_modnames, 
> __start_, __stop_)

Nit: I think this can be shortened to:

BOUNDED_SECTION_BY(.initcall.modnames, _initcall_modnames)

>  
>  #define INIT_TEXT                                                    \
>       *(.init.text .init.text.*)                                      \
> diff --git a/include/linux/init.h b/include/linux/init.h
> index 40331923b9f4..9c78b6c30361 100644
> --- a/include/linux/init.h
> +++ b/include/linux/init.h
> @@ -271,8 +271,30 @@ extern struct module __this_module;
>               __initcall_name(initcall, __iid, id),           \
>               __initcall_section(__sec, __iid))
>  
> -#define ___define_initcall(fn, id, __sec)                    \
> -     __unique_initcall(fn, id, __sec, __initcall_id(fn))
> +#ifdef CONFIG_HAVE_ARCH_PREL32_RELOCATIONS
> +#define __initcall_fn_ptr(fn, __iid, id)     __initcall_stub(fn, __iid, id)
> +#else
> +#define __initcall_fn_ptr(fn, __iid, id)     fn
> +#endif
> +
> +struct initcall_modname {
> +     initcall_t initcall_fn;
> +     const char *modname;
> +};
> +
> +#define ____define_initcall_modname(fn, id, __sec, __iid)            \
> +     __unique_initcall(fn, id, __sec, __iid)                         \
> +     static const char __initstr_##fn[] __used __aligned(1)          \
> +             __section(".init.rodata") = KBUILD_MODNAME;             \
> +     static const struct initcall_modname __modname_##fn __used      \
> +             __section(".initcall.modnames") = {                     \
> +                     .initcall_fn = __initcall_fn_ptr(fn, __iid, id), \
> +                     .modname = __initstr_##fn                       \
> +             };

All initcalls are ultimately defined via ____define_initcall_modname(),
including initcalls in vmlinux code that can never be built as modules.
In such a case, KBUILD_MODNAME is set to the target's basename (same as
KBUILD_BASENAME).

This wastes memory and also allows the module_blacklist parameter to
match initcalls that are unrelated to built-in modules.

> +
> +#define ___define_initcall(fn, id, __sec)                            \
> +     ____define_initcall_modname(fn, id, __sec, __initcall_id(fn))
> +
>  
>  #define __define_initcall(fn, id) ___define_initcall(fn, id, .initcall##id)
>  
> diff --git a/include/linux/module.h b/include/linux/module.h
> index 7566815fabbe..bc2968c225e1 100644
> --- a/include/linux/module.h
> +++ b/include/linux/module.h
> @@ -883,6 +883,8 @@ static inline void module_for_each_mod(int(*func)(struct 
> module *mod, void *data
>  }
>  #endif /* CONFIG_MODULES */
>  
> +extern bool module_is_denylisted(const char *module_name);
> +

Nit: The extern keyword in function declarations is unnecessary. See
Documentation/process/coding-style.rst.

>  #ifdef CONFIG_SYSFS
>  extern struct kset *module_kset;
>  extern const struct kobj_type module_ktype;
> diff --git a/init/main.c b/init/main.c
> index e363232b428b..af71811d24e3 100644
> --- a/init/main.c
> +++ b/init/main.c
> @@ -1334,12 +1334,65 @@ static inline void do_trace_initcall_level(const char 
> *level)
>  }
>  #endif /* !TRACEPOINTS_ENABLED */
>  
> +extern struct initcall_modname __start_initcall_modnames[];
> +extern struct initcall_modname __stop_initcall_modnames[];
> +
> +/* module_denylist is a comma-separated list of module names */
> +static char *module_denylist;
> +bool module_is_denylisted(const char *module_name)

module_is_denylisted() should be __init_or_module.

> +{
> +     const char *p;
> +     size_t len;
> +
> +     if (!module_denylist)
> +             return false;
> +
> +     for (p = module_denylist; *p; p += len) {
> +             len = strcspn(p, ",");
> +             if (strlen(module_name) == len && !memcmp(module_name, p, len))
> +                     return true;
> +             if (p[len] == ',')
> +                     len++;
> +     }
> +     return false;
> +}
> +core_param(module_denylist, module_denylist, charp, 0400);
> +core_param(module_blacklist, module_denylist, charp, 0400);
> +
> +static const char *initcall_get_modname(initcall_t fn)

initcall_get_modname() should ideally be __init.

Nit: I think a better name would be something like get_builtin_modname()
to make clear that the function returns a module name only for built-in
modules, not regular ones.

> +{
> +     struct initcall_modname *p;
> +     unsigned long addr = (unsigned long)dereference_function_descriptor(fn);
> +
> +     if (system_state >= SYSTEM_FREEING_INITMEM)
> +             return NULL;
> +
> +     if (!is_kernel_text(addr) &&
> +         !is_kernel_inittext(addr))
> +             return NULL;

I believe these checks could be avoided if the code was reorganized to
avoid reaching this logic on the do_init_module() path in the first
place.

> +
> +     for (p = __start_initcall_modnames; p < __stop_initcall_modnames; p++) {
> +             if (dereference_function_descriptor(p->initcall_fn) ==
> +                 dereference_function_descriptor(fn))
> +                     return p->modname;
> +     }
> +     return NULL;
> +}
> +
>  int __init_or_module do_one_initcall(initcall_t fn)
>  {
>       int count = preempt_count();
>       char msgbuf[64];
> +     const char *modname;
>       int ret;
>  
> +     modname = initcall_get_modname(fn);
> +     if (modname && module_is_denylisted(modname)) {
> +             pr_info("Skipping initcall for blacklisted built-in module 
> %s\n",
> +                     modname);
> +             return 0;
> +     }
> +
>       if (initcall_blacklisted(fn))
>               return -EPERM;
>  
> diff --git a/kernel/module/main.c b/kernel/module/main.c
> index 46dd8d25a605..e6d9c52b9786 100644
> --- a/kernel/module/main.c
> +++ b/kernel/module/main.c
> @@ -2919,26 +2919,6 @@ int __weak module_frob_arch_sections(Elf_Ehdr *hdr,
>       return 0;
>  }
>  
> -/* module_blacklist is a comma-separated list of module names */
> -static char *module_blacklist;
> -static bool blacklisted(const char *module_name)
> -{
> -     const char *p;
> -     size_t len;
> -
> -     if (!module_blacklist)
> -             return false;
> -
> -     for (p = module_blacklist; *p; p += len) {
> -             len = strcspn(p, ",");
> -             if (strlen(module_name) == len && !memcmp(module_name, p, len))
> -                     return true;
> -             if (p[len] == ',')
> -                     len++;
> -     }
> -     return false;
> -}
> -core_param(module_blacklist, module_blacklist, charp, 0400);
>  
>  static struct module *layout_and_allocate(struct load_info *info, int flags)
>  {
> @@ -3389,9 +3369,9 @@ static int early_mod_check(struct load_info *info, int 
> flags)
>  
>       /*
>        * Now that we know we have the correct module name, check
> -      * if it's blacklisted.
> +      * if it's denylisted.
>        */
> -     if (blacklisted(info->name)) {
> +     if (module_is_denylisted(info->name)) {
>               pr_err("Module %s is blacklisted\n", info->name);
>               return -EPERM;
>       }

-- 
Cheers,
Petr

Reply via email to