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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5134 Β· 14 files
FileTypeDescriptionSize
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 β†’ the drm_gem_object_get() taken by drm_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 file stays allocated and on the global filelist forever, and nfiles never decreases β†’ looping exports walks the system into kern.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
VERDICT.md
↓ download raw

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_install moves the CALLER's reference into the fd table.
  • DragonFly fsetfd() does NOT consume: fsetfd_locked() (kern_descrip.c:2190-2194) does fhold(fp) then installs β€” the table takes a NEW reference.
  • sys/dev/drm/linux_dma-buf.c:139-166 (dma_buf_fd) never issues the balancing fdrop(), and its only caller that transfers a reference (drm_gem_prime_handle_to_fd, drm_prime.c:668-685) deliberately does NOT dma_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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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).
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0 (same base kernel; /boot/kernel/drm.ko rebuilt from patched source)

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.