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

Use-after-free in async TRIM block-free path β€” ffs_blkfree stores unrefcounted mount/device pointers in deferred task

Summary

ffs_blkfree :1667-1697 when MNT_TRIM: kmalloc struct ffs_blkfree_trim_params stores ip->i_fs ip->i_devvp ip->i_dev RAW pointers NO vref/vfs_ref. Submits BUF_CMD_FREEBLKS bio via vn_strategy :1697 completion callback ffs_blkfree_trim_completed :1646 TASK_INIT+taskqueue_enqueue(swi) :1655 biodone. Task body ffs_blkfree_trim_task :1633-1642 calls ffs_blkfree_cg(tp->i_fs...) derefs all 3 pointers. ffs_unmount (ffs_vfsops.c:854-865) vinvalbuf(devvp,V_SAVE) does NOT track transient strategy buffers then VOP_CLOSE vrele kfree(fs->fs_csp) kfree(fs) kfree(ump). NO taskqueue_drain(swi) anywhere in sys/vfs/ufs/*.c. Task executes after kfree(fs) reads/writes freed M_UFSMNT heap via fs->fs_bsize fs->fs_fpg fs_cs writes. bread(tp->i_devvp) on freed device vnode. Trigger: MNT_TRIM mount generate large file deletes then umount while TRIM bios in-flight. Fix: atomic um_trim_pending counter increment in ffs_blkfree decrement in trim_task drain loop in ffs_unmount.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0793 Β· 14 files
FileTypeDescriptionSize
VERDICT.md verdict full narrative: false-positive root cause + latent UAF + fix 9.5 KB ↓ raw
README.md readme repro instructions 2.2 KB ↓ raw
asyncd.c trigger-source KLD: slow async-TRIM disk (FREEBLKS deferred 2s) 5.9 KB view raw
Makefile trigger-source builds asyncd.ko against /usr/src 168 B ↓ download
trim_uaf.c trigger-source syscall harness: mount ufs+trim, fill/delete, umount 3.3 KB view raw
trim_race.c trigger-source aggressive multi-file race harness 2.4 KB view raw
fix.diff suggested-fix validated 2-part fix: mount pointer + umount drain 2.0 KB view raw
build.sh build-script exact build 368 B view raw
run.sh run-script exact run 1.3 KB view raw
fix_build.log build-log full single-fix kernel build output (rc=0) 5.6 MB ↓ download
run.log run-log 3-kernel comparison: baseline/expose/full-fix 5.3 KB view raw
env.txt environment uname, cc, sysctls, device notes 1.6 KB 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
README.md readme repro instructions
↓ download raw

DF-0793 β€” async TRIM block-free UAF (verification)

Verdict: NOT REPRODUCED as filed (the async-TRIM code path is dead code on current master). Root cause discovered: ffs_blkfree's MNT_TRIM gate reads ip->i_devvp->v_mount (devfs mount, never has MNT_TRIM) instead of ip->i_vnode->v_mount (the ufs mount). A latent UAF exists and is un-masked by fixing that pointer; fix.diff fixes both the pointer and the missing drain. See VERDICT.md for the full analysis.

Files

file what
VERDICT.md full narrative + evidence
asyncd.c KLD: slow async-TRIM disk (FREEBLKS deferred 2s)
Makefile builds asyncd.ko against /usr/src
trim_uaf.c syscall harness: mount ufs+trim, fill/delete, umount
trim_race.c aggressive multi-file race harness
fix.diff validated two-part fix (mount pointer + umount drain)
build.sh/run.sh exact repro commands
fix_build.log full output of the single-fix kernel build (rc=0)
run.log the three-kernel comparison (baseline / expose / full-fix)
env.txt guest environment
manifest.json machine-readable catalog

Reproduce (on the DragonFly guest)

The async-TRIM path needs (a) a kernel that consults the right mount pointer (unpatched master does NOT β€” that is the bug) and (b) an async device (QEMU's vn/vbd complete FREEBLKS synchronously, so they cannot reproduce the in-flight window). asyncd.c supplies (b).

# as root on the guest (mount needs privilege; vfs.usermount=0):
./build.sh                       # builds asyncd.ko + trim_uaf
kldload ./asyncd.ko
newfs /dev/asyncd0s0
# trim_uaf mounts /dev/vn0 by default; point it at asyncd0s0:
cc -o trim_uaf_async trim_uaf.c -DDEV=\"/dev/asyncd0s0\"   # or sed
./trim_uaf_async 5
dmesg | grep -c 'asyncd: FREEBLKS'    # unpatched -> 0 (TRIM dead = the bug)

Fix validation

ssh dfbsd "cat > /root/fix.diff" < fix.diff
ssh dfbsd "cd /usr/src && patch -p1 < /root/fix.diff"
ssh dfbsd "cd /usr/src && make -j6 nativekernel KERNCONF=X86_64_GENERIC"
# install + reboot, then re-run the harness -> FREEBLKS now flows, no panic
VERDICT.md verdict full narrative: false-positive root cause + latent UAF + fix
↓ download raw

DF-0793 β€” Use-after-free in async TRIM block-free path

Verdict (one line)

NOT REPRODUCED (false-positive as filed): the async-TRIM store/defer/UAF code path in ffs_blkfree is unreachable on current master, because its MNT_TRIM gate reads the wrong mount pointer. The finding's underlying concern (missing taskqueue_drain in ffs_unmount) is a real latent defect, but it is masked by a separate bug that makes the entire FFS async-TRIM feature non-functional. A fix for both was authored and validated.


1. The claim

ffs_blkfree (sys/vfs/ufs/ffs_alloc.c:1667-1697), when the mount has MNT_TRIM, kmallocs a struct ffs_blkfree_trim_params, stores raw, un-refcounted copies of ip->i_fs, ip->i_devvp, ip->i_dev, and submits a BUF_CMD_FREEBLKS bio whose completion callback (ffs_blkfree_trim_completed, :1646) defers the real free work (ffs_blkfree_trim_task :1633) onto taskqueue_swi. ffs_unmount (ffs_vfsops.c:824) frees fs/ump/devvp with no taskqueue_drain of the pending TRIM tasks anywhere in sys/vfs/ufs/. Alleged impact: delete a large file under an MNT_TRIM mount, then umount while TRIM bios are in-flight -> the deferred task dereferences freed M_UFSMNT heap / freed device vnode -> UAF.

The "no drain" half of the claim is factually correct: grep -rn taskqueue_drain sys/vfs/ufs/ returns nothing, and taskqueue_enqueue(taskqueue_swi,&tp->task) at ffs_alloc.c:1655 is the only swi reference in the whole UFS tree.

2. Why it does not reproduce β€” the gate uses the wrong mount pointer

ffs_blkfree decides whether to TRIM with:

struct mount *mp = ip->i_devvp->v_mount;          /* ffs_alloc.c:1670 */
...
if (!(mp->mnt_flag & MNT_TRIM)) {                 /* ffs_alloc.c:1673 */
        ffs_blkfree_cg(...);                       /* synchronous */
        return;
}
/* ... TRIM path: kmalloc tp, store raw ptrs, submit async FREEBLKS bio ... */

ip->i_devvp is the block-device vnode the filesystem lives on. Its v_mount is the devfs mount of /dev (or NULL), not the ufs mount. ffs_mountfs never sets devvp->v_mount; it sets dev->si_mountpoint = mp (ffs_vfsops.c:734) β€” a field on the cdev that ffs_blkfree does not consult. The real ufs mount is reachable from the inode as ip->i_vnode->v_mount.

So mp->mnt_flag & MNT_TRIM is evaluated against the devfs mount, which never has MNT_TRIM. The gate is therefore always TRUE (TRIM never selected), ffs_blkfree_cg runs synchronously, and the store/defer/UAF code at :1679-1697 is dead code.

Proof (kernel instrumentation)

A diagnostic kernel with a kprintf at the top of ffs_blkfree, on a ufs mount mounted with MNT_TRIM (mount | grep shows ufs, trim, local), printing for 48 ffs_blkfree calls:

devvp->v_mount      = 0xfffff8008db3c800      (devfs mount)
ip->i_vnode->v_mount= 0xfffff8008eddb800      (the ufs mount β€” DIFFERENT object)
devvp_vmtrim        = 0                       (devfs mount has NO MNT_TRIM)
ipvmount_trim       = 1                       (ufs mount HAS MNT_TRIM)

The flag is set on the ufs mount (ipvmount_trim=1) but never seen, because the check reads the device vnode's mount (devvp_vmtrim=0).

Behavioural proof (async-disk KLD)

To remove the "QEMU has no slow async TRIM device" excuse, a small root-loaded KLD (asyncd.c, modelled on sys/dev/disk/md/md.c) provides a 256 MB memory-backed disk whose READ/WRITE complete synchronously but whose BUF_CMD_FREEBLKS completion is deferred 2 s via a callout β€” exactly mimicking a real SSD. Mounting ufs+trim on it (via a direct mount(2) syscall, which the kernel accepts without a device-capability check β€” only the userland mount_ufs gates on kern.cam.da.*.trim_enabled, sbin/mount_ufs/mount_ufs.c:101) and deleting a file:

kernel FREEBLKS bios sent panic
unpatched baseline (#0 06:02:54) 0 no
"expose" (mount-pointer fix only) 405 no*
full-fix (mount-pointer fix + drain) 675 / 810 no

* see Β§4.

0 FREEBLKS on the unpatched kernel is the definitive confirmation that the TRIM branch is never taken: ffs_blkfree always returns through the synchronous ffs_blkfree_cg path. The struct ffs_blkfree_trim_params, the bio, the callback, and the swi task are never instantiated.

3. Reachability for an unprivileged user

Even setting aside the dead-code problem, the async-TRIM path is not reachable by an unprivileged user on the default guest:

  • vfs.usermount = 0 β€” the user cannot call mount(2) at all.
  • The only MNT_TRIM-accepting mount path (mount_ufs userland) restricts TRIM to CAM da disks with trim_enabled; the guest has no da disk (root is vbd0/hammer2). The kernel mount(2) would accept MNT_TRIM on any device, but issuing it still requires root.
  • vn completes BUF_CMD_FREEBLKS as a synchronous no-op (sys/dev/disk/vn/vn.c:304), so even a root-mounted vn+trim would never produce an in-flight TRIM bio.

So: on the default GENERIC guest an unprivileged user has no path to the TRIM block-free path. There is no escalation surface to develop.

4. The latent UAF is real (demonstrated by un-masking it)

Fixing only the masking mount-pointer bug (mp = ip->i_vnode->v_mount) makes the TRIM path reachable β€” this is the "expose" kernel above, where 405 FREEBLKS bios flow and the deferred ffs_blkfree_trim_task dereferences the stored tp->i_fs/tp->i_devvp/tp->i_dev. That confirms the finding's mechanism is genuine (the store-then-defer pattern runs, and the deferred task does deref pointers that ffs_unmount will free).

No panic was observed on the expose kernel, because of an implicit synchronization: umount latency tracks the async bio delay exactly (2 s delay β†’ 2129 ms umount; 4 s delay β†’ 3931 ms umount). Something in the unmount path (the device buf drain on vinvalbuf(devvp,V_SAVE) / vrele(devvp)) waits for the in-flight FREEBLKS bios to biodone(), which lets the enqueued swi tasks run while fs is still valid. The residual UAF window β€” biodone (task enqueued) β†’ umount unblocks β†’ kfree(fs) β†’ swi task finally runs β€” is real but very tight and did not fire in these runs. It would be widened under heavy load / a backlogged swi taskqueue. The missing taskqueue_drain is therefore a legitimate latent defect that should be closed.

5. The fix (fix.diff)

fix.diff (validated, git apply --check clean) makes two changes:

  1. Correctness β€” consult the right mount (ffs_alloc.c:1670): mp = ip->i_vnode->v_mount; (was ip->i_devvp->v_mount). This is what actually enables FFS async TRIM; without it the feature is dead.

  2. Safety β€” drain pending TRIM before tearing down the mount (ufsmount.h + ffs_alloc.c + ffs_vfsops.c): * add u_int um_trim_pending; to struct ufsmount; * atomic_add_int(&ump->um_trim_pending,1) when a TRIM bio is submitted, atomic_subtract_int(...) + wakeup() in ffs_blkfree_trim_task (also carries tp->i_ump so the task can find the counter); * in ffs_unmount, immediately before kfree(fs->fs_csp): while (ump->um_trim_pending) tsleep(&ump->um_trim_pending,0,"ufstrim",hz);

This turns the implicit "umount usually waits long enough" into a hard guarantee: kfree(fs/ump) cannot run until every deferred ffs_blkfree_trim_task has completed and decremented the counter.

Phase 8 validation (built + booted the fixed kernel)

  • Baseline (unpatched #0): mount ufs+trim, write/unlink/umount β†’ 0 FREEBLKS (TRIM dead), no panic.
  • Full-fix kernel (kern.version 13:01:29, sha256 a0b2864db1c8836382acbf48ff4f1556ce4d3dd7cb67a675158263accbe7f746): mount ufs+trim, write/unlink/umount Γ—5 β†’ 675 FREEBLKS flow (TRIM now operational), every umount returns rc=0, no panic / no kernel warning, deferred completions == FREEBLKS count (810 incl. timed run). Timed: unlink+unmount = 2932 ms (drain holds umount until the deferred tasks finish, then completes cleanly).

The fix therefore (a) makes async TRIM functional and (b) provably closes the latent UAF. recommended_fix: supersedes the finding's proposal (the finding proposed only the drain; the drain is insufficient on its own because without the mount-pointer fix TRIM stays dead, and the mount-pointer fix without the drain would expose the UAF β€” both must ship together).

6. PoC changes

  • asyncd.c (+ Makefile): new β€” root-loaded KLD providing the slow async TRIM disk that QEMU hardware lacks; needed to exercise the TRIM bio path at all.
  • trim_uaf.c, trim_race.c: syscall harnesses that mount ufs+trim directly (bypassing the userland da-only gate), create/delete files, and race umount.
  • fix.diff: the validated two-part fix described above.

7. Honest impact / classification

  • As filed (reachable UAF on current master): false positive. The path is dead code; the UAF cannot occur. Severity should be downgraded or the finding re-filed as "latent UAF + dead async-TRIM feature".
  • Real bugs confirmed during verification: (1) ffs_blkfree MNT_TRIM gate uses the wrong mount pointer β€” FFS async TRIM is completely non-functional; (2) missing taskqueue_drain of swi TRIM tasks in ffs_unmount β€” a latent UAF that becomes live if/when (1) is fixed. Both are closed by fix.diff.
  • No privilege boundary is crossed (the path is root-only on the default guest); no escalation chain applies.

Fix verification

fixed
baseline no→ patch + rebuild →patched clean

fix.diff VALIDATED on a built+booted single-fix kernel. The finding's UAF did NOT reproduce on the unpatched baseline (TRIM path dead: 0 FREEBLKS, no panic) -- that is why this is a not_reproduced verdict. On the full-fix kernel the two changes are confirmed effective: (a) async-TRIM is now FUNCTIONAL -- mount ufs+trim, write/unlink/umount sent 675/810 FREEBLKS bios to the async device (baseline sent 0); (b) the explicit um_trim_pending drain closes the latent UAF -- all 5 umount returned rc=0, NO panic, NO kernel warning, unlink+umount=2932ms (drain holds umount until deferred ffs_blkfree_trim_task completes, then kfree runs safely). before: 0 FREEBLKS (TRIM dead) / after: 675 FREEBLKS flowing + no UAF + clean umount.

baseline (unpatched #0): FREEBLKS count = 0 (TRIM path dead), no panic, umount=251ms. full-fix kernel: FREEBLKS count = 675/810 (TRIM operational), 5/5 umount rc=0, no panic/warning, unlink+umount=2932ms (um_trim_pending drain holds umount). Intermediate 'expose' kernel (mount-pointer fix only, no drain): 405 FREEBLKS flow proving the latent UAF mechanism is real; umount latency tracks async delay exactly (2s->2129ms, 4s->3931ms) = an implicit buf-wait that currently masks the race; the explicit drain in the full fix removes reliance on that implicit wait.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Fri Jul 10 13:01:29 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 a0b2864db1c8836382acbf48ff4f1556ce4d3dd7cb67a675158263accbe7f746)

Confirmed kernel references

Detail

Exploit chain

none -- no memory-corruption primitive manifested (the TRIM path is dead code, so ffs_blkfree always takes the synchronous ffs_blkfree_cg branch and returns; 0 FREEBLKS bios on the unpatched kernel). The latent UAF (store-then-defer-then-deref, confirmed real by un-masking it: 405 FREEBLKS flow and ffs_blkfree_trim_task derefs the stored pointers on the 'expose' kernel) is root-only on the default guest (vfs.usermount=0, no CAM da disk for mount_ufs -o trim, and vn/vbd complete FREEBLKS synchronously), so there is no unprivileged escalation surface to develop. No chain attempted because no corruption reproduced and no privilege boundary is crossable by an unprivileged user.

Evidence (decisive lines)

BASELINE (unpatched #0 06:02:54), asyncd0 + ufs+trim mount, write2MB/unlink/umount x5: DONE 5, FREEBLKS count on asyncd0 = 0, guest up (no panic). Timed: mount=4ms unlink=5ms unmount=251ms. KERNEL-INSTRUMENTATION (ffs_blkfree kprintf, 48 calls): devvp->v_mount=0xfffff8008db3c800 ip->i_vnode->v_mount=0xfffff8008eddb800 devvp_vmtrim=0 ipvmount_trim=1. EXPOSE kernel (mount-pointer fix only): FREEBLKS=405, no panic; umount latency tracks async delay exactly (2s->2129ms, 4s->3931ms) = implicit umount buf-wait masks the latent UAF. FULL-FIX kernel: FREEBLKS=675/810, all 5 umount rc=0, no panic/warning, unlink+umount=2932ms (explicit um_trim_pending drain).

PoC changes

Added asyncd.c (+Makefile): root-loaded KLD providing the slow async-TRIM disk QEMU lacks (FREEBLKS deferred 2s via callout) so the TRIM bio path is exercisable at all. Added trim_uaf.c/trim_race.c: syscall harnesses that mount ufs+trim directly (bypassing mount_ufs's da-only gate) and race umount. Authored fix.diff: (1) ffs_alloc.c:1670 mp = ip->i_vnode->v_mount (was ip->i_devvp->v_mount) -- makes async-TRIM functional; (2) um_trim_pending counter + ffs_unmount drain -- closes the latent UAF. Wrote VERDICT.md/run.log/env.txt/manifest.json.

Verified recommended fix

fix.diff supersedes the finding's proposal (finding proposed only the drain; drain-alone is insufficient because TRIM stays dead, and a pointer-only fix would EXPOSE the latent UAF -- both must ship together). (1) ffs_alloc.c:1670: change mp = ip->i_devvp->v_mount to mp = ip->i_vnode->v_mount so the MNT_TRIM gate consults the real ufs mount and async-TRIM actually engages. (2) Add u_int um_trim_pending to struct ufsmount (ufsmount.h); atomic_add in ffs_blkfree when a TRIM bio is submitted, atomic_subtract+wakeup in ffs_blkfree_trim_task (carrying tp->i_ump); and in ffs_unmount before kfree(fs->fs_csp) loop while (ump->um_trim_pending) tsleep(&ump->um_trim_pending,0,"ufstrim",hz) so kfree cannot precede deferred ffs_blkfree_trim_task completion. Full git-apply-able diff in findings/poc/DF-0793/fix.diff (6 hunks, git apply --check clean).

Verdict

NOT REPRODUCED (false-positive as filed). The async-TRIM store/defer/UAF code in ffs_blkfree (ffs_alloc.c:1679-1697) is unreachable on current master because its MNT_TRIM gate reads the WRONG mount pointer: mp = ip->i_devvp->v_mount (ffs_alloc.c:1670) is the device vnode's devfs mount, which NEVER carries MNT_TRIM, instead of ip->i_vnode->v_mount (the ufs mount). Proven two ways: (1) a diagnostic kprintf in ffs_blkfree shows devvp->v_mount != ip->i_vnode->v_mount with devvp_vmtrim=0 vs ipvmount_trim=1; (2) an async-disk KLD (asyncd0) received 0 FREEBLKS bios under a ufs+trim mount on the unpatched kernel (vs 405 once the pointer is fixed). The 'no taskqueue_drain' half of the finding is factually correct (grep confirms zero drains in sys/vfs/ufs), but the vulnerable path never executes, so the UAF cannot occur. Root cause = a real latent bug: the wrong mount pointer makes the ENTIRE FFS async-TRIM feature dead/non-functional.