kctl.ko— "a configurable kernel control device. perfectly safe, we promise." You get the module, a strippedbzImage, and an unprivileged shell.
The first thing I want from a kernel target is the same checklist as userland, just with different acronyms:
- 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_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_FREEthe 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.
|
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:
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 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.
|
The fix is one line — the line the author left out:
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.