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)
PoC verification
Evidence pack
findings/poc/DF-0793 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
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 callmount(2)at all.- The only
MNT_TRIM-accepting mount path (mount_ufsuserland) restricts TRIM to CAMdadisks withtrim_enabled; the guest has nodadisk (root isvbd0/hammer2). The kernelmount(2)would acceptMNT_TRIMon any device, but issuing it still requires root. vncompletesBUF_CMD_FREEBLKSas 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:
-
Correctness β consult the right mount (
ffs_alloc.c:1670):mp = ip->i_vnode->v_mount;(wasip->i_devvp->v_mount). This is what actually enables FFS async TRIM; without it the feature is dead. -
Safety β drain pending TRIM before tearing down the mount (
ufsmount.h+ffs_alloc.c+ffs_vfsops.c): * addu_int um_trim_pending;tostruct ufsmount; *atomic_add_int(&ump->um_trim_pending,1)when a TRIM bio is submitted,atomic_subtract_int(...)+wakeup()inffs_blkfree_trim_task(also carriestp->i_umpso the task can find the counter); * inffs_unmount, immediately beforekfree(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.version13:01:29, sha256a0b2864db1c8836382acbf48ff4f1556ce4d3dd7cb67a675158263accbe7f746): mount ufs+trim, write/unlink/umount Γ5 β 675 FREEBLKS flow (TRIM now operational), everyumountreturnsrc=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_blkfreeMNT_TRIM gate uses the wrong mount pointer β FFS async TRIM is completely non-functional; (2) missingtaskqueue_drainof swi TRIM tasks inffs_unmountβ a latent UAF that becomes live if/when (1) is fixed. Both are closed byfix.diff. - No privilege boundary is crossed (the path is root-only on the default guest); no escalation chain applies.
Fix verification
fixedfix.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.
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.
No comments yet.