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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2832 Β· 23 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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)
- Stock race (no crash expected; window is ns vs umount's ms):
sh race_stock2.sh 10as root in the guest. - Witness proof: build
witness_kernel_baseline.cas the kernel (cpover/usr/src/sys/kern/vfs_sync.c,make nativekernel && make installkernel, reboot), then mount a vn-backed hammer2 fs, run 6Γchurn_openas nobody, wait fordebug.df2832_parked > 0, thensysctl debug.df2832_stop=1. Console showsstop: parked=Nβthr_stop FREE ctx=β¦βDF2832: HIT stale ctx=β¦(+ the artifact panic below). - Fix validation: build
witness_kernel_fixed.c, same procedure βparked=N β FREE, no HIT.
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()readmp->mnt_syncer_ctxand 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 undersc_token(which the trigger family never takes) and thenhashdestroy+kfrees the ctx β vfs_sync.c:353-357.hashdestroy's only protection is a KASSERT on the lists, nothing protects thesyncer_triggerfield readers.- The caller's
struct mount *is also unreferenced;dounmountcan 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) doeslwkt_gettoken(&ctx->sc_token)withctx == 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_vxlockedforce-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 realvn_syncer_thr_stopdestroyed the ctx under them.HIT stale ctx=0xβ¦: a parked caller observed the teardown β on the stock code it would have executedatomic_fetchadd_int(&ctx->syncer_trigger, 2)on freed memory.- The
lwkt_gettokenpanic is the NULL-ctx facet (a churner'svn_syncer_addafter 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_gettokenNULL-deref panic (guards work; execution proceeds until the teardown-of-a-live-mount hitshashdestroy'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 β
/tmpdenies 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 -fcycles) β 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 -fcannot get throughVFS_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_requestsnon-atomic increment (vfs_sync.c:569) observed while reading β cosmetic, not filed.
Fix verification
fixedBaseline 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']
Confirmed kernel references
- sys/kern/vfs_sync.c:561-572
- sys/kern/vfs_sync.c:579-588
- sys/kern/vfs_sync.c:590-598
- sys/kern/vfs_sync.c:603-614
- sys/kern/vfs_sync.c:330-358
- sys/kern/vfs_vfsops.c:124-138
- sys/kern/vfs_syscalls.c:1107-1117
- sys/kern/vfs_vnops.c:474-485
- sys/vfs/hammer2/hammer2_vfsops.c:2925-3007
- sys/vfs/hammer2/hammer2_flush.c:272
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.
No comments yet.