How Page Fault Handling Wins userfaultfd Race Conditions
Learn how userfaultfd race conditions stall Linux kernel threads during copy_from_user, turning microsecond windows into reliable use-after-free exploits.

Kernel concurrency bugs are notoriously difficult to trigger in production systems. A typical race condition between two system calls might span an execution window of less than twenty nanoseconds. An engineer attempting to hit a use-after-free or a double fetch under default scheduling conditions must spray millions of syscall invocations across multiple CPU cores, hoping that thread preemption or cache contention aligns the execution paths. In Linux kernel exploitation, userfaultfd race conditions eliminate this statistical lottery. By moving page fault resolution into user space, an attacker can pause kernel thread execution indefinitely at the exact byte boundary where memory transfers occur.
When a kernel routine invokes helper functions like copy_from_user() or get_user(), it assumes memory access either succeeds immediately or returns -EFAULT. If the source user buffer points to an unmapped page registered with the userfaultfd subsystem, the hardware page fault traps into the kernel. The kernel suspends the executing thread and dispatches an event to a user-space file descriptor. The user process holds the kernel thread frozen in mid-syscall for as long as desired. During this artificial pause, companion threads free objects, reallocate slab caches, or rewrite data structures before permitting the faulted thread to resume.
Understanding how this mechanism operates exposes why kernel data copying is fraught with concurrency hazards. Standard locking primitives cannot protect user copies because holding a spinlock while sleeping on a user-space fault triggers an immediate kernel panic. Investigating these execution mechanics shows how a benign memory management feature became one of the most reliable building blocks for modern Linux privilege escalation.
Anatomy of copy_from_user and the Kernel Page Fault Trap
The Linux kernel splits virtual memory into distinct halves: user space and kernel space. Kernel code cannot read directly from user-space pointers without validation. Doing so risks dereferencing invalid addresses, triggering unhandled page faults in supervisor mode, or violating Virtual Memory Area (VMA) permission boundaries. The kernel mediates data transfers across this trust boundary via functions such as copy_from_user() and _copy_to_user().
Under the hood on x86_64, copy_from_user() maps to raw_copy_from_user(), which executes optimized assembly instructions like rep movsb or iterative mov sequences. Before touching the buffer, the kernel executes access_ok(), checking that the entire memory range resides strictly within user-space boundaries. However, access_ok() does not verify whether the pages backing that range are physically allocated, present in the page tables, or mapped with valid permissions. Checking presence beforehand would invite Time-of-Check to Time-of-Use (TOCTOU) races and destroy throughput.
When the CPU executes a read or write instruction against an address whose page table entry (PTE) lacks the Present bit, the Memory Management Unit (MMU) halts instruction execution and raises an interrupt: vector 14, the Page Fault exception (#PF). The CPU pushes an error code and the faulting address into the CR2 register, transitions to ring 0, and jumps to the kernel entry point asm_exc_page_fault.
User Space Kernel Space (Ring 0) Hardware (MMU)
| | |
|-- syscall(buffer_ptr) ----->| |
| |-- copy_from_user(buffer_ptr) --->|
| | |-- [PTE not present]
| | |-- Raises Vector 14 (#PF)
| |<-- asm_exc_page_fault -----------|
| |-- do_user_addr_fault() |
In normal operation, the page fault handler calls do_user_addr_fault() in arch/x86/mm/fault.c. The kernel walks the task's mm_struct, locates the corresponding VMA, and calls handle_mm_fault(). As analyzed in How Linux Page Fault Handling Impacts Memory Throughput, demand paging allocates a physical page frame, updates the PTE, and resumes user-space execution transparently. If the address falls outside valid VMAs, the kernel consults its internal exception tables (__ex_table). If the fault occurred inside an authorized copy helper, the fixup routine catches it and returns the number of uncopied bytes, causing copy_from_user() to return -EFAULT.
This flow changes completely when a VMA has the VM_UFFD_MISSING flag set. Instead of allocating physical memory or aborting through the exception table, the kernel diverts control to the user-space fault handling subsystem.
Why userfaultfd Race Conditions Turn Microseconds into Minutes
The userfaultfd mechanism was merged in Linux kernel 4.3 to support live process migration and on-demand paging for hypervisors like QEMU/KVM. When migrating a virtual machine across physical nodes, the destination hypervisor starts the guest before transferring its entire memory footprint. If the guest touches a page not yet transferred over the network, userfaultfd intercepts the fault, allows the hypervisor to fetch the page from the network, populates the physical frame, and unfreezes the guest CPU.
To configure this interception, an application opens a file descriptor using the userfaultfd() system call, passing flags like O_CLOEXEC and O_NONBLOCK. The application then registers an anonymous memory range with the kernel using the UFFDIO_REGISTER ioctl, specifying UFFDIO_REGISTER_MODE_MISSING.
#include
#include
#include
#include
#include
#include
#include
int init_uffd(void *addr, size_t len) {
long uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd < 0) return -1;
struct uffdio_api uffdio_api = {
.api = UFFD_API,
.features = 0
};
if (ioctl(uffd, UFFDIO_API, &uffdio_api) < 0) {
close(uffd);
return -1;
}
struct uffdio_register uffdio_register = {
.range = {
.start = (unsigned long)addr,
.len = len
},
.mode = UFFDIO_REGISTER_MODE_MISSING
};
if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) < 0) {
close(uffd);
return -1;
}
return uffd;
}
When a kernel thread calls copy_from_user() on a buffer backed by this registered range, execution reaches handle_userfault() in fs/userfaultfd.c. The kernel checks if the fault occurred on behalf of user space or during an unhandled kernel-mode copy:
/* fs/userfaultfd.c snippet */
int handle_userfault(struct vm_fault *vmf, unsigned long reason) {
struct mm_struct *mm = vmf->vma->vm_mm;
struct userfaultfd_ctx *ctx;
struct uffd_msg msg;
/* ... validation logic ... */
set_current_state(TASK_KILLABLE);
/* Enqueue fault message and wake up monitoring process */
wake_up_interruptible_poll(&ctx->fault_pending_wqh, EPOLLIN);
/* Block kernel execution path until resolved */
schedule();
}
The calling thread does not return an error, nor does it spin on a CPU core. It yields its time slice and transitions into TASK_KILLABLE or TASK_INTERRUPTIBLE. The kernel places the thread onto a wait queue: ctx->fault_wqh.
This transition transforms vulnerability engineering. Under conventional conditions, winning a race condition in the kernel requires microsecond precision. If a vulnerable driver checks a pointer and dereferences it three instructions later, an exploit thread must race across those three instructions to replace the underlying object. When the driver reads from a user buffer registered with userfaultfd, the kernel thread halts in the middle of copy_from_user(). The race window expands from 20 nanoseconds to indefinitely long. An attacker can keep the thread asleep for seconds, minutes, or hours while preparing the exact memory layout required for reliable code execution.
Exploiting Double Fetches and Heap Re-allocation in Slub
The primary targets for userfaultfd-stabilized race conditions are double-fetch bugs and heap use-after-free (UAF) windows. A double-fetch vulnerability occurs when the kernel reads a value from user space twice: first to validate metadata (such as payload size or type), and second to process the actual data payload.
Consider a vulnerable system call interface that processes variable-length network packets:
/* Vulnerable kernel handler */
long handle_user_packet(struct user_packet __user *arg) {
struct user_packet header;
void *kernel_buf;
/* First fetch: get header */
if (copy_from_user(&header, arg, sizeof(header)))
return -EFAULT;
if (header.size > 512)
return -EINVAL;
kernel_buf = kmalloc(header.size, GFP_KERNEL);
if (!kernel_buf)
return -ENOMEM;
/* Second fetch: read packet body */
if (copy_from_user(kernel_buf, arg->data, header.size)) {
kfree(kernel_buf);
return -EFAULT;
}
process_packet(kernel_buf, header.size);
kfree(kernel_buf);
return 0;
}
In this routine, the developer intended to allocate memory matching header.size. If the attacker maps arg->data across a page boundary where the second page is registered with userfaultfd, copy_from_user() copies bytes until it crosses the page boundary, then traps into user space.
While the kernel thread handling handle_user_packet() sleeps inside schedule(), the exploit process initiates an alternate thread. Because the kernel has already completed the validation step (header.size > 512) and allocated memory via kmalloc(), the exploit thread can change hardware state, free related objects, or trigger a re-allocation race.
The technique becomes even more potent in use-after-free exploitation. In an un-stabilized UAF, thread A frees an object, and thread B must spray the SLUB allocator to reclaim the exact memory chunk before thread A (or thread C) touches the stale pointer. Without synchronization, the re-allocation attempt frequently fails or corrupts unintended slabs.
Thread 1 (Kernel Syscall) Thread 2 (Exploit Handler) Kernel SLUB State
| | |
kmalloc(obj_A) | [obj_A allocated]
kfree(obj_A) | [obj_A in freelist]
| | |
copy_from_user(buf) | |
| | |
[Triggers #PF] | |
v | |
Thread 1 BLOCKS | |
in schedule() -----------------------> Polls UFFD event |
| | |
(SLEEPING) Spray kmalloc cache |
(SLEEPING) -----------------> Reclaims obj_A slot ---------> [obj_A overwritten]
(SLEEPING) | |
| ioctl(UFFDIO_COPY) |
v | |
Thread 1 WAKES <--------------------------+ |
| |
Uses corrupted obj_A pointer |
With userfaultfd, if kfree(obj_A) occurs right before a copy_from_user() call, the fault traps the kernel before the stale pointer is cleared or reused. While the faulted thread is suspended, the exploit thread executes thousands of allocations matching the target slab cache (such as kmalloc-128 or kmalloc-512). As demonstrated in advanced attacks like those covered in How Linux Dirty Cred Exploits Bypass Kernel Mitigations, controlling the SLUB recycling window turns probabilistic memory corruption into deterministic privilege escalation. The attacker replaces an expired object with a sensitive credential structure or a target pipe buffer before releasing the fault lock.
Polling UFFD Events with Epoll and User-Space Page Swapping
Managing blocked threads requires a dedicated event loop in user space. The userfaultfd file descriptor supports readiness notifications through standard Linux multiplexing interfaces like poll(), select(), and epoll.
As documented in How Linux epoll Works: Red-Black Trees and Ready Lists, monitoring file descriptors through an epoll instance minimizes wake-up overhead. The exploit creates an epoll set, adds the uffd descriptor with EPOLLIN, and waits for the kernel to trigger an event notification.
When the kernel traps a fault in handle_userfault(), it populates a struct uffd_msg and signals the wait queue. The user-space handler reads the message to retrieve the fault details:
#include
#include
#include
void handle_fault_loop(int uffd, void *page_payload, size_t page_size) {
int epoll_fd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = {
.events = EPOLLIN,
.data.fd = uffd
};
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, uffd, &ev);
struct epoll_event events[1];
int nfds = epoll_wait(epoll_fd, events, 1, -1);
if (nfds > 0) {
struct uffd_msg msg;
ssize_t nread = read(uffd, &msg, sizeof(msg));
if (nread == sizeof(msg) && msg.event == UFFD_EVENT_PAGEFAULT) {
unsigned long faulting_addr = msg.arg.pagefault.address;
/* Fault caught: Kernel thread is now completely frozen.
* Execute heap spray or payload swap operations here. */
/* Resolve the fault to wake up the kernel thread */
struct uffdio_copy copy_op = {
.dst = faulting_addr,
.src = (unsigned long)page_payload,
.len = page_size,
.mode = 0
};
ioctl(uffd, UFFDIO_COPY, ©_op);
}
}
close(epoll_fd);
}
The critical control operation is the UFFDIO_COPY ioctl. It instructs the kernel to allocate a physical page frame at the faulting address (dst), copy the contents from an alternative user buffer (src), update the hardware page tables, and wake up any threads sleeping on ctx->fault_wqh.
The resolution completes atomically from the perspective of the faulting kernel thread. When schedule() returns, the thread re-evaluates the instruction that faulted. This time, the MMU finds a valid PTE, loads the memory into CPU registers, and execution proceeds down the original path. Because user code chose when to issue UFFDIO_COPY, the attacker dictated the exact ordering of operations between kernel memory allocation, pointer dereference, and object destruction.
Modern Defenses: Unprivileged Restrictions and FUSE Alternatives
Because userfaultfd became the standard vehicle for kernel exploit stabilization across hundreds of public writeups, the Linux kernel security community took steps to restrict its reach.
In Linux 5.11, kernel maintainers introduced a dedicated control knob: vm.unprivileged_userfaultfd. Setting this sysctl to 0 restricts access to userfaultfd() to processes possessing the CAP_SYS_PTRACE capability:
# Disable unprivileged userfaultfd access
sudo sysctl -w vm.unprivileged_userfaultfd=0
sysctl vm.unprivileged_userfaultfd
|
+---------------------+---------------------+
| |
Value: 0 Value: 1
| |
Has CAP_SYS_PTRACE? Permitted for
/ \ all applications
Yes No
| |
Allowed Returns -EPERM
Major server distributions and mobile platforms rapidly enabled this restriction. Android, container runtimes, and hardened enterprise kernels disable unprivileged userfaultfd by default. Later kernels added UFFD_USER_MODE_ONLY, allowing user-space processes to monitor faults originating strictly from user mode, preventing interception of kernel-space copy_from_user() operations while still allowing user-level garbage collectors and language runtimes to function.
Faced with these restrictions, exploit developers identified an architectural fallback: FUSE (Filesystem in Userspace). FUSE allows non-root users to implement file systems in user mode via /dev/fuse.
When an application uses mmap() on a file exposed by a FUSE driver, accessing the mapped buffer triggers a file-backed page fault. The kernel must consult the FUSE server to populate the page cache via readpage. Because the FUSE server runs as an unprivileged process controlled by the user, it can intentionally delay its response to the kernel's read request.
Application (Attacker) Kernel VFS / MM FUSE Daemon (Attacker)
| | |
syscall(mmap_buf) ----------------->| |
| copy_from_user() |
| |-- [#PF on FUSE memory] |
| |-- Dispatches read request -------->|
| | |
| Kernel thread BLOCKS |
| waiting for reply |
| | Delays response
| | Sprays kernel heap
| | Prepares payload
| |<-- Sends read completion ----------|
| Kernel thread WAKES |
The mechanics match userfaultfd closely. When a target kernel thread calls copy_from_user() on a FUSE-mapped buffer, the thread blocks in the kernel Virtual File System (VFS) layer, waiting for the user-space daemon to reply. The exploit gains identical pause-and-spray capabilities without ever calling userfaultfd().
Defending against user-fault synchronization requires changes to how kernel APIs are structured. Modern kernel code increasingly avoids holding locks across user memory accesses, reduces the window between allocation and consumption, and avoids double-fetching altogether by copying structs into kernel memory in one contiguous operation before parsing fields.
Conclusion
The transformation of userfaultfd from a virtualization tool into an exploit mechanism illustrates a fundamental rule of systems architecture: whenever user space gains deterministic control over kernel scheduling, concurrency assumptions collapse. By turning an asynchronous hardware exception into a synchronous user-space event, userfaultfd converted transient race windows into fully serialized workflows.
While restricting unprivileged access via vm.unprivileged_userfaultfd raised the barrier for standard local privilege escalations, alternative paths like FUSE demonstrate that the underlying vulnerability lies in the sleepable nature of kernel copies. Robust defenses cannot rely solely on blocking specific debugging or virtualization interfaces; they require kernel code that treats every transfer across the user boundary as an operation that can stall indefinitely.
Measured on our own hardware: What Does a Page Fault Cost? First Touch vs Prefaulting 512 MiB
When a program writes to freshly mapped memory, how much does taking one page fault per 4 KiB page cost compared with asking the kernel to populate the whole range in one call before touching it?
We ran it. The numbers below come from a program executed on the server hosting this site on 2026-10-11 — an AMD EPYC 9354P 32-Core Processor with 8 cores visible, 31.3 GB of memory, Linux 6.8.0-139-generic.
| Metric | Value |
|---|---|
| fault vs populate factor | 1.5 |
| buffer mb | 512 |
| first touch minor faults | 131072 |
| first touch ns per page | 2533.4 |
| pages | 131072 |
| populate minor faults | 131072 |
| populate then touch ns per page | 1684.6 |
| repeats | 5 |
| retouch mapped ns per page | 27.3 |
This is a shared virtual server, not an isolated test rig, so treat the absolute figures as indicative and the ratio between the two cases as the finding. The full method, the machine specification, and the complete source code are on the What Does a Page Fault Cost? First Touch vs Prefaulting 512 MiB benchmark page, so you can check the method or run it yourself.
References
- Linux Kernel Documentation on userfaultfd: https://docs.kernel.org/admin-guide/mm/userfaultfd.html
- LWN Coverage of userfaultfd blocking mechanics: https://lwn.net/Articles/819834/
- Torvalds Linux Git Commit 37cd0575b85f: Restrict unprivileged userfaultfd: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=37cd0575b85ff0101b0b3e6488a03260d7055963