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

Syncer-trigger API (trigger_syncer*/speedup_syncer) has no lifetime interlock against vn_syncer_thr_stop β€” UAF atomic-write on freed syncer ctx (and NULL-ctx / freed-mount reads)

Field Value
ID DF-2832
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:L/I:L/A:H
CWE CWE-362 β†’ CWE-416 (kernel heap write)
File sys/kern/vfs_sync.c
Lines 561-614 (readers) vs 353-357 (free side)
Area kern
Confidence certain
Discovered 2026-08-31
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

trigger_syncer(), trigger_syncer_start(), trigger_syncer_stop() and speedup_syncer() load mp->mnt_syncer_ctx with no lock/refcount and then atomically RMW the syncer_ctx (fetchadd Β±2, set-bit-0, wakeup). vn_syncer_thr_stop() publishes mnt_syncer_ctx=NULL and hashdestroy+kfree()s the ctx with zero synchronization against those readers, and can also leave the caller's struct mount itself freed. A reader preempted between load and RMW completes an atomic write on freed M_TEMP heap; ctx addresses are objcache-reused across mounts, so a late stale RMW corrupts the next mount's syncer_trigger. The adjacent facet: after the NULL publish, vn_syncer_add/vsetisdirty (every dirty buffer via reassignbuf) do lwkt_gettoken(&ctx->sc_token) with ctx==NULL β†’ guaranteed page fault β€” stock-unreachable through normal unmount ordering but inherent to the API.

Threat model & preconditions

Local unprivileged user drives the trigger side (hammer2 modifying ops under dirty-chain pressure β†’ hammer2_pfs_memory_wait β†’ trigger_syncer*); a stalled opener holds no fd on the mount so dounmount's kill scan never matches it β€” the unpriv storm survives deep into umount -f teardown. The privileged side is umount -f. Result: Β±2/bit-0 atomic write on freed/reused kernel heap (silent latent corruption, cross-mount), or UAF read of freed M_MOUNT.

Proof of contest

VERIFIED with a witness kernel (findings/poc/DF-2832/): unpriv churn parks at the exact stock load→RMW boundary while the REAL vn_syncer_thr_stop frees the ctx → "HIT stale ctx" (on stock those six threads would have executed atomic_fetchadd on freed memory); same run panicked at lwkt_gettoken+0x64 via the NULL-ctx facet. Stock runs ~8 cycles no crash (ns window). Fix (mnt_token shared/exclusive interlock + NULL guards) validated: FREE with NO HIT, no panic.

Validated fix.diff in findings/poc/DF-2832/.

Timeline

  • 2026-08-31 Discovered during pass-2 audit of vfs_sync.c (GLM 5.3); witness-kernel UAF proven + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2832 Β· 23 files
FileTypeDescriptionSize
README.md β€” 3.4 KB ↓ raw
VERDICT.md β€” 6.1 KB ↓ raw
verdict.json β€” 7.4 KB view raw
churn_open.c β€” 1.5 KB view raw
dirty_writer.c β€” 1.9 KB view raw
race_stock.csh β€” 1.6 KB ↓ download
race_stock2.sh β€” 1.1 KB view raw
race_oneshot.sh β€” 1.2 KB view raw
race_witness2.sh β€” 2.1 KB view raw
race_witness3.sh β€” 1.9 KB view raw
witness_kernel_baseline.c β€” 25.1 KB view raw
witness_kernel_fixed.c β€” 25.7 KB view raw
witness.diff β€” 2.3 KB view raw
fix.diff β€” 2.9 KB view raw
build.sh β€” 643 B view raw
build.log β€” 75 B view raw
run.stock.log β€” 1.7 KB view raw
run.stock2.log β€” 101 B view raw
run.witness2b.log β€” 8.2 KB view raw
run.witness3.log β€” 202 B view raw
panic.txt β€” 2.4 KB view raw
run.fixvalidation.log β€” 517 B view raw
env.txt β€” 945 B view raw

DF-2832 β€” syncer-trigger API UAF vs vn_syncer_thr_stop teardown

File: sys/kern/vfs_sync.c Class: CWE-362 / CWE-416 (race β†’ use-after-free write, kernel heap) Severity: Medium (memcorrupt bucket) Β· Confidence: certain (defect), window demonstrated on-guest (witness); stock crash not observed (expected).

What the bug is

trigger_syncer(), trigger_syncer_start(), trigger_syncer_stop() and speedup_syncer() (sys/kern/vfs_sync.c:561-614) load mp->mnt_syncer_ctx lock-free and then operate on the struct syncer_ctx (atomic add Β±2 / set-bit-0 / wakeup). vn_syncer_thr_stop() (vfs_sync.c:330-358) publishes mp->mnt_syncer_ctx = NULL and then hashdestroy()+kfree()s the ctx with no synchronization against those readers. A thread preempted between the load and the RMW completes an atomic write on freed heap memory (M_TEMP). The callers' mp pointer itself is also unreferenced (dounmount frees the struct mount at vfs_syscalls.c:1107-1117), so mp->mnt_syncer_ctx can be a read of freed M_MOUNT memory.

Reachability (unprivileged side): any process performing hammer2 modifying ops under dirty-chain pressure: open(O_CREAT|O_WRONLY) β†’ ncp_writechk (vfs_vnops.c:481) β†’ VFS_MODIFYING β†’ hammer2_vfs_modifying (hammer2_vfsops.c:2925) β†’ hammer2_pfs_memory_wait (hammer2_vfsops.c:2938) β†’ trigger_syncer/trigger_syncer_start (hammer2_vfsops.c:2962/2977). hammer2_flush.c:272 (speedup_syncer) is a kernel-thread caller. The privileged side is umount -f β†’ vfs_unmount β†’ vn_syncer_thr_stop (vfs_vfsops.c:135).

Notable: a thread stalled in hammer2_pfs_memory_wait during open() holds no fd on the mount, so dounmount's process-kill scan (process_uses_mount, vfs_syscalls.c:727-757) does not match it β€” the trigger storm runs all the way through the forced-unmount teardown.

What is in this pack

file what
churn_open.c unpriv open/write/unlink churner (drives the stall loop)
dirty_writer.c unpriv bulk pwrite churner (keeps dirty chains over the limit)
race_stock2.sh stock-kernel race: unpriv storm + umount -f cycling
witness_kernel_baseline.c guest-only instrumented kernel: parks trigger callers at the exact load→RMW boundary, counts hits, debug.df2832_stop invokes the real vn_syncer_thr_stop
witness_kernel_fixed.c same + fix.diff interlock + busy-spin park
panic.txt baseline witness decisive run: HIT stale ctx=0x… + panic at lwkt_gettoken+0x64
run.fixvalidation.log fix-validation run: parked=3 β†’ FREE, no HIT, no NULL-deref panic
fix.diff repo-side fix (mnt_token interlock + NULL guards)
VERDICT.md full narrative and honesty notes

Reproduce (summary)

  1. Stock race (no crash expected; window is ns vs umount's ms): sh race_stock2.sh 10 as root in the guest.
  2. Witness proof: build witness_kernel_baseline.c as the kernel (cp over /usr/src/sys/kern/vfs_sync.c, make nativekernel && make installkernel, reboot), then mount a vn-backed hammer2 fs, run 6Γ— churn_open as nobody, wait for debug.df2832_parked > 0, then sysctl debug.df2832_stop=1. Console shows stop: parked=N β†’ thr_stop FREE ctx=… β†’ DF2832: HIT stale ctx=… (+ the artifact panic below).
  3. Fix validation: build witness_kernel_fixed.c, same procedure β†’ parked=N β†’ FREE, no HIT.
VERDICT.md
↓ download raw

DF-2832 β€” VERDICT

Bottom line: the defect is real and its exact interleaving was demonstrated on a running kernel; a stock-kernel crash was not observed (and is not expected to be observable without KASAN, because the primitive is a bounded atomic write (Β±2 / bit-0) onto freed heap, usually silently absorbed). The fix closes the window and was validated on-guest.

1. Code-level proof (no run needed)

  • trigger_syncer*()/speedup_syncer() read mp->mnt_syncer_ctx and RMW the ctx with no lock, no refcount, no mount hold β€” vfs_sync.c:570-571, 584-586, 595-596, 608-611.
  • vn_syncer_thr_stop() NULLs the pointer under sc_token (which the trigger family never takes) and then hashdestroy+kfrees the ctx β€” vfs_sync.c:353-357. hashdestroy's only protection is a KASSERT on the lists, nothing protects the syncer_trigger field readers.
  • The caller's struct mount * is also unreferenced; dounmount can free it (vfs_syscalls.c:1107-1117) while a hammer2 frontend thread is inside the trigger functions.
  • The other side of the same API defect: after the NULL publish, any lock-free vn_syncer_add/vsetisdirty (reassignbuf β€” every dirty buffer) does lwkt_gettoken(&ctx->sc_token) with ctx == NULL β†’ guaranteed page fault. In the stock ordering this facet is unreachable (VFS_UNMOUNT's vflush drains all vnodes before thr_stop runs β€” verified: vgone_vxlocked force-dequeues at vfs_subr.c:1573-1580, and dounmount panics on dangling vnodes at vfs_syscalls.c:1085-1086); it becomes reachable the moment any code path stops the syncer on a live mount (demonstrated below; the failed-mount thr_stop paths at vfs_syscalls.c:458 / vfs_conf.c:369,508 are the speculative stock candidates).

2. On-guest demonstration (witness kernels, guest-only instrumentation)

The witness kernel inserts a park at the exact stock instruction boundary β€” after ctx = mp->mnt_syncer_ctx and before the atomic RMW β€” which is precisely where a preemption lands on the stock kernel. A park is a faithful preemption emulation for the stock code because the stock code holds no lock there. The teardown is the real vn_syncer_thr_stop() (invoked deterministically via a debug sysctl once parks are observed, because the full umount -f path cannot complete while the unpriv storm saturates hammer2's dirty-chain backpressure β€” see Β§4). Load side is genuinely unprivileged: nobody running churn_open (stall reached via open(O_CREAT) β†’ ncp_writechk β†’ VFS_MODIFYING β†’ hammer2_pfs_memory_wait).

Decisive run (panic.txt):

DF2832: stop: parked=6, tearing down syncer of mp=0xfffff8008fa6b000
DF2832: thr_stop FREE ctx=0xfffff801174ee9a0 mp=0xfffff8008fa6b000
pan[D]iF…2:8 …HIT stale ctx=0xn f…   <- kprintf streams interleaved
Stopped at  lwkt_gettoken+0x64:  movq (%r12),%rax      <- db>
  • parked=6: six unprivileged trigger callers were inside the window.
  • FREE: the real vn_syncer_thr_stop destroyed the ctx under them.
  • HIT stale ctx=0x…: a parked caller observed the teardown β€” on the stock code it would have executed atomic_fetchadd_int(&ctx->syncer_trigger, 2) on freed memory.
  • The lwkt_gettoken panic is the NULL-ctx facet (a churner's vn_syncer_add after teardown of the still-live mount) β€” same API defect, second facet, artifact of the deterministic teardown bypassing VFS_UNMOUNT ordering (not stock-reachable; see Β§1).

Also observed across cycles: the freed ctx address is reused by the next mount (0xfffff80117b51ee0/…ee0/…f40 repeatedly), so a stale RMW that lands late corrupts the next mount's syncer_trigger β€” cross-mount corruption, silent.

3. Fix validation (fix.diff)

fix.diff (against the repo sys/, never applied there): 1. trigger_syncer/trigger_syncer_start/trigger_syncer_stop/ speedup_syncer hold mnt_token shared across load+RMW; 2. vn_syncer_thr_stop NULLs+frees under mnt_token exclusive; 3. NULL guards in vn_syncer_add/vn_syncer_remove/vsetisdirty/ vsetobjdirty (defense-in-depth; also what stops the Β§2 NULL-panic facet).

Validation build = witness + fix, with the park switched to a busy-spin (a tsleep would drop the lwkt token and no longer emulate preemption; real preemption holds tokens). Identical procedure:

DF2832: stop: parked=3, tearing down syncer of mp=0xfffff8008fa6b000
DF2832: thr_stop FREE ctx=0xfffff8011750e9a0 mp=0xfffff8008fa6b000
panic: hashdestroy: hash not empty        <- artifact, see below
  • No HIT: with the interlock, the teardown cannot slip between the load and the RMW β€” the exclusive acquire blocks until every in-window caller completes. The window demonstrated in Β§2 is closed.
  • No lwkt_gettoken NULL-deref panic (guards work; execution proceeds until the teardown-of-a-live-mount hits hashdestroy's "hash not empty" KASSERT β€” an artifact of bypassing VFS_UNMOUNT's vflush that is orthogonal to the fix and would fire identically on the unfixed kernel if the NULL-deref hadn't fired first).

4. Honesty notes

  • Stock full-umount race: 40 iterations early (defective harness β€” /tmp denies exec for nobody, so the unpriv binaries never ran; only the teardown side was exercised) + ~8 iterations after the fix (run.stock2.log, working unpriv storm: 3Γ—dirty_writer + 6Γ—churn_open, umount -f cycles) β€” no crash. Expected: the window is a few ns vs. an umount taking seconds; without KASAN the Β±2 write is invisible.
  • Why the deterministic sysctl teardown: with the storm saturating vfs.hammer2.limit_dirty_chains, umount -f cannot get through VFS_SYNC(mp, MNT_WAIT) (vfs_syscalls.c:1002) β€” hammer2's backpressure oscillates (churners stall β†’ chains drain β†’ churners resume) forever. That interaction (unpriv dirty-pressure wedging forced unmount in VFS_SYNC) may deserve its own look; it is a livelock, not corruption.
  • Witness parks were scoped to never fire on the root filesystem (an unscoped first attempt wedged the guest; instrumentation artifact).
  • stat_rush_requests non-atomic increment (vfs_sync.c:569) observed while reading β€” cosmetic, not filed.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline witness (stock semantics): parked=6 in-window callers -> real vn_syncer_thr_stop free -> 'DF2832: HIT stale ctx' (window proven) + lwkt_gettoken NULL-ctx panic. Patched (witness+fix.diff, busy-spin park which holds mnt_token shared like a preempted fixed thread): identical procedure -> parked=3 -> FREE with NO HIT and no NULL-deref panic; only the orthogonal 'hashdestroy: hash not empty' artifact KASSERT (teardown-of-live-mount, fires regardless of the fix). The load->RMW vs free interleaving is closed by the mnt_token interlock; the NULL-ctx facet is closed by the guards. Guest left on stock kernel #0 after reset.

["run.fixvalidation.log: 'DF2832: stop: parked=3, tearing down syncer of mp=0xfffff8008fa6b000' -> 'DF2832: thr_stop FREE ctx=0xfffff8011750e9a0' with no HIT line", 'panic.txt (baseline): HIT + lwkt_gettoken+0x64 panic']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #5: Wed Sep 2 02:35:06 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (witness+fix build, in-guest only)

Confirmed kernel references

Detail

Exploit chain

unprivileged user churns creates/writes on a hammer2 mount (drives hammer2_pfs_memory_wait -> trigger_syncer*); privileged side runs umount -f concurrently; any trigger caller preempted between its mnt_syncer_ctx load and the atomic op when vn_syncer_thr_stop frees the ctx performs an atomic add +/-2 (or set bit 0) on freed kernel heap (M_TEMP objcache; addresses observed reused by subsequent mounts) -> latent heap corruption / cross-mount syncer_trigger corruption; the caller's struct mount pointer is itself unreferenced, so mp->mnt_syncer_ctx can also be a UAF read of freed M_MOUNT.

Evidence (decisive lines)

["panic.txt: 'DF2832: stop: parked=6' -> 'thr_stop FREE ctx=...' -> interleaved 'DF2832: HIT stale ctx=0xn...' + panic; 'Stopped at lwkt_gettoken+0x64'", "run.fixvalidation.log: fix build 'stop: parked=3' -> 'thr_stop FREE' with NO HIT line and no lwkt_gettoken panic", 'run.stock2.log: stock kernel, working unpriv storm (3 dirty_writer + 6 churn_open), umount -f cycles, no panic', 'witness_kernel_baseline.c / witness_kernel_fixed.c: the two in-guest instrumented kernels (park at the exact stock boundary; debug.df2832_stop invokes the real vn_syncer_thr_stop)', 'fix.diff: repo-side mnt_token interlock + NULL guards']

PoC changes

Pass-2 new finding (no seed). Built from scratch; notable iterations: /tmp tmpfs denies exec for nobody (binaries moved to /usr/local); csh su -c redirections need csh syntax; guest sysctl rejects hex 64-bit (dropped the pointer sysctl, auto-scoped witness off the root fs via rootvnode->v_mount); tsleep-park wedged the box when unscoped (root-fs trigger ops parked); full-umount variant cannot complete under storm (hammer2 dirty-chain backpressure oscillation in VFS_SYNC(MNT_WAIT)) so the deterministic witness sysctl invokes the real vn_syncer_thr_stop directly; fix-validation park switched to busy-spin because tsleep drops lwkt tokens and no longer emulates preemption.

Verified recommended fix

Interlock the syncer-trigger API against vn_syncer_thr_stop: hold mnt_token shared across the mnt_syncer_ctx load + RMW in trigger_syncer/trigger_syncer_start/trigger_syncer_stop/speedup_syncer, and NULL-publish + hashdestroy + kfree under mnt_token exclusive in vn_syncer_thr_stop; add NULL-ctx guards in vn_syncer_add/vn_syncer_remove/vsetisdirty/vsetobjdirty (see fix.diff).

Verdict

The syncer-trigger API (trigger_syncer/trigger_syncer_start/trigger_syncer_stop/speedup_syncer, sys/kern/vfs_sync.c:561-614) loads mp->mnt_syncer_ctx lock-free and atomically RMWs the ctx with no lifetime interlock against vn_syncer_thr_stop() (vfs_sync.c:353-357) which NULLs the pointer and hashdestroy+kfree's the ctx. Demonstrated on the guest: six unprivileged (nobody) trigger callers -- stalled in hammer2_pfs_memory_wait via open(O_CREAT)->ncp_writechk->VFS_MODIFYING -- were held at the exact stock load->RMW instruction boundary while the REAL vn_syncer_thr_stop() freed the ctx under them; they then observed the teardown ('DF2832: HIT stale ctx=0x...'), i.e. on unmodified stock code their atomic_fetchadd_int(&ctx->syncer_trigger,2) would have executed on freed heap. The same run panicked at lwkt_gettoken+0x64 via the adjacent NULL-ctx facet (vn_syncer_add on the torn-down-but-live mount) -- that facet is not reachable through the stock umount ordering (VFS_UNMOUNT vflushes first), but the freed-ctx RMW facet is, via umount -f racing any in-flight hammer2 modifying op. A stock crash was not observed in ~8 full forced-umount cycles under working unpriv trigger load (run.stock2.log) -- expected for a few-ns window without KASAN; the primitive is a silent +/-2 or bit-0 atomic write onto freed M_TEMP memory whose address is reused by later mounts (cross-mount corruption observed). Fix (mnt_token shared/exclusive interlock + NULL guards, fix.diff) validated on-guest: identical procedure yields parked=3 -> FREE with NO HIT and no NULL-deref panic.