Unprivileged Root via Use-After-Free in Linux DRM GEM change_handle (CVE-2026-46215)

Unprivileged Root via Use-After-Free in Linux DRM GEM change_handle (CVE-2026-46215)

Original text: “Unprivileged root via a use-after-free in DRM GEM change_handle (CVE-2026-46215)”cyberstan, cyberstan.co.uk (12 April 2026). Code blocks and figures below are reproduced verbatim with attribution captions.

Executive Summary

CVE-2026-46215 is a use-after-free (UAF) race condition in the Linux kernel’s Direct Rendering Manager (DRM) GEM subsystem, triggered through the drm_gem_change_handle_ioctl() interface. The ioctl renumbers a GEM buffer handle within a file-descriptor’s private IDR table. Between the new-handle idr_alloc() and the old-handle idr_remove(), a concurrent call to close() — or any path that drops the old handle’s refcount — can drive the object’s reference count to zero and trigger its kfree() before the ioctl finishes. The freed kmalloc-512 slab can be immediately reclaimed by a controlled struct pipe_buffer array, whose fields alias onto drm_gem_object.name at offset 224. A subsequent GEM FLINK call writes a controlled integer into that field, setting PIPE_BUF_FLAG_CAN_MERGE and enabling a DirtyPipe-style page-cache overwrite to achieve full local privilege escalation to root.

The vulnerability affects Linux kernels v6.8 through v6.14-rc2 and was patched upstream in v6.14-rc3. Exploitation requires only the ability to open a DRM render node — typically world-accessible on desktop Linux distributions. No kernel symbol leak or advanced technique is required beyond recovering the anon_pipe_buf_ops pointer via the aliased pipe_buffer.ops field. The author reported the vulnerability on 18 March 2026; a fix appeared in the v6.14-rc3 merge window. CVSS v3.1 Base Score: 7.8 HIGH (AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). CWE-416 (Use After Free). MITRE ATT&CK: T1068 (Exploitation for Privilege Escalation).

An Unprivileged Local Attack Surface

The DRM subsystem exposes GPU resources through character device nodes under /dev/dri/. On typical desktop Linux distributions these nodes are readable and writable by unprivileged users (mode 0666 or accessible via the render group with no special capabilities required). The GEM API within DRM allows processes to allocate GPU buffer objects (BOs), assign them integer handles local to a file descriptor, and share them with other processes through DMA-BUF file descriptors or the legacy FLINK global-name mechanism. Every open DRM file descriptor maintains a private IDR — an integer-to-pointer mapping table — associating handles to struct drm_gem_object pointers.

The change_handle ioctl

Added in kernel v6.8 to support Vulkan sparse-memory aliasing, the DRM_IOCTL_GEM_CHANGE_HANDLE ioctl allows a process to reassign an existing GEM buffer object from one handle number to another within the same open file descriptor. The intended semantics are atomic: after the ioctl returns, only the new handle resolves to the object; the old handle is gone. The implementation must: (1) take a lookup reference to the object from the old handle, (2) insert the new handle into the IDR, (3) perform ancillary prime bookkeeping, (4) remove the old handle from the IDR, and (5) release the lookup reference. The bug lives in the gap between steps 2 and 4.

The Bug: A Refcount That Never Moves

After idr_alloc() inserts the new handle (step 2), both the old and new handles simultaneously resolve to the same object inside the IDR. Any concurrent caller that sees and closes the old handle will invoke drm_gem_object_release_handle(), which calls drm_gem_object_put(). If the object’s only remaining real reference is the one held by the open-file table entry for the old handle, this decrement drives the refcount to zero and triggers kfree() through a workqueue (virtio_gpu_dequeue_ctrl_func). The ioctl still holds a dangling pointer to the now-freed object when it proceeds to remove the old handle (step 4) and release the lookup reference (step 5) — a classic use-after-free.

The race window is narrow but reliable under thread pressure: a tight spinner on DRM_IOCTL_GEM_CLOSE races against repeated DRM_IOCTL_GEM_CHANGE_HANDLE invocations. In the author’s proof-of-concept the race was won on iteration 977 — a success rate exceeding 50% without scheduler pinning. The diagram below illustrates the two-thread race on the IDR state machine:

Race condition state diagram: two IDR entries exist simultaneously during change_handle, enabling concurrent close to free the object
Race condition state diagram — the window between idr_alloc(new) and idr_remove(old) leaves the object reachable via two handles simultaneously, allowing a concurrent close to free it. Source: original article.

The following is a simplified, pre-fix view of the vulnerable code path:

/* drm_gem_change_handle_ioctl(), simplified, pre-fix */
obj = drm_gem_object_lookup(file_priv, args->handle);   /* +1 lookup ref */
spin_lock(&file_priv->table_lock);
idr_alloc(&file_priv->object_idr, obj, new_handle, ...); /* new entry */
spin_unlock(&file_priv->table_lock);
/* ... prime bookkeeping under prime.lock ... */
spin_lock(&file_priv->table_lock);
idr_remove(&file_priv->object_idr, args->handle);       /* old entry */
spin_unlock(&file_priv->table_lock);
drm_gem_object_put(obj);                                 /* -1 lookup ref */

Reclaiming the Object

A struct drm_gem_object allocated by virtio_gpu via __drm_gem_shmem_create() is 512 bytes and lands in the kmalloc-512 slab cache. After the race frees the object, the attacker reclaims that slab slot before any legitimate kernel write touches it. The standard technique is a pipe() spray: opening many pipes and writing just enough data to each to force the kernel to allocate internal struct pipe_buffer arrays. Sizing these arrays to 512 bytes ensures one of them lands in the freed slot, giving the attacker an aliased view of the old drm_gem_object through the still-live FLINK handle.

Leaking a Pointer: The Struct Overlap

With a struct pipe_buffer[N] array overlapping the freed drm_gem_object, certain fields alias. Offset 216 of drm_gem_object is size (a size_t); offset 224 is name (a 32-bit global FLINK name). These coincide with pipe_buf[5].ops at byte 5×40+16 = 216 and pipe_buf[5].flags at byte 5×40+24 = 224. The GEM subsystem can read back drm_gem_object.name through a DRM_IOCTL_GEM_FLINK call on the dangling handle. Because ops holds a kernel text pointer (anon_pipe_buf_ops), reading the lower 32 bits via FLINK reveals 32 bits of a kernel text address, defeating KASLR within a predictable range on x86-64.

Struct overlap diagram: pipe_buffer array fields at offsets 216 and 224 alias drm_gem_object.size and drm_gem_object.name
Field overlap between the reclaimed struct pipe_buffer array and the freed drm_gem_object. Offset 216 ≡ pipe_buf[5].opsdrm_gem_object.size; offset 224 ≡ pipe_buf[5].flagsdrm_gem_object.name. Source: original article.

The exact field offsets used in the exploit:

#define GEM_SIZE_OFF        216   /* drm_gem_object.size            */
#define GEM_NAME_OFF        224   /* drm_gem_object.name            */
#define PIPEBUF_SIZE_ACTUAL 40    /* sizeof(struct pipe_buffer)     */
#define OVERLAP_IDX         5
#define PIPEBUF_OPS_OFF     16    /* pipe_buffer.ops   -> 5*40+16 = 216 */
#define PIPEBUF_FLAGS_OFF   24    /* pipe_buffer.flags -> 5*40+24 = 224 */

Bypassing the DirtyPipe Fix

CVE-2022-0847 (DirtyPipe) was fixed by clearing PIPE_BUF_FLAG_CAN_MERGE on all new pipe_buffer entries written through the normal pipe write path. The fix prevents arbitrary merging of user data into existing page-cache pages — unless the attacker can set that flag on an already-existing pipe_buffer by another means. The struct-overlap technique accomplishes exactly this: by writing a controlled integer into pipe_buf[5].flags via GEM FLINK, the attacker sets PIPE_BUF_FLAG_CAN_MERGE = 0x10, re-enabling the DirtyPipe write primitive on a targeted pipe that references a victim file’s page cache. The FLINK name 16 (0x10) is pre-staged by allocating 15 dummy GEM objects and FLINKing them so that the dangling handle’s FLINK yields exactly 16:

/* pre-stage names 1..15 so the dangling handle's FLINK gets name 16 */
for (i = 0; i < 15; i++) {
        h = create_gem_bo(fd);
        ioctl(fd, DRM_IOCTL_GEM_FLINK, &(struct drm_gem_flink){ .handle = h });
}
struct drm_gem_flink fl = { .handle = dangling };
ioctl(fd, DRM_IOCTL_GEM_FLINK, &fl);     /* fl.name == 16 == 0x10 */
/* writes 16 into gem.name @224 == pipe_buf[5].flags -> PIPE_BUF_FLAG_CAN_MERGE */

The Exploit Chain

The complete exploitation sequence: (1) open a DRM render node; (2) allocate a GEM BO via DRM_IOCTL_MODE_CREATE_DUMB; (3) race DRM_IOCTL_GEM_CHANGE_HANDLE against DRM_IOCTL_GEM_CLOSE until the UAF is triggered; (4) spray pipe() pairs to reclaim the freed 512-byte slab slot with a pipe_buffer array; (5) call DRM_IOCTL_GEM_FLINK on the dangling handle to read pipe_buf[5].ops (anon_pipe_buf_ops) through gem.name, deriving the KASLR slide; (6) pre-stage FLINK names 1–15, then FLINK the dangling handle to get name 16, writing PIPE_BUF_FLAG_CAN_MERGE into pipe_buf[5].flags; (7) use the DirtyPipe write primitive to overwrite /etc/passwd in the page cache, clearing the root password. The write takes effect on the next su invocation with no crash artifact.

Evidence

KASAN instrumentation confirms the slab-use-after-free on a debug kernel build:

BUG: KASAN: slab-use-after-free in drm_gem_object_release_handle+0x24/0x100
Read of size 8 at addr ffff888104769d60 by task kasan_trigger/75
Allocated by task 75:
  virtio_gpu_create_object -> __drm_gem_shmem_create ->
  virtio_gpu_mode_dumb_create -> drm_mode_create_dumb_ioctl
Freed by task 39:
  kfree -> virtio_gpu_dequeue_ctrl_func -> process_one_work
The buggy address belongs to the cache kmalloc-512 of size 512

Exploit output on an unpatched v6.13 kernel in a virtio-gpu VM:

[!] Race won (iter 977): handle=132049
[!] KASLR: pipe_buf_ops = 0xffffffff82428400
[!] FLINK: 16 = 0x10
[*] /etc/passwd:
    root::0:0:pwned:/root:/bin/sh
[!] LPE CONFIRMED

The Fix

The patch shipped in v6.14-rc3 closes the race window by using an atomic idr_replace() swap. The new handle is first inserted with a NULL value (blocking concurrent FLINK or OPEN_BY_NAME lookups), then swapped for the real object pointer only after atomically validating that the old entry is still live. If a concurrent close has consumed the old slot, the ioctl detects the mismatch and unwinds cleanly with -ENOENT:

ret = idr_alloc(&file_priv->object_idr, obj, handle, handle + 1, GFP_NOWAIT);
if (ret < 0) { ... }
idrobj = idr_replace(&file_priv->object_idr, NULL, handle);
if (idrobj != obj) {
        /* a concurrent close already took this slot */
        idr_replace(&file_priv->object_idr, idrobj, handle);
        idr_remove(&file_priv->object_idr, args->new_handle);
        ret = -ENOENT;
        goto out_unlock;
}

What I Suggested, and What Shipped

The author’s initial suggestion to DRM maintainers was to hold file_priv->table_lock across the entire insert–bookkeeping–remove sequence, eliminating the race at the cost of holding a spinlock over the prime shmem bookkeeping path. Maintainers instead opted for the idr_replace-based swap, which avoids lock contention on the common path while still closing the UAF window. The shipped fix is technically superior: it tolerates concurrent closes cleanly without extending lock hold times on unrelated callers.

Disclosure Timeline

  • 18 March 2026 — Vulnerability reported to linux-distros@vs.openwall.org and DRM maintainers via private mail.
  • 21 March 2026 — Maintainers acknowledged the report and began patch review.
  • 28 March 2026 — Fix committed to drm-next.
  • 7 April 2026 — v6.14-rc3 released; fix is included.
  • 12 April 2026 — CVE-2026-46215 assigned; public disclosure.
  • May 2026 — Fix backported to stable branches (v6.12.y, v6.11.y).

A Broader Pattern

The change_handle UAF is a textbook TOCTOU defect in handle-table management: the “time of check” (IDR insertion of the new handle) is separated from the “time of use” (IDR removal of the old handle) by a lock drop needed for sleeping allocations. Every subsystem that manages userspace handles — DRM, io_uring, file descriptors, io_uring registered files — must treat the interval between inserting a new mapping and removing the old one as a critical section with respect to concurrent release paths. Dynamic sanitizer coverage alone is insufficient; formal reasoning about lock invariants would catch this class at design time.

Key Takeaways

  • CVE-2026-46215 is a use-after-free race in drm_gem_change_handle_ioctl() exploitable by any unprivileged user with access to a DRM render node.
  • The vulnerability window is between idr_alloc(new) and idr_remove(old); a concurrent close drives the object refcount to zero, freeing it while the ioctl holds a dangling pointer.
  • Exploitation chains three primitives: UAF → kmalloc-512 slab reclaim via pipe_buffer spray → FLINK name write → KASLR leak + DirtyPipe re-enablement → root.
  • Kernels v6.8 through v6.14-rc2 are affected; current stable kernels (v6.12.y, v6.11.y) have received backports.
  • The DirtyPipe-bypass component shows that patching an individual memory-corruption bug does not eliminate derived exploitation primitives if the enabling flag can be set by other means.
  • KASAN detected the UAF reliably on debug builds; production kernels without KASAN remained fully exploitable and syzkaller coverage did not exercise the race.
  • The fix uses idr_replace(NULL) to atomically validate the old handle’s liveness before committing the swap — a pattern applicable to all IDR-based handle management code in the kernel.

Defensive Recommendations

  • Patch immediately: Apply v6.14-rc3 or the stable backports to v6.12.y / v6.11.y. Verify the commit drm/gem: fix change_handle UAF race is present in your kernel build.
  • Restrict DRM render node access: On servers and containers where GPU rendering is not required, remove unprivileged users from the render group and set /dev/dri/renderD* permissions to 0600. Consider disabling the module entirely with install drm /bin/false in modprobe.d.
  • Deploy seccomp profiles: Restrict ioctl(2) on DRM device nodes to an allow-list of safe commands. Workloads that do not need DRM_IOCTL_GEM_CHANGE_HANDLE can block it via a seccomp EPERM rule.
  • Enable LKRG or grsecurity: Linux Kernel Runtime Guard detects exploits that modify credential structures in memory, providing a defense-in-depth layer against the final LPE step even if the UAF is triggered.
  • Monitor for anomalous ioctl patterns: Instrument kernels with a kprobe on drm_gem_change_handle_ioctl and flag high-frequency calls from non-compositor processes. Race exploitation requires thousands of ioctl iterations.
  • Audit IDR handle-table patterns: Apply the idr_replace(NULL) validation pattern review to other DRM ioctls and to io_uring registered-file management, which uses a structurally similar two-phase swap idiom.
  • Enable KASAN in staging kernels: This UAF was reliably KASAN-detectable. Systematic KASAN fuzzing of all DRM ioctls in a virtio-gpu VM would have caught the regression at development time.

Conclusion

CVE-2026-46215 demonstrates that a narrow TOCTOU window in a recently introduced ioctl, combined with a structural field overlap between the freed object and a kernel data structure the attacker controls, is sufficient for a full, reliable local privilege escalation chain. The exploit required no zero-day primitives beyond the UAF itself: slab reclaim, FLINK-based KASLR, and DirtyPipe are all well-documented techniques. Vendors and distributions should prioritize the stable backport, and kernel developers should adopt the idr_replace(NULL) validation pattern as a standard idiom for IDR handle swaps going forward.

Original text: “Unprivileged root via a use-after-free in DRM GEM change_handle (CVE-2026-46215)” by cyberstan at cyberstan.co.uk.

Comments are closed.