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

userfaultfd & FUSE — freezing a race until it's deterministic

2026-04-203 min readkernel pwn

Plenty of kernel bugs are "exploitable in theory": a use-after-free where you must reclaim the freed object with controlled data before the kernel touches it again, or a double-fetch where a value must change between two reads. The window is microseconds — racing it blind is a coin flip. The trick is to stop flipping coins and pause the kernel at exactly the wrong moment for it and the right one for you.

target./vuln.ko
arch
amd64-64-little
bug
UAF — narrow race between free and reuse
goal
turn the race into a deterministic, single-shot reclaim

userfaultfd: handle your own page faults#

userfaultfd(2) lets a process register a memory range and handle the page faults for it in userland. When anything — including the kernel doing copy_from_user on your behalf — touches an unpopulated page in that range, the faulting context blocks until your handler resolves it. That blocked context is your lever:

the frozen-mid-copy tricktxt
1
2
3
4
5
6
1. mmap a buffer; register its 2nd page with userfaultfd, leave it unpopulated.
2. hand the kernel a struct that straddles the page boundary.
3. kernel copy_from_user: copies page 1, then FAULTS on page 2  ->  BLOCKS.
   |  the syscall is now frozen, half-done, for as long as you like.
4. meanwhile, in another thread: free() the object, groom the heap, reclaim it.
5. resolve the fault (UFFDIO_COPY) -> the copy finishes into the new state.

The kernel is stuck inside the vulnerable operation while you rearrange the world beneath it. A 2-microsecond race becomes a deliberate sequence:

uffd handler (sketch)c
1
2
3
4
5
6
7
8
9
long uffd = syscall(SYS_userfaultfd, O_CLOEXEC | O_NONBLOCK);
// ... UFFDIO_API + UFFDIO_REGISTER the page ...
void *fault_handler(void *_) {
    struct uffd_msg msg; read(uffd, &msg, sizeof msg);   // blocks until the fault
    // *** kernel is frozen here, mid copy_from_user ***
    do_free_and_reclaim();                                // win the race at leisure
    ioctl(uffd, UFFDIO_COPY, &copy);                      // let the kernel continue
    return NULL;
}

when userfaultfd is locked down: FUSE#

Defenders noticed. vm.unprivileged_userfaultfd=0 (default on many distros since ~5.2) restricts uffd, and seccomp sandboxes routinely block the syscall outright. The fallback gives the same primitive through the filesystem:

  • back a file with a FUSE (filesystem-in-userspace) mount;
  • mmap that file and pass the mapping to the kernel;
  • when the kernel reads an un-cached page, it calls into your FUSE read handler — which blocks exactly like the uffd handler, same controllable window.

FUSE needs no special privilege beyond a user mount, so it survives where uffd doesn't. Same idea, different doorway.

a coin flip becomes a single deterministic shot

With the copy frozen, the reclaim isn't a race at all — it's a step:

txt
1
2
3
4
[*] fault caught, kernel parked mid-copy
[*] freed victim, sprayed replacement objects
[*] resumed copy -> wrote into our controlled object
segfault{the_kernel_waited_for_me}

the defensive view#

  • vm.unprivileged_userfaultfd=0 and seccomp-blocking userfaultfd close the easy door; FUSE and madvise(MADV_DONTNEED) tricks keep a narrower one open.
  • The deeper fix is the bug class: don't copy_from_user into a structure whose lifetime another thread can change mid-copy, and don't double-fetch user memory. The race primitive only matters because the window exists at all.

These aren't bugs themselves — they're race amplifiers. A flaky one-in-a- thousand reclaim becomes a 100% deterministic exploit because you stopped letting the kernel run at full speed and made one of its faults wait for your permission.