A driver is not a program with different imports
A .ko is an ET_REL relocatable object: no entry point, no
loader, no process — just sections that the OS places into its own address space,
relocates, and calls back into. So mwemu supplies the three missing pieces, and
nothing else:
- A linker — places the sections and applies every relocation at load time.
- A kernel to call — each imported symbol
(
kmalloc,mutex_lock,printk…) resolves to a slot that, when called, is intercepted and routed to a Rust implementation. Same trick as the user-mode API layer, pointed at a kernel surface. - An allocator with a memory — the reason this exists.
The idea: a slab that refuses to forget
Driver bugs are lifetime bugs, and they hide because a real slab is helpful: it
hands a freed chunk straight back out, so a stale pointer keeps working right up until
it reads someone else's data. mwemu inverts that. A freed chunk is not recycled — it
goes to quarantine: it stays mapped, poisoned with SLUB's
0x6b6b6b6b…, and every access is checked against a ledger that
remembers who allocated it, where, and where it was freed. Chunks are separated by an
unmapped redzone, so an overflow faults instead of corrupting the neighbour. Keeping
the chunk mapped is what turns an invisible bug into a report — and lets execution
continue past the first stale access, so one run surfaces the whole chain.
The test target: a hidden UAF
tlm is a deliberately vulnerable character driver, written the way a
real one is: refcounted objects in their own kmem_cache, a
mutex-protected list, per-object op vectors, an ioctl surface. The bug is
not "free it, then read it two lines later". It keeps a one-entry
hot-channel cache to skip the list walk on repeated writes:
/*
* One-entry hot-channel cache. It holds no reference on purpose, so the
* rule is that whoever removes a channel from the list also clears it.
*/
struct tlm_channel *fast;
u32 fast_id;
That invariant is honoured on close and on unload — but the author missed the third
way a channel dies: TLM_IOC_DESTROY frees the object while the file handle
stays open. The cache is left dangling, and the next write takes the hot path straight
through it, skipping even the magic check — all the way to an indirect
call:
if (dev->fast && dev->fast_id == req->id)
ch = dev->fast; /* freed object */
...
ret = ch->ops->encode(ch, kbuf, req->len); /* indirect call through it */
Trigger: create a channel, write once (populates the cache), destroy it, write again.
Running it
make driver builds it into test/linux_uaf_driver.ko; the
tests skip themselves if it is absent.
make driver
cargo test -p libmwemu tests::kernel -- --nocapture
Four tests: the module links with no unresolved imports, a legitimate write stays silent, a double-free is caught, and the stale-cache write is reported. That last one:
BUG: KMWEMU: use-after-free (read) in tlm_channel of size 8 at addr 0xffff888000001128
object 0xffff888000001100..0xffff888000001160 (requested 88 bytes, bucket 96), offset 40
allocated by kmem_cache_alloc_noprof at 0xffffffffc000056c (step 86)
freed by kmem_cache_free at 0xffffffffc00004e9 (step 407)
BUG: KMWEMU: use-after-free (poisoned pointer dereference) at addr 0x6b6b6b6b6b6b6b73
Read together, those two lines are the bug. The first is the load of
ch->ops (offset 40) out of the quarantined object, naming its cache and
both the alloc and free sites. The second is the dereference of the pointer that load
produced — 0x6b6b… is free poison, so its provenance is proof, not
a guess.
What it catches
| Finding | How it is decided |
|---|---|
| use-after-free (read / write) | access lands in a quarantined chunk |
| poisoned-pointer deref | the address itself is free poison — the pointer came out of a freed object |
| use-after-free call | an indirect branch target came out of quarantine |
| double-free / invalid free | free of a quarantined chunk, or of something that is not a chunk base |
| slab out-of-bounds | access past the requested size, inside the bucket |
| memory leak | still live after the module's exit path ran |
The memory helpers (memcpy, copy_from_user…) run
through the guard too — a memcpy() into a freed object is a UAF no
instruction-level check would see, because the copy runs in the kernel's code, not the
driver's. Refcounts and deferred work are modelled for real
(call_rcu/timers are queued, not run inline) precisely because that is
where the "free later" half of most UAFs lives.
Driving it
Reaching the ioctl handler needs argument structs in guest memory, so it is driven
programmatically — from Rust, from the CLI (mwemu -f driver.ko -6 -v links
the module and runs its init like insmod), or over an
MCP server so an agent can link a
driver and drive its ioctls conversationally:
let mut emu = libmwemu::emu64();
emu.load_kernel_module("driver.ko")?; // link + relocate
emu.run_module_init()?; // insmod
emu.call_module_symbol("drv_ioctl", &[0, cmd, argp])?;
for f in emu.kernel_findings() { println!("{}", f.report()); }
Why it generalizes
Only the symbol tables and handlers differ between operating systems — placement,
interception, the ledger and the analysis are shared. Windows and macOS already have
their surfaces and pool/zone allocators implemented; what is missing is only the
loader (a .sys is a PE with a DriverEntry, a kext a Mach-O
bundle). The day either loader lands it inherits the whole use-after-free analysis for
free, because the part that finds the bug never cared which OS the driver was for.
References
- mwemu — the emulator (kernel-mode lives in
libmwemu::kernel) - Model Context Protocol — how it is driven from an agent
Reproduced against the tlm reference driver. It is
deliberately vulnerable and only ever linked and executed inside the emulator, never
inserted into a real kernel.