Re: [PATCH v4] module: Extend module_blacklist parameter to built-in modules

Petr Pavlu <[email protected]>
Newsgroups org.kernel.vger.linux-arch,org.kernel.vger.linux-kernel,org.kernel.vger.linux-modules
Message-ID <[email protected]>
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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.