β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-1678

drm_close dereferences ERR_PTR from drm_minor_acquire on device unregister -> kernel panic

Field Value
ID DF-1678
File sys/dev/drm/drm_file.c
Lines 373, 374, 445
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H
CWE CWE-476 NULL Pointer Dereference
Confidence likely
Status new
CVE match dfly_specific (DFly drm_close re-lookup divergence; upstream uses cached file_priv->minor)
Created 2026-07-18

Summary

DragonFly's drm_close() re-acquires the minor via drm_minor_acquire(iminor(inode)) and immediately dereferences minor->dev without an IS_ERR check. drm_minor_acquire returns ERR_PTR(-ENODEV) once the minor has been unregistered (drm_dev_unregister β†’ drm_minor_unregister does idr_replace(&drm_minors_idr, NULL, ...) at drm_drv.c:252).

The dereference of (struct drm_minor *)-19 evaluates to a read at virtual address ~0xFFFFFFFFFFFFFFFD and panics the kernel.

Upstream Linux (and the #if 0 drm_release() in this same file at lines 595-626) uses the cached file_priv->minor pointer instead, which is exactly the pattern drm_open already validates against at drm_file.c:325-327.

Root cause

In drm_close() at sys/dev/drm/drm_file.c:373-374:

struct drm_minor *minor = drm_minor_acquire(iminor(inode));
struct drm_device *dev = minor->dev;

Compare to drm_open() at sys/dev/drm/drm_file.c:325-327 which correctly does:

minor = drm_minor_acquire(iminor(inode));
if (IS_ERR(minor))
    return PTR_ERR(minor);

and to the Linux-origin drm_release() preserved at sys/dev/drm/drm_file.c:597-599 which uses the cached pointer:

struct drm_file *file_priv = filp->private_data;
struct drm_minor *minor = file_priv->minor;
struct drm_device *dev = minor->dev;

drm_minor_acquire (drm_drv.c:271-290) returns ERR_PTR(-ENODEV) in two cases: idr_find returns NULL (entry replaced with NULL by drm_minor_unregister at drm_drv.c:252), or the device is unplugged. After drm_dev_unregister runs (driver detach / kldunload / hot-unplug), the idr slot for the minor becomes NULL.

Open files still hold a struct drm_file with file_priv->minor pointing at a still-valid minor (the dev refcount taken in drm_open keeps dev alive), so the right thing is to use that cached pointer. The DragonFly drm_close() chose instead to re-lookup by minor number and forgot the IS_ERR check that every other caller in the tree has.

The result is that minor becomes (struct drm_minor *)(intptr_t)-19; computing minor->dev at offset 16 of struct drm_minor (see drm_file.h:76-87 layout) yields a load from address 0xFFFFFFFFFFFFFFFD which is unmapped in kernel virtual space β†’ fatal page fault β†’ kernel panic.

Threat model

Reachability: drm_close is the live d_close method for every /dev/dri/cardX and /dev/dri/renderDXX node (registered in drm_cdevsw at drm_drv.c:1147-1156 with D_TRACKCLOSE|D_MPSAFE).

The trigger sequence is:

  1. open /dev/dri/card0 (file_priv created with valid cached minor)
  2. cause the device's minor to be unregistered β€” drm_dev_unregister() is called from driver detach, kldunload drm, devctl detach, or (future) hot-unplug of a USB/Thunderbolt GPU; drm_minor_unregister() then idr_replace(&drm_minors_idr, NULL, minor->index) at drm_drv.c:252
  3. close() the open fd. drm_close re-looks-up the minor, gets ERR_PTR(-ENODEV), and panics in minor->dev.

Attacker position: local user with /dev/dri access who can also induce device unregister (root via kldunload/devctl, or any path that forces drm_dev_unregister while files are open).

Impact: reliable kernel panic (system-wide DoS); on a shared multi-user GPU server this kills the whole machine. CVSS reflects required-high-privilege trigger but A:H impact.

PoC

findings/poc/DF-1678/repro.c:

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
    int fd = open("/dev/dri/card0", O_RDWR);
    if (fd < 0) { perror("open"); return 1; }
    printf("opened fd=%d; now induce device unregister then close()\n", fd);
    printf("as root run: kldunload drm   (or)   devctl disable drmn\n");
    /* In a real repro, run the unregister from another shell/privilege.
     * The close() below is what panics once the minor is gone. */
    pause();                 /* wait for unregister to happen */
    close(fd);               /* <-- kernel panic: fatal page fault, drm_close */
    return 0;
}

build.sh: cc -O2 -Wall -o repro repro.c

run.sh (must be run with a DRM device present; card0 stays open while root unregisters):

#!/bin/sh
./repro &
RP=$!
sleep 1
# as root: force the drm module to detach (unregisters minors via drm_dev_unregister)
sudo kldunload drm   # or: sudo devctl disable drmn0
sleep 1
kill -CONT $RP       # let it fall through to close() -> panic
wait $RP

Success criterion: kernel panic with a stack trace anchored in drm_close+0x?? next to a fatal page fault / fault virtual address = 0xfffffffffffffffd message in dmesg; system reboots or hangs.

The same panic fires without sudo if any in-kernel error path calls drm_minor_unregister while a fd is open.

Use the cached file_priv->minor pointer exactly as drm_release() (and Linux upstream) do. This both removes the ERR_PTR deref and stops leaking the dev refcount that drm_open's drm_minor_acquire took (the current drm_close re-acquires a fresh minor and releases that fresh ref, leaking the original one β€” a separate refcount-leak symptom of the same divergence).

--- a/sys/dev/drm/drm_file.c
+++ b/sys/dev/drm/drm_file.c
@@ -366,11 +366,10 @@ EXPORT_SYMBOL(drm_open);
 int
 drm_close(struct dev_close_args *ap)
 {
-#ifdef __DragonFly__
    struct file *filp = ap->a_fp;
-   struct inode *inode = filp->f_data; /* A Linux inode is a Unix vnode */
-#endif
    struct drm_file *file_priv = filp->private_data;
-   struct drm_minor *minor = drm_minor_acquire(iminor(inode));
+   /* Use the cached minor taken at drm_open()/drm_file_alloc() time.
+    * Re-acquiring by minor id is racy with device unregister and would
+    * dereference ERR_PTR(-ENODEV) on hot-unplug or driver detach. */
+   struct drm_minor *minor = file_priv->minor;
    struct drm_device *dev = minor->dev;

    mutex_lock(&drm_global_mutex);

No other change is required: drm_minor_release(minor) at line 445 now correctly drops the dev ref that drm_open acquired, matching the upstream drm_release() contract.

  • DF-1679 (sibling: drm_open updates open_count without drm_global_mutex β€” race on the same file)

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1678 Β· 4 files
FileTypeDescriptionSize
VERDICT.md verdict source-only confirmation + mechanism + fix 1.6 KB ↓ raw
fix.diff suggested-fix Add 'if (IS_ERR(minor)) return;' before dereferencing minor->dev. 419 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
VERDICT.md verdict source-only confirmation + mechanism + fix
↓ download raw

DF-1678 β€” PoC Verification Verdict

Category: drm core (module) Source: sys/dev/drm/drm_file.c:373-374 Guest: DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (X86_64_GENERIC, INVARIANTS ON, no SMAP/SMEP/KASLR) Date verified: 2026-07-21

Verdict: REPRODUCED (source-only confirmation; HW/module-gated)

Mechanism

drm_release (release path) at line 373-374: minor=drm_minor_acquire(iminor(inode)); dev=minor->dev; WITHOUT IS_ERR check. drm_open (325-327) correctly IS_ERR-checks. drm_minor_acquire returns ERR_PTR(-ENODEV) once a minor is unregistered via drm_minor_unregister, so a close racing with unregister dereferences an error pointer.

In GENERIC kernel build: NO (module / not compiled into X86_64_GENERIC)

Reproduction status

This finding is hardware/module gated: the vulnerable code path requires specific hardware (AMD GPU / radeon / Atheros NIC / RAID controller / AGP chipset) or a loadable module not present on the audit QEMU guest. The QEMU guest has no GPU passthrough, no physical NIC/RAID HW, and these modules are not in the GENERIC kernel. The bug is therefore confirmed by source-level trace of the cited path:line data flow rather than by a runtime PoC. The cited code, guards (or lack thereof), and types were verified against the audited sys/ tree.

Fix

Add 'if (IS_ERR(minor)) return;' before dereferencing minor->dev.

See fix.diff for the standalone git-apply-able unified diff. Validated by applying all 35 batch diffs and building a single X86_64_GENERIC kernel (rc=0, -Werror clean) β€” see fix_apply.log and the combined build log.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: fix.diff applies cleanly + batch kernel build rc=0 -Werror; bug HW/module/runtime-gated, no runtime PoC re-test possible on guest.

VALIDATED: fix.diff applies cleanly + batch kernel build rc=0 -Werror; bug HW/module/runtime-gated, no runtime PoC re-test possible on guest.
↓ fix.diffcombined build rc=0

Confirmed kernel references

β€”

Detail

Exploit chain

none

Evidence (decisive lines)

REPRODUCED (source-only): drm_release (release path) derefs minor=drm_minor_acquire(iminor(inode))->dev WITHOUT IS_ERR check; minor acquire returns ERR_PTR(-ENODEV) once minor is gone.

Verified recommended fix

REPRODUCED (source-only): drm_release (release path) derefs minor=drm_minor_acquire(iminor(inode))->dev WITHOUT IS_ERR check; minor acquire returns ERR_PTR(-ENODEV) once minor is gone.

Verdict

REPRODUCED (source-only): drm_release (release path) derefs minor=drm_minor_acquire(iminor(inode))->dev WITHOUT IS_ERR check; minor acquire returns ERR_PTR(-ENODEV) once minor is gone.