Re: [PATCH v4] module: Extend module_blacklist parameter to built-in modules
Petr Pavlu <[email protected]> Tue, 14 Jul 2026 16:46:08 +0200
| Newsgroups | org.kernel.vger.linux-modules,org.kernel.vger.linux-arch,org.kernel.vger.linux-kernel |
|---|---|
| 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