Controlling kernel modules

Autoloading, blacklists and locking loading.

Intermediate14 min · lesson 20 of 24

A kernel module is a piece of kernel code, usually a driver, filesystem or network protocol, that is loaded into the running kernel when something needs it. Once loaded it runs with the kernel's full privilege, so every module that can be loaded is attack surface, and some can be loaded by an ordinary user without any privilege at all. In this lesson you watch an unprivileged program pull a protocol module into the kernel, measure what Ubuntu and RHEL already block, and learn the difference between the two modprobe.d rules people use: blacklist, which closes one path, and install ... /bin/false, which closes them all. You also see what a rule does not reach (the initramfs, insmod, modules already loaded) and the stronger controls behind it.

How a module gets loaded without anyone asking

The kernel loads most modules on demand. When a program opens a socket for a protocol family the kernel has no code for, mounts a filesystem type it does not know, or a device appears, the kernel builds a name for what it needs (an alias such as net-pf-29, "network protocol family 29") and asks modprobe to provide it. modprobe reads its configuration in /etc/modprobe.d/ and /usr/lib/modprobe.d/, matches the alias against the aliases each module declares, and inserts the module with any others it depends on. The request runs as root, whoever triggered it.

How an autoload reaches modprobe.d
1A program needs a feature
socket(AF_CAN, ...), a mount, a device
2The kernel requests an alias
net-pf-29, can-proto-1
3modprobe reads modprobe.d
install, blacklist and alias rules
4A rule refuses, or it loads
install ... /bin/false stops it here
5The module runs in the kernel
with the kernel's full privilege
The request runs as root even when an unprivileged program triggered it. modprobe.d is the one point where configuration can say no.

That is how a bug in an obscure protocol becomes a local privilege escalation: an unprivileged user opens one socket, the kernel loads the buggy code, and the user attacks it. The textbook case, DCCP (CVE-2017-6074), is history now: DCCP was removed from the kernel in Linux 6.16, and neither lab kernel has it. The mechanism is unchanged, though. modinfo shows the aliases a module answers to; SCTP answers to "family 2 or 10 (IPv4, IPv6), protocol 132", and the CAN bus modules to family 29:

deploy@web01 · Ubuntu 26.04 LTS
$ modinfo -F alias sctp modinfo -F alias can modinfo -F alias can_raw
net-pf-10-proto-132 net-pf-2-proto-132 net-pf-29 can-proto-1

CAN (Controller Area Network) is a vehicle and industrial bus, and a server has no use for it, which makes it a clean example. As the unprivileged deploy user, create one CAN socket and compare lsmod (the list of loaded modules) before and after:

deploy@web01 · Ubuntu 26.04 LTS
$ lsmod | grep -c '^can' python3 -c 'import socket; socket.socket(socket.AF_CAN, socket.SOCK_RAW, socket.CAN_RAW); print("CAN socket created")' lsmod | grep '^can'
0 CAN socket created can_raw 28672 0 can 32768 1 can_raw
$ sudo modprobe -r can_raw lsmod | grep -c '^can'
0

One socket loaded two modules into the kernel. modprobe -r removed them again because nothing was using them. That is not always possible: in the same experiment with SCTP, the module stayed "in use" after the socket closed and could not be removed, so on that VM only a reboot takes it out. A rule you write later affects the next load, not what is already in memory.

What Ubuntu and RHEL already block

Measure before you write rules. Ubuntu ships a set of files in /etc/modprobe.d/ and /usr/lib/modprobe.d/; most blacklist drivers that conflict with better ones. The security-relevant one is blacklist-rare-network.conf:

deploy@web01 · Ubuntu 26.04 LTS
$ ls /etc/modprobe.d/ /usr/lib/modprobe.d/
/etc/modprobe.d/: blacklist-ath_pci.conf blacklist-firewire.conf blacklist-framebuffer.conf blacklist-rare-network.conf blacklist.conf iwlwifi.conf mdadm.conf /usr/lib/modprobe.d/: aliases.conf blacklist_linux_7.0.0-31-generic.conf blacklist_linux_7.0.0-34-generic.conf fbdev-blacklist.conf systemd.conf
$ cat /etc/modprobe.d/blacklist-rare-network.conf
# Many less commonly used network protocols have recently had various # security flaws discovered. In an effort to reduce the scope of future # vulnerability exploitations, they are being blacklisted here so that # unprivileged users cannot use them by default. System owners can still # either modify this file, or specifically modprobe any needed protocols. # ax25 alias net-pf-3 off # netrom alias net-pf-6 off # x25 alias net-pf-9 off # rose alias net-pf-11 off # decnet alias net-pf-12 off # econet alias net-pf-19 off # rds alias net-pf-21 off # af_802154 alias net-pf-36 off

alias net-pf-21 off points the alias for RDS sockets at a module named off, which does not exist, so an unprivileged RDS socket cannot load anything. The list covers old protocol families; SCTP, TIPC and CAN are not on it. RHEL goes further: many rarely used modules are not in the base kernel packages at all but in kernel-modules-extra, and that package ships blacklist files for the protocols in it that a user could autoload.

deploy@rocky10 · Rocky Linux 10.2
$ ls /etc/modprobe.d/ /usr/lib/modprobe.d/
/etc/modprobe.d/: firewalld-sysctls.conf l2tp_eth-blacklist.conf l2tp_ip6-blacklist.conf l2tp_ip-blacklist.conf l2tp_netlink-blacklist.conf l2tp_ppp-blacklist.conf lockd.conf sctp-blacklist.conf sctp_diag-blacklist.conf tipc_diag-blacklist.conf /usr/lib/modprobe.d/: dist-blacklist.conf README systemd.conf
$ cat /etc/modprobe.d/sctp-blacklist.conf rpm -qf /etc/modprobe.d/sctp-blacklist.conf
# This kernel module can be automatically loaded by non-root users. To # enhance system security, the module is blacklisted by default to ensure # system administrators make the module available for use as needed. # See https://access.redhat.com/articles/3760101 for more details. # # Remove the blacklist by adding a comment # at the start of the line. blacklist sctp kernel-modules-extra-6.12.0-211.60.1.el10_2.aarch64

The CIS RHEL 10 Level 1 profile in scap-security-guide 0.1.82 lists modules to disable: old filesystems (cramfs, freevxfs, hfs, hfsplus, jffs2), usb-storage, firewire-core, and the dccp, rds, sctp, tipc, can and atm protocols; many hardening lists add udf and squashfs. Check which of them exist on each kernel before writing anything. modinfo -F filename prints the module file, (builtin) for code compiled into the kernel, or an error when there is nothing to block:

deploy@web01 · Ubuntu 26.04 LTS
$ for m in cramfs freevxfs hfs hfsplus jffs2 udf squashfs usb-storage dccp rds sctp tipc can; do printf "%-12s " $m; modinfo -F filename $m 2>&1; done
cramfs /lib/modules/7.0.0-34-generic/kernel/fs/cramfs/cramfs.ko.zst freevxfs /lib/modules/7.0.0-34-generic/kernel/fs/freevxfs/freevxfs.ko.zst hfs /lib/modules/7.0.0-34-generic/kernel/fs/hfs/hfs.ko.zst hfsplus /lib/modules/7.0.0-34-generic/kernel/fs/hfsplus/hfsplus.ko.zst jffs2 /lib/modules/7.0.0-34-generic/kernel/fs/jffs2/jffs2.ko.zst udf /lib/modules/7.0.0-34-generic/kernel/fs/udf/udf.ko.zst squashfs (builtin) usb-storage /lib/modules/7.0.0-34-generic/kernel/drivers/usb/storage/usb-storage.ko.zst dccp modinfo: ERROR: Module dccp not found. rds /lib/modules/7.0.0-34-generic/kernel/net/rds/rds.ko.zst sctp /lib/modules/7.0.0-34-generic/kernel/net/sctp/sctp.ko.zst tipc /lib/modules/7.0.0-34-generic/kernel/net/tipc/tipc.ko.zst can /lib/modules/7.0.0-34-generic/kernel/net/can/can.ko.zst
deploy@rocky10 · Rocky Linux 10.2
$ for m in cramfs freevxfs hfs hfsplus jffs2 udf squashfs usb-storage dccp rds sctp tipc; do printf "%-12s " $m; modinfo -F filename $m 2>&1; done
cramfs modinfo: ERROR: Module cramfs not found. freevxfs modinfo: ERROR: Module freevxfs not found. hfs modinfo: ERROR: Module hfs not found. hfsplus modinfo: ERROR: Module hfsplus not found. jffs2 modinfo: ERROR: Module jffs2 not found. udf /lib/modules/6.12.0-211.16.1.el10_2.0.1.aarch64/kernel/fs/udf/udf.ko.xz squashfs modinfo: ERROR: Module squashfs not found. usb-storage /lib/modules/6.12.0-211.16.1.el10_2.0.1.aarch64/kernel/drivers/usb/storage/usb-storage.ko.xz dccp modinfo: ERROR: Module dccp not found. rds modinfo: ERROR: Module rds not found. sctp modinfo: ERROR: Module sctp not found. tipc modinfo: ERROR: Module tipc not found.
$ python3 -c 'import socket; socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_SCTP); print("SCTP socket created")'
… OSError: [Errno 93] Protocol not supported

On Ubuntu most of the list exists as loadable modules, and squashfs is built in: modprobe.d cannot stop built-in code, and snap packages need it anyway. On the Rocky lab only udf and usb-storage exist for the running kernel, so the unprivileged SCTP socket fails with "Protocol not supported" because there is nothing to load. Write rules for what exists and what the host does not need; a rule for a module that is not there is harmless but proves nothing.

blacklist against install /bin/false

The modprobe.d(5) man page describes blacklist narrowly: it makes modprobe ignore a module's own internal aliases. Test exactly that. With blacklist can in place, the unprivileged socket no longer loads anything, and Python reports "Address family not supported by protocol":

deploy@web01 · Ubuntu 26.04 LTS
$ echo 'blacklist can' | sudo tee /etc/modprobe.d/secopslog-modules.conf python3 -c 'import socket; socket.socket(socket.AF_CAN, socket.SOCK_RAW, socket.CAN_RAW); print("CAN socket created")'
blacklist can … OSError: [Errno 97] Address family not supported by protocol

The alias path is closed, but nothing else is. Loading the module by name still works, and so does loading a module that depends on it: can_raw needs can, and modprobe inserts can without looking at the blacklist. Another module, or anyone with sudo, can still bring it in.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo modprobe can lsmod | grep '^can' sudo modprobe -r can
can 32768 0
$ sudo modprobe can_raw lsmod | grep '^can' sudo modprobe -r can_raw
can_raw 28672 0 can 32768 1 can_raw

install MODULE COMMAND tells modprobe to run COMMAND instead of inserting the module, on every path that goes through modprobe: by alias, by name, and as a dependency. /bin/false does nothing and fails, so every attempt fails visibly. (/bin/true also stops the load, but reports success, which hides the attempt from scripts.) Keep the blacklist line too; it is what RHEL ships and what compliance scanners look for.

/etc/modprobe.d/secopslog-modules.conf
# SecOpsLog: this host has no CAN bus. "install" makes every modprobe of
# can run /bin/false instead: by name, by alias, or as a dependency.
install can /bin/false
# "blacklist" also ignores the module's own aliases (the form RHEL ships).
blacklist can

modprobe -n -v is a dry run: it prints what modprobe would do without doing it, and needs no root. For can_raw it shows the install command for the dependency first. Then every path fails, and lsmod confirms nothing was loaded:

deploy@web01 · Ubuntu 26.04 LTS
$ modprobe -n -v can modprobe -n -v can_raw
install /bin/false install /bin/false insmod /lib/modules/7.0.0-34-generic/kernel/net/can/can-raw.ko.zst
$ python3 -c 'import socket; socket.socket(socket.AF_CAN, socket.SOCK_RAW, socket.CAN_RAW); print("CAN socket created")'
… OSError: [Errno 97] Address family not supported by protocol
$ sudo modprobe can
modprobe: ERROR: Error running install command '/bin/false' for module can: retcode 1 modprobe: ERROR: could not insert 'can': Invalid argument
$ sudo modprobe can_raw
modprobe: ERROR: Error running install command '/bin/false' for module can: retcode 1 modprobe: ERROR: could not insert 'can_raw': Invalid argument
$ lsmod | grep -c '^can'
0

The same gap explains why RHEL ships a separate blacklist file for sctp_diag: it has aliases of its own and depends on sctp, so blacklisting sctp alone would leave a way to load it. An install rule for the base module closes that gap. The CIS RHEL 10 profile checks for install MODULE /bin/false (it accepts /bin/true too). These rules are CIS and SecOpsLog recommendations, not distribution defaults, apart from Ubuntu's alias ... off list and RHEL's blacklist files.

Boot, impact and rollback

Early boot runs from the initramfs, a small filesystem image the boot loader loads with the kernel, and it carries its own copy of /etc/modprobe.d from the day it was built. Both Ubuntu 26.04 and RHEL 10 build it with dracut; on Ubuntu, update-initramfs is dracut's compatibility wrapper. The lab's initramfs has Ubuntu's seven files and not the new one:

deploy@web01 · Ubuntu 26.04 LTS
$ dpkg -S /usr/sbin/update-initramfs sudo lsinitrd /boot/initrd.img-$(uname -r) | grep 'etc/modprobe.d/'
dracut: /usr/sbin/update-initramfs -rw-r--r-- 1 root root 324 Feb 2 2026 etc/modprobe.d/blacklist-ath_pci.conf -rw-r--r-- 1 root root 210 Feb 2 2026 etc/modprobe.d/blacklist-firewire.conf -rw-r--r-- 1 root root 677 Feb 2 2026 etc/modprobe.d/blacklist-framebuffer.conf -rw-r--r-- 1 root root 583 Feb 2 2026 etc/modprobe.d/blacklist-rare-network.conf -rw-r--r-- 1 root root 1518 Feb 2 2026 etc/modprobe.d/blacklist.conf -rw-r--r-- 1 root root 347 Feb 2 2026 etc/modprobe.d/iwlwifi.conf -rw-r--r-- 1 root root 379 Aug 2 18:51 etc/modprobe.d/mdadm.conf

A rule for a network protocol only matters once the system is up, so the running system's copy is enough. A rule for a storage or filesystem driver that the initramfs may load needs a rebuild to apply from the first second: sudo update-initramfs -u on Ubuntu, sudo dracut -f --regenerate-all on RHEL. The lab does not rebuild it, because a broken initramfs is a host that does not boot; do it where you have a console, and keep the previous kernel's entry in the boot menu as the fallback.

Operational impact comes from blocking something the host turns out to need. udf reads DVD images and the provisioning ISO that some cloud platforms (Azure among them) attach at first boot. usb-storage is how people plug in a disk at a physical console. squashfs is built into Ubuntu's kernel and cannot be blocked there. Before you roll a rule out, check lsmod on a sample of hosts for anything on your list. Rollback is to delete the file (and rebuild the initramfs if you rebuilt it for the rule); the next load works at once:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo rm /etc/modprobe.d/secopslog-modules.conf python3 -c 'import socket; socket.socket(socket.AF_CAN, socket.SOCK_RAW, socket.CAN_RAW); print("CAN socket created")' lsmod | grep '^can' sudo modprobe -r can_raw
CAN socket created can_raw 28672 0 can 32768 1 can_raw

Stronger controls: the latch, signatures and audit

modprobe.d binds modprobe and nothing else. Root can insert a module file directly with insmod, which reads no configuration. Three controls reach further, and you can read their state without changing anything:

deploy@web01 · Ubuntu 26.04 LTS
$ cat /proc/sys/kernel/modules_disabled /proc/sys/kernel/tainted /sys/kernel/security/lockdown modinfo -F signer can
0 0 [none] integrity confidentiality Build time autogenerated kernel key

kernel.modules_disabled is 0. Writing 1 stops all module loading and unloading, by any path including insmod, until reboot; it is a one-way latch and cannot be set back to 0. That is its value (root cannot undo it either) and its danger: everything the host will ever need must be loaded before the latch is set. Put in a sysctl.d file, it is applied early in boot by systemd-sysctl, and every module needed after that point fails to load for the rest of the boot: nf_tables (a module on Ubuntu) when the firewall loads its ruleset, a driver for hardware plugged in later, a filesystem mounted later. It suits single-purpose appliances where the full module set is known and tested; the lab only reads it.

/proc/sys/kernel/tainted is 0, meaning no unsigned, out-of-tree or forced module has been loaded this boot; a non-zero value on a host that should run only distribution modules is worth investigating. The shipped can module is signed with the kernel build key, but with lockdown at [none] an unsigned module would still load; the disk encryption and boot integrity lesson explains signature enforcement through Secure Boot and lockdown. For a record of who loaded what, the auditd lesson's modules rule already logs every init_module, finit_module and delete_module call, and auditd ships the same rules as 43-module-load.rules.

Try this

Repeat the experiment with another CAN protocol on a lab Ubuntu machine. As an ordinary user, run python3 -c 'import socket; socket.socket(socket.AF_CAN, socket.SOCK_DGRAM, socket.CAN_BCM)' and check lsmod | grep '^can': can_bcm and can are loaded. Remove them with sudo modprobe -r can_bcm. Now write blacklist can_bcm into /etc/modprobe.d/can-bcm.conf and predict, before running each, whether the socket, sudo modprobe can_bcm and modprobe -n -v can_bcm will succeed; then run them and compare. Unload can_bcm again, replace the file's contents with install can_bcm /bin/false and blacklist can_bcm, predict again, and run the dry run and sudo modprobe can_bcm. Delete the file when you are done. What the lab saw: with the blacklist, the socket failed with "Protocol not supported" (the CAN address family was there, the BCM protocol module was not loaded), while the load by name worked; with the install line, the dry run printed install /bin/false and sudo modprobe can_bcm failed.

Takeaway

Check which listed modules exist on each kernel, then block the ones the host never needs with install MODULE /bin/false plus blacklist, because blacklist alone leaves loading by name and by dependency open. Remember what the file does not reach: modules already loaded, the initramfs until you rebuild it, and insmod, which only kernel.modules_disabled or enforced signatures stop.

Quick check
01Your hosts have only "blacklist can" in /etc/modprobe.d. An unprivileged CAN socket fails as expected, yet after a colleague runs sudo modprobe can_raw, lsmod shows can loaded. Why?
Correct — The lab shows exactly this; install can /bin/false is what stops the dependency path too.
Incorrect — modprobe reads its configuration whoever runs it; the install rule in the lab stopped sudo modprobe can_raw.
Incorrect — modprobe reads modprobe.d on every run; the socket failure right after the edit proves it was read.
Incorrect — can_raw declares can as a dependency and modprobe loads can separately, as lsmod shows.
02You add "install usb-storage /bin/false" on a fleet and verify it with modprobe -n -v, yet on a host rebooted with a USB disk attached, usb-storage is loaded before the root filesystem is mounted. What was missed?
Incorrect — Device-triggered loads go through modprobe too, and the install rule applies to them in the running system.
Incorrect — On the lab kernel usb-storage is a loadable module (modinfo shows its .ko file); built-in code would show (builtin).
Incorrect — Both commands stop the load; the difference is only whether modprobe reports failure.
Correct — Early boot uses the initramfs copy of /etc/modprobe.d, which only changes when the image is rebuilt.
03A host has install rules for every module on your list. Why might you still set kernel.modules_disabled=1 on it, and what does that cost?
Incorrect — The latch is about security, not speed; it blocks module loading and unloading of any kind.
Correct — The latch stops every module load, including root's insmod, and it cannot be cleared, so any module needed later fails.
Incorrect — The latch has nothing to do with where modprobe.d is read; it stops loading altogether.
Incorrect — The latch removes nothing; it also forbids unloading, so loaded modules stay.

Related