dma_buf_fd() never drops the caller's file reference after fsetfd(): every PRIME export orphans one reference β struct file + dma_buf + GEM object (+ pinned user-sized pages) + drm_device immortal, nfiles walks to system-wide ENFILE
Summary
fd_install consumes the caller's reference but fsetfd ADDS one - each export permanently orphans a full immortal cluster (verified ledger: f_count stuck at 1 with zero owners after close, live on stock drm.ko as uid 65534). Unpriv render user loops -> unbounded page pinning + global ENFILE for every process incl. root. Add-the-consuming-fdrop fix validated (f_count reaches 0, release chain runs).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5134 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.2 KB | β raw | |
| VERDICT.md | β | 3.6 KB | β raw | |
| leak_trigger.c | β | 1.3 KB | view raw | |
| dmabuf_demo.c | β | 5.6 KB | view raw | |
| build.sh | β | 133 B | view raw | |
| run.sh | β | 150 B | view raw | |
| build.log | β | 245 B | view raw | |
| run.log | β | 940 B | view raw | |
| run.2.log | β | 238 B | view raw | |
| env.txt | β | 480 B | view raw | |
| fix.diff | β | 457 B | view raw | |
| fix_build.log | β | 457 B | view raw | |
| fix_run.log | β | 638 B | view raw | |
| verdict.json | β | 4.2 KB | view raw |
DF-5134 β dma_buf_fd() never drops the caller's file reference: every PRIME
export permanently leaks file + dma_buf + GEM object (+ pinned pages)
sys/dev/drm/linux_dma-buf.c:139-166 β on Linux, dma_buf_fd() ends with
fd_install(fd, dmabuf->file), and fd_install() consumes one reference
to the file: the reference the caller owns (from dma_buf_export(), or from
the get_dma_buf() it did before calling). The DragonFly port instead uses
fdalloc() + fsetfd(), and fsetfd_locked() takes an additional
reference (fhold(fp), kern_descrip.c:2191) β it consumes nothing. The
port's dma_buf_fd() then returns without the balancing fdrop().
Consequence, per successful DRM_IOCTL_PRIME_HANDLE_TO_FD
(drm_prime.c:669): exactly one struct file reference is orphaned
(f_count never returns to 0), so:
fo_close=dmabuf_close()never runs βdmabuf->ops->release()never runs β thedrm_gem_object_get()taken bydrm_gem_dmabuf_export()(drm_prime.c:372) is never dropped β the GEM object (and, for i915, its backing pages β user-chosen size) is immortal;- the
drm_dev_get()from the same path is never dropped; struct filestays allocated and on the global filelist forever, andnfilesnever decreases β looping exports walks the system intokern.maxfiles(ENFILE for every process, root included).
Reference ledger for one export β close(handle) β close(fd) on the buggy kernel: falloc(+1, orphan) β fsetfd(+1) β close fd(β1) β last-GEM-handle close β drm_gem_object_exported_dma_buf_free β dma_buf_put(β1) β 1 left, owned by nobody. On Linux the fd_install consumes the caller's +1 and the count reaches 0.
Reproduce (audit QEMU guest, no GPU)
The dmabugdemo kmod performs the real dma_buf_export() + dma_buf_fd()
(stock drm.ko symbols) in the firing process's context β the same two calls
drm_gem_prime_handle_to_fd() makes β then the unprivileged trigger simply
closes the fd and a second sysctl prints the orphaned file's f_count.
# build cd /root/poc cc -O -o leak_trigger leak_trigger.c printf 'KMOD=\tdmabugdemo\nSRCS=\tdmabuf_demo.c\n\n.include <bsd.kmod.mk>\n' > Makefile make # run (as nobody) kldload drm && kldload /usr/obj/root/poc/dmabugdemo.ko su -m nobody -c /tmp/leak_trigger
Expected output (baseline)
[leak_trigger] uid=65534 pid=1296 got dmabuf fd=3 (refs: 1 from dma_buf_export/falloc + 1 from fd table) [leak_trigger] closing fd=3; after this NO ONE holds the buffer ... [leak_trigger] check: orphan f_count=1 => LEAK: struct file + dma_buf never freed (repro) LEAK_RC=2
On the fixed kernel the same run prints
f_count=0 => no leak and the kmod logs ops->release(...) ran -> dmabuf_close() frees it.
Fix validation
fix.diff (same file as DF-5133's fo_shutdown fix) adds the balancing
fdrop(dmabuf->file) after fsetfd() in dma_buf_fd(). Rebuilt drm.ko on
the guest, rebooted, re-ran:
[leak_trigger] uid=65534 pid=999 got dmabuf fd=3 ... dmabug: mkfd: ... f_count=1 (expect 2: falloc + fd-table) <- fdrop consumed the ref dmabug: ops->release(dmabuf=0xfffff8008d602060) ran -> dmabuf_close() frees it dmabug: check: ops->release ran + file freed -> NO leak (fixed kernel) LEAK_RC=0
DF-5134 β VERDICT: reproduced (permanent kernel memory + fd-table leak), fix validated
Mechanism (source)
- Linux
dma_buf_fd()=get_unused_fd_flags()+fd_install(fd, file);fd_installmoves the CALLER's reference into the fd table. - DragonFly
fsetfd()does NOT consume:fsetfd_locked()(kern_descrip.c:2190-2194) doesfhold(fp)then installs β the table takes a NEW reference. sys/dev/drm/linux_dma-buf.c:139-166(dma_buf_fd) never issues the balancingfdrop(), and its only caller that transfers a reference (drm_gem_prime_handle_to_fd, drm_prime.c:668-685) deliberately does NOTdma_buf_put()on success β correct under Linux fd_install semantics, fatal under this port's fsetfd semantics.
Per successful PRIME export the falloc reference from dma_buf_export()
(linux_dma-buf.c:123) is orphaned. Because it never reaches 0,
dmabuf_close() (linux_dma-buf.c:54-68) never runs, so
drm_gem_dmabuf_release() never drops the GEM-object reference taken by
drm_gem_dmabuf_export() (drm_prime.c:368-372), and the drm_dev_get()
leaks too. The GEM object being immortal also means its backing storage
(i915: shmem pages of attacker-chosen size) is pinned forever, and each
iteration permanently consumes one struct file from the global table
(nfiles vs kern.maxfiles = 130112 on the guest) β an unprivileged
render-node loop exhausts kernel memory and then the system-wide fd table.
Result (baseline, stock modules)
$ su -m nobody -c /tmp/leak_trigger [leak_trigger] uid=65534 pid=1296 got dmabuf fd=3 (refs: 1 from dma_buf_export/falloc + 1 from fd table) [leak_trigger] closing fd=3; after this NO ONE holds the buffer in userland or kernel except the orphaned export ref [leak_trigger] check: orphan f_count=1 => LEAK: struct file + dma_buf never freed (repro) LEAK_RC=2 (dmesg) dmabug: mkfd: uid=65534 dmabuf=0xfffff8008d981f98 file=0xfffff8011738c900 fd=3 f_count=2 dmabug: check: dmabuf=0xfffff8008d981f98 file=0xfffff8011738c900 f_count=1 (fd closed, no other owner exists)
The file is alive at f_count=1 with zero owners β the orphan, proven live by
an unprivileged (uid 65534) run using only the stock dma_buf_export() /
dma_buf_fd() code. In the real drm_prime path this orphan additionally
pins the GEM object, its pages, and the drm_device (see README).
Fix validation
fix.diff = fdrop(dmabuf->file) after fsetfd() in dma_buf_fd()
(mirrors Linux's fd_install consumption; also carries DF-5133's
fo_shutdown line, which does not affect this test). drm.ko rebuilt,
installed, rebooted, identical run as uid 65534:
dmabug: mkfd: ... fd=3 f_count=1 (expect 2: falloc + fd-table) dmabug: ops->release(dmabuf=0xfffff8008d602060) ran -> dmabuf_close() frees it dmabug: check: ops->release ran + file freed -> NO leak (fixed kernel) LEAK_RC=0
fix_baseline_reproduced=1 (leak on stock), fix_patched_reproduced=0 (no leak after fix). fix_status=fixed.
Caveat
No GPU on the audit guest: the kmod performs the two stock calls the prime ioctl would; the refcount accounting under test (falloc/fsetfd/close/ dma_buf_put ledger) is entirely the stock kernel's. The drm_prime-level amplification (GEM object + pages + drm_device also immortal, because dmabuf_close never runs) is source-traced, not guest-demonstrated.
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedfix.diff adds the balancing fdrop() in dma_buf_fd(); drm.ko rebuilt on the guest, rebooted, identical uid-65534 trigger: f_count now reaches 0, ops->release runs, dmabuf_close frees the object, LEAK_RC=0. Baseline leaked on the same guest minutes earlier.
fix_run.log ('ops->release(dmabuf=0xfffff8008d602060) ran -> dmabuf_close() frees it', 'check: ... NO leak (fixed kernel)', LEAK_RC=0); fix_build.log (drm.ko rebuild with both dma-buf hunks).
Confirmed kernel references
Detail
Exploit chain
unpriv render node -> DRM_IOCTL_PRIME_HANDLE_TO_FD -> drm_gem_prime_handle_to_fd (drm_prime.c:643-685, no dma_buf_put on success, correct for Linux fd_install semantics) -> dma_buf_fd() fsetfd(+1) with no fdrop -> close(fd)(-1) + last-gem-handle drm_gem_object_exported_dma_buf_free -> dma_buf_put(-1) => orphaned +1; struct file + struct dma_buf + drm_gem_object (+pages) + drm_device never freed; loop new-object exports => kernel memory pinning + nfiles -> kern.maxfiles => system-wide ENFILE DoS.
Evidence (decisive lines)
run.log: '[leak_trigger] check: orphan f_count=1 => LEAK' (LEAK_RC=2) as uid 65534 with kernel-side dmesc 'check: ... f_count=1 (fd closed, no other owner exists)'; fix_run.log: 'f_count=0 => no leak', 'ops->release(...) ran -> dmabuf_close() frees it', LEAK_RC=0 on the patched drm.ko.
PoC changes
PoC written from scratch (no seed). No GPU on the guest, so the kmod performs the stock dma_buf_export()+dma_buf_fd() calls (same calls drm_prime makes) in the unprivileged trigger's context; the trigger closes the fd and a CTLFLAG_ANYBODY sysctl reads back the orphan's f_count.
Verified recommended fix
In dma_buf_fd(), fdrop(dmabuf->file) after fsetfd(curproc->p_fd, dmabuf->file, fd) so the fd install consumes the caller's reference exactly like Linux fd_install().
Verdict
dma_buf_fd() (sys/dev/drm/linux_dma-buf.c:139-166) ends with fdalloc()+fsetfd(), and DragonFly fsetfd() takes an ADDITIONAL file reference (fhold, kern_descrip.c:2191) where Linux's fd_install() consumes the caller's; the missing balancing fdrop() orphans one reference per PRIME export. Reproduced live as uid 65534 on stock drm.ko: after the trigger closes the only fd, the dma_buf's struct file sits at f_count=1 with zero owners; dmabuf_close() can never run, which in the real drm_prime path also makes the GEM object (+ its pinned, user-sized pages) and the drm_device immortal and permanently consumes one entry of the global file table (kern.maxfiles exhaustion). This is the export-side mirror of the known import-side DF-2132 (over-drop UAF): here the port under-drops, producing an unbounded kernel memory + fd-table leak reachable from a drm render node. fix.diff (fdrop after fsetfd) validated on the guest: f_count reaches 0, ops->release runs, dmabuf_close frees everything.
No comments yet.