Linux kernel module autoloading can often significantly increase the overall attack surface. Depending on the distribution configuration, certain outdated / often unused modules can be loaded by non-privileged users. From the attacker's perspective, module autoloading is obviously useful if the vulnerability is present in the module that can be loaded by a non-privileged user to trigger the bug. It can also be useful as an exploitation technique such as triggering module loading after overwriting the modprobe path with a path to your controlled binary.
The following article describes the module autoloading process implemented by the Linux kernel. One of the common uses of this process is network modules when certain protocol implementations are compiled as modules and requested from user space.
For example, DCCP (Datagram Congestion Control Protocol) implementation is generally compiled as a module on most mainstream distributions. When a socket is created in user space:
int sock_fd = socket(AF_INET, SOCK_DCCP, IPPROTO_DCCP);
the first argument represents the protocol family and the last argument is the actual protocol to be used under the chosen protocol family. The following execution path is triggered on socket creation:
int __sock_create(struct net *net, int family, int type, int protocol, struct socket **res, int kern) { ... if (family < 0 || family >= NPROTO) return -EAFNOSUPPORT; if (type < 0 || type >= SOCK_MAX) return -EINVAL; ... #ifdef CONFIG_MODULES ... if (rcu_access_pointer(net_families[family]) == NULL) request_module("net-pf-%d", family); [1] #endif ... pf = rcu_dereference(net_families[family]); err = -EAFNOSUPPORT; if (!pf) goto out_release; ... err = pf->create(net, sock, protocol, kern); [2] if (err < 0) goto out_module_put;
The family
input argument for the example above is AF_INET
. If the entire family is not present, the kernel attempts to autoload the module in [1]. request_module
is a macro for the following function:
int __request_module(bool wait, const char *fmt, ...) { va_list args; char module_name[MODULE_NAME_LEN]; int ret; ... if (!modprobe_path[0]) return 0; va_start(args, fmt); ret = vsnprintf(module_name, MODULE_NAME_LEN, fmt, args); va_end(args); if (ret >= MODULE_NAME_LEN) return -ENAMETOOLONG; ret = security_kernel_module_request(module_name); if (ret) return ret; ... trace_module_request(module_name, wait, _RET_IP_); ret = call_modprobe(module_name, wait ? UMH_WAIT_PROC : UMH_WAIT_EXEC); [3] atomic_inc(&kmod_concurrent_max); wake_up(&kmod_wq); return ret; }
In [3], the kernel executes call_modprobe
which creates a user-space process with root privileges. modprobe_path
is a global variable (char [256]
) pointing to /sbin/modprobe
by default (and can be set to an arbitrary path via sysctl kernel.modprobe
).
static int call_modprobe(char *module_name, int wait) { struct subprocess_info *info; static char *envp[] = { "HOME=/", "TERM=linux", "PATH=/sbin:/usr/sbin:/bin:/usr/bin", NULL }; char **argv = kmalloc(sizeof(char *[5]), GFP_KERNEL); if (!argv) goto out; module_name = kstrdup(module_name, GFP_KERNEL); if (!module_name) goto free_argv; argv[0] = modprobe_path; argv[1] = "-q"; argv[2] = "--"; argv[3] = module_name; /* check free_modprobe_argv() */ argv[4] = NULL; info = call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL, NULL, free_modprobe_argv, NULL); if (!info) goto free_module_name; return call_usermodehelper_exec(info, wait | UMH_KILLABLE); [4] ...
The argument to modprobe is net-pf-n
where n
is the numeric value for the address family (i.e. AF_INET
in our example). /sbin/modprobe
resolves the net-pf-n alias and loads the requested module. All these module aliases are listed in /lib/modules/`uname -r`/modules.alias
.
# grep net-pf- /lib/modules/`uname -r`/modules.alias ... alias net-pf-28 mpls_router alias net-pf-26 llc2 alias net-pf-15 af_key alias net-pf-4 ipx alias net-pf-5 appletalk alias net-pf-9 x25 alias net-pf-6 netrom alias net-pf-11 rose alias net-pf-3 ax25 alias net-pf-29 can alias net-pf-31 bluetooth alias net-pf-33 rxrpc alias net-pf-41 kcm alias net-pf-20 atm alias net-pf-8 atm ...
And all protocol address families supported by the kernel are listed in include/linux/socket.h
:
/* Supported address families. */ #define AF_UNSPEC 0 #define AF_UNIX 1 /* Unix domain sockets */ #define AF_LOCAL 1 /* POSIX name for AF_UNIX */ #define AF_INET 2 /* Internet IP Protocol */ #define AF_AX25 3 /* Amateur Radio AX.25 */ #define AF_IPX 4 /* Novell IPX */ #define AF_APPLETALK 5 /* AppleTalk DDP */ #define AF_NETROM 6 /* Amateur Radio NET/ROM */ #define AF_BRIDGE 7 /* Multiprotocol bridge */ #define AF_ATMPVC 8 /* ATM PVCs */ #define AF_X25 9 /* Reserved for X.25 project */ #define AF_INET6 10 /* IP version 6 */ #define AF_ROSE 11 /* Amateur Radio X.25 PLP */ #define AF_DECnet 12 /* Reserved for DECnet project */ #define AF_NETBEUI 13 /* Reserved for 802.2LLC project*/ #define AF_SECURITY 14 /* Security callback pseudo AF */ #define AF_KEY 15 /* PF_KEY key management API */ #define AF_NETLINK 16 ...
Once the network family is loaded (if required), __sock_create()
then executes create()
in [2] (provided by the address family) function with protocol and socket type parameters passed to sys_socket()
. For example, if AF_INET
is specified (as in our example above), __sock_create
will execute inet_create()
:
static int inet_create(struct net *net, struct socket *sock, int protocol, int kern) { ... lookup_protocol: err = -ESOCKTNOSUPPORT; rcu_read_lock(); list_for_each_entry_rcu(answer, &inetsw[sock->type], list) { err = 0; /* Check the non-wild match. */ if (protocol == answer->protocol) { if (protocol != IPPROTO_IP) break; } else { /* Check for the two wild cases. */ if (IPPROTO_IP == protocol) { protocol = answer->protocol; break; } if (IPPROTO_IP == answer->protocol) break; } err = -EPROTONOSUPPORT; } if (unlikely(err)) { if (try_loading_module < 2) { rcu_read_unlock(); /* * Be more specific, e.g. net-pf-2-proto-132-type-1 * (net-pf-PF_INET-proto-IPPROTO_SCTP-type-SOCK_STREAM) */ if (++try_loading_module == 1) request_module("net-pf-%d-proto-%d-type-%d", [5] PF_INET, protocol, sock->type); /* * Fall back to generic, e.g. net-pf-2-proto-132 * (net-pf-PF_INET-proto-IPPROTO_SCTP) */ else request_module("net-pf-%d-proto-%d", [6] PF_INET, protocol); goto lookup_protocol; } else goto out_rcu_unlock; } ...
This function iterates over the available protocols and if the match is found, attempts to autoload the module in [5] or [6].
Depending on the distribution's configuration, certain protocol families might be blacklisted - cannot be autoloaded by non-privileged users; privileged users can still load them manually. For example, on Ubuntu Bionic (18.04) the following protocols are blacklisted:
for p in `grep -v "^#" /etc/modprobe.d/blacklist-rare-network.conf | cut -d" " -f2`; do grep "$p " /lib/modules/`uname -r`/modules.alias | cut -d" " -f3; done ax25 decnet ieee802154_socket netrom rds rose x25
Most mainstream distributions blacklist these network protocols which significantly reduces the attack surface. For example, CVE-2019-1181 is given a CVSS base score of 8.1 yet the actual risk is almost non-existent! On most distributions this protocol is either not compiled into the kernel at all or compiled as a module and then blacklisted (see above).
Furthermore, some protocols implement capability checks (either within the current or init user/network namespaces). For example, the AF_PACKET
network family requires CAP_SYS_RAW
capability but depending on the distribution's configuration, this capability can be obtained by a non-privileged user through a namespace. Namespaces is another feature that drastically increases the kernel attack surface but we will leave this for another post.