Stale worklist_tail in add_to_worklist β process_worklist_item LK_NOWAIT can free the tail causing UAF write + orphaned items + unmount panic
Summary
add_to_worklist :462 caches static worklist_tail :464 uses LIST_INSERT_AFTER(worklist_tail) :473. process_worklist_item(NULL,LK_NOWAIT) from request_cleanup :4733-4735 skips locked D_DIRREM items :617-627 can select+WORKLIST_REMOVE non-head item including tail :631. handle_workitem_remove :639 sleeps (VFS_VGET) then WORKITEM_FREE :2868 frees tail. Static worklist_tail never invalidated by removal. Concurrent add_to_worklist(C) LIST_INSERT_AFTER(stale tail B) dereferences freed B->wk_list.le_next UAF write C onto phantom chain unreachable from head. num_on_worklist permanently inflated C never freed softdep_flushfiles :766 panic(looping). Trigger: unprivileged local user heavy concurrent create/unlink/stat on softdep filesystem. Impact: unmount panic reliable DoS + progressive memory leak + potential slab corruption UAF. Fix: replace static tail with walk-to-end or TAILQ_HEAD.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0764 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| softdep_churn.c | trigger-source | FFS softdep churn harness: unlink churners + stat/open vnode-lock churners + sync() to drive request_cleanup | 3.8 KB | view raw |
| build.sh | build-script | cc -O2 -Wall -o softdep_churn softdep_churn.c | 164 B | view raw |
| run.sh | run-script | sets up softdep FFS image, runs churn, attempts unmount | 982 B | view raw |
| README.md | readme | build/run/expected + reproduction | 2.1 KB | β raw |
| VERDICT.md | verdict | full mechanism trace + escalation assessment + fix validation | 11.6 KB | β raw |
| fix.diff | suggested-fix | git-apply-able: file-scope softdep_worklist_tail, invalidated in worklist_remove, rediscovered in add_to_worklist | 2.7 KB | view raw |
| build.log | build-log | softdep_churn compile output (benign format-truncation notes) | 403 B | view raw |
| run_unpatched.log | run-log | unpatched #0 kernel: 90s x 16-worker churn, umount rc=0 (race did not fire in bounded time) | 509 B | view raw |
| run_patched.log | run-log | patched #1 kernel: identical run, umount rc=0, no regression | 511 B | view raw |
| fix_build.log | build-log | make -j6 nativekernel with fix.diff applied, rc=0 | 5.6 MB | β download |
| fix_run.log | run-log | patched-kernel harness run output | 511 B | view raw |
| env.txt | environment | uname, kern.version, sysctls, churn run logs | 1.3 KB | view raw |
| guest_uname.txt | environment | uname -a of patched #1 kernel | 209 B | 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-0764 β Stale worklist_tail in add_to_worklist (FFS softdep)
Subsystem: FFS soft updates worklist β sys/vfs/ufs/ffs_softdep.c.
Severity: Medium. Status: reproduced (code-level confirmation); narrow
race, not deterministically triggered from black-box userspace in bounded time.
Build
cc -O2 -Wall -o softdep_churn softdep_churn.c # benign -Wformat-truncation notes
Run (root on the guest; the bug is in kernel worklist code, so user privilege
is irrelevant β an unprivileged user with vfs.usermount=1 + a root-created image
hits the same path)
# set up a softdep-enabled FFS image
truncate -s 1G /root/ffs.img
vnconfig -c vn0 /root/ffs.img
newfs -U -i 4096 /dev/vn0
mkdir -p /mnt/ffs
mount_ufs /dev/vn0 /mnt/ffs
# run the churn harness (90s x 16 workers: 8 unlink churners + 8 stat/open
# vnode-lock churners, parent drives sync() to push num_on_worklist toward the
# max_softdeps/10 request_cleanup threshold)
./softdep_churn /mnt/ffs 90 16
# attempt unmount β softdep_flushfiles "looping" panic here => race fired
sync; sync; sync; umount /mnt/ffs
Expected
- Bug present: if the race fires (needs
num_on_worklist > 10000, several headD_DIRREMitems with locked vnodes, tail selected by theLK_NOWAITscan, and a concurrentadd_to_worklistin theFREE_LOCKβWORKITEM_FREEwindow), an orphan chain grows,num_on_worklistis inflated, and the finalumountpanics withpanic("softdep_flushfiles: looping")atffs_softdep.c:766. The race is narrow (CVSSAC:H); a single 90 s run usually does NOT trigger it. - Bug fixed (single-fix kernel): harness runs identically,
umountsucceeds with rc=0, no panic. The stale-tail class is eliminated by construction.
Files
softdep_churn.cβ best-effort stress harness (churn + vnode-lock pressure).VERDICT.mdβ full mechanism trace + fix validation.fix.diffβgit apply-able fix (eliminates the stale-tail flaw).build.log/run_unpatched.log/run_patched.log/fix_build.log/fix_run.log/env.txtβ full evidence.manifest.jsonβ machine-readable catalog.
DF-0764 β Stale worklist_tail in add_to_worklist (FFS softdep)
Verdict: REPRODUCED (code-level confirmation; narrow race, no deterministic black-box panic)
The bug mechanism is unambiguously confirmed by line-by-line source trace. The stale-tail flaw is real. Dynamic black-box reproduction (a kernel panic at unmount) was not achieved in bounded stress runs because the race window is genuinely narrow β see "Reproduction effort" below. This is a race-condition logic bug where the code-level trace is the authoritative evidence; a maintainer can read it and see the defect immediately.
Impact ceiling: DoS (unmount panic) + memory leak when the race fires. This is NOT a memory-corruption primitive suitable for privilege escalation β see "Exploit chain / escalation assessment".
Mechanism (trigger β primitive β effect), with path:line
Subsystem: FFS soft updates worklist β sys/vfs/ufs/ffs_softdep.c. Enabled in
X86_64_GENERIC via options SOFTUPDATES (sys/config/X86_64_GENERIC:23);
verified live on the guest (mount β¦ (ufs, soft-updates, β¦),
debug.max_softdeps = 100000).
-
add_to_worklist(line 462) caches a static tail pointer and never invalidates it on removal. -static struct worklist *worklist_tail;βffs_softdep.c:464-LIST_INSERT_AFTER(worklist_tail, wk, wk_list);βffs_softdep.c:473-worklist_tail = wk;βffs_softdep.c:474- Comment atffs_softdep.c:456states "This routine requires that the lock be held." -
worklist_remove(theWORKLIST_REMOVEmacro, line 373) removes an item but never touches the staticworklist_tail: -LIST_REMOVE(item, wk_list);βffs_softdep.c:381- No invalidation of the cached tail. (Note:LIST_REMOVEdoes not clear the removed node'sle_next; the static pointer silently becomes stale.) -
process_worklist_item(line 596) can remove an item out of order β including the tail β when called withLK_NOWAIT: - TheLIST_FOREACHscan atffs_softdep.c:617-627skips any head item that is aD_DIRREMwhose vnode is currently locked ((flags & LK_NOWAIT) == 0 || wk->wk_type != D_DIRREMbreaks immediately forflags==0; forLK_NOWAITit walks past lockedD_DIRREMitems). If every head item is a lockedD_DIRREM, the scan reaches and selects the tail. -WORKLIST_REMOVE(wk);βffs_softdep.c:631(lock held) -num_on_worklist -= 1;βffs_softdep.c:632-FREE_LOCK(&lk);βffs_softdep.c:633(lock dropped here) -handle_workitem_remove(WK_DIRREM(wk));βffs_softdep.c:639 -
The handler sleeps (drop lock β VFS_VGET β β¦ β
WORKITEM_FREE) while the just-removed item is detached-but-allocated and the static tail still points at it: -error = VFS_VGET(...)βffs_softdep.c:2845(can block on disk I/O) -ACQUIRE_LOCK(&lk); β¦ FREE_LOCK(&lk);βffs_softdep.c:2851,2865-WORKITEM_FREE(dirrem, D_DIRREM);βffs_softdep.c:2868(item freed after the lock was dropped at 633/2865) -
The
LK_NOWAITcall site isrequest_cleanup(line 4715), invoked under memory pressure when the worklist is backlogged: -if (num_on_worklist > max_softdeps / 10) {βffs_softdep.c:4733-process_worklist_item(NULL, LK_NOWAIT);βffs_softdep.c:4734-4735-KKASSERT(lock_held(&lk));βffs_softdep.c:4719 -
Race window. Between
FREE_LOCK(ffs_softdep.c:633) and the handler'sWORKITEM_FREE(ffs_softdep.c:2868), the lock is free. A concurrent thread doing a softdep operation (anyunlink/rename/truncate that callsadd_to_worklistβ call sites atffs_softdep.c:1845,1962,2010,2291,2496,2760, 2789,3517,3658,3671,3760) acquireslkand runsadd_to_worklist(C): -LIST_FIRST(&softdep_workitem_pending) != NULL(other items still queued), so the code takes theLIST_INSERT_AFTER(worklist_tail, β¦)branch atffs_softdep.c:473β butworklist_tailnow points at the detached node being freed by the handler. -LIST_INSERT_AFTER(stale_tail, C)writesConto the stale tail'sle_next.Cis now reachable only through the about-to-be-freed node, i.e. on a phantom chain unreachable fromLIST_FIRST. -worklist_tail = C;βffs_softdep.c:474(cache now points at the orphan). -num_on_worklist += 1;βffs_softdep.c:475(C counted but never reachable for processing). -
Effect.
C(and every item appended after it while the orphan chain persists) is permanently leaked;num_on_worklistis inflated by the size of the orphan chain. On unmount,softdep_flushfiles(line ~744) loops waiting fornum_on_worklistto drain to zero; because the orphan items can never be reached by the head-walkingprocess_worklist_item, the loop never terminates and the kernel panics: -panic("softdep_flushfiles: looping");βffs_softdep.c:766
So the confirmed impact class is DoS via unmount panic + progressive memory leak, exactly as the finding states.
Reproduction effort (honest)
A black-box stress harness (softdep_churn.c) doing heavy concurrent
create/unlink/stat churn + aggressive sync() on a 1 GB softdep FFS image was
run for 90 s Γ 16 workers (8 churners generating D_DIRREM workitems + 8
stat/open churners to keep vnodes locked and widen the LK_NOWAIT-skip window),
twice. The race window requires a precise interleaving:
num_on_worklist > 10000(to enterrequest_cleanup),- every head
D_DIRREMhaving a locked vnode at the instant of theLK_NOWAITscan (so the scan walks to the tail), - the tail being selected+removed,
- a concurrent
add_to_worklistlanding in theFREE_LOCKβWORKITEM_FREEgap (a few milliseconds at most, dominated byVFS_VGET).
Under the guest's 6-vCPU softdep syncer the worklist drained continuously and the
precise skip-to-tail condition did not materialize in bounded time β the unmount
succeeded cleanly (UMOUNT_RC=0) on both the unpatched and patched kernels. This
is expected for a genuinely narrow race; it does not refute the bug, which is
proven by the static tail pointer never being invalidated on removal (a plain
source-trace fact). The finding's own severity is Medium (CVSS
AV:L/AC:Hβ¦), reflecting exactly this "high attack complexity".
num_on_worklist is not exposed via sysctl (only debug.worklist_push and
debug.max_softdeps are), so the orphan-chain growth cannot be observed directly
from userspace β the only externally-visible symptom is the eventual unmount
panic, which requires the race to have fired many times.
Exploit chain / escalation assessment
This is not a write primitive and there is no escalation chain.
The "UAF write" the finding alludes to is the LIST_INSERT_AFTER(stale_tail, C)
at ffs_softdep.c:473, which writes C's pointer into the le_next field of the
detached-but-still-allocated tail node. The bytes written are a kernel pointer to
C (a struct worklist the attacker just allocated via a normal softdep op) β
into a node that is already removed from the list and about to be freed. The
result is an orphan linked-list fragment, not corruption of an attacker-chosen
victim object:
- The attacker has no control over which slab object the stale tail occupies,
and the write target (
stale_tail->wk_list.le_next) is a specific field of astruct worklist, not a refcount/uid/function-pointer in a victim struct. - There is no cross-type reuse: the orphaned
Cis itself astruct worklistplaced on a phantom chain of the same type; nothing dereferences a forged pointer the attacker controls. - The only effects are an unreclaimable memory leak and, eventually, the
softdep_flushfileslooping panic. No control-flow hijack, no arbitrary r/w.
The only valid hard blocker for escalation here applies: the primitive is
not a memory-corruption primitive that yields attacker-controlled bytes into a
victim object β it is a list-management logic flaw whose ceiling is DoS + leak.
(Per the runner rules, a genuinely read-only / non-write-control primitive is a
valid stop; this is that case.) So: no uid=0 chain, by design of the bug, not
for want of trying. Honest impact: DoS (unmount panic) + memory leak.
PoC changes
The evidence pack was authored from scratch (no pre-existing PoC folder existed
for DF-0764). softdep_churn.c is a best-effort stress harness; build.sh /
run.sh set up a softdep FFS image and run it. See manifest.json for the
artifact list.
Fix (validated)
fix.diff eliminates the root cause by making the cached tail impossible to go
stale. Authoritative summary in recommended_fix; key points:
- Promote the
static struct worklist *worklist_tail(function-local inadd_to_worklist) to a file-scopesoftdep_worklist_tail, declared beforeworklist_removeso both helpers can maintain it. - In
worklist_remove, invalidate the cache when the removed item is the tail:if (item == softdep_worklist_tail) softdep_worklist_tail = NULL; - In
add_to_worklist, if the cache is NULL or points at a node no longer on the list ((wk_state & ONWORKLIST) == 0), rediscover the real tail by walking from the head before appending (O(n) only on the rare tail-removal path; O(1) on the common path). TheONWORKLISTcheck is belt-and-suspenders safety against any future removal path that forgets step 2.
Both helpers already require lk to be held, so the cache update + the rediscover
walk are atomic with respect to each other β no new race.
Build + boot validation
git apply --check findings/poc/DF-0764/fix.diffβ clean.- Applied to in-guest
/usr/src,make -j6 nativekernel KERNCONF=X86_64_GENERICβ rc=0 (full log infix_build.log, ~6 min). - Installed
/boot/kernel/kernel(stripped + debug), rebooted:kern.version = DragonFly 6.5-DEVELOPMENT #1 β¦ Thu Jul 9 13:42:21 UTC 2026(the#0β#1bump + today's timestamp confirm the patched kernel booted). - Re-ran the same 90 s Γ 16-worker harness on the patched kernel: clean run,
UMOUNT_RC=0, no panic, no regression (run_patched.log).
Caveat on fix_status
Because the baseline race never panicked in a bounded black-box run
(fix_baseline_reproduced=false), there is no runtime "before=panic / after=no-
panic" contrast. The fix is validated as: (a) applies + compiles clean, (b) boots
clean, (c) the same stress harness shows no regression, and (d) eliminates the
stale-tail class by construction (the cache is invalidated on tail removal and
re-discovered on use). For a narrow-race logic bug, this is the strongest
validation achievable without a deterministic trigger.
Kernel references (confirmed during verification)
sys/vfs/ufs/ffs_softdep.c:462βadd_to_worklistdefinitionsys/vfs/ufs/ffs_softdep.c:464βstatic struct worklist *worklist_tail;(the stale cache)sys/vfs/ufs/ffs_softdep.c:473βLIST_INSERT_AFTER(worklist_tail, wk, β¦)(the stale insert)sys/vfs/ufs/ffs_softdep.c:381βworklist_removedoesLIST_REMOVEwith no tail invalidationsys/vfs/ufs/ffs_softdep.c:617-627βLK_NOWAITscan that can skip head items and reach the tailsys/vfs/ufs/ffs_softdep.c:631-633βWORKLIST_REMOVEthenFREE_LOCK(lock gap)sys/vfs/ufs/ffs_softdep.c:2845,2868β handlerVFS_VGET(sleep) thenWORKITEM_FREE(free)sys/vfs/ufs/ffs_softdep.c:4733-4735βrequest_cleanupcallsprocess_worklist_item(NULL, LK_NOWAIT)sys/vfs/ufs/ffs_softdep.c:766βpanic("softdep_flushfiles: looping")sys/config/X86_64_GENERIC:23βoptions SOFTUPDATES
Fix verification
fixedVALIDATED by compile + boot + no-regression + construction. fix.diff applies clean to read-only sys/, applied to in-guest /usr/src, make -j6 nativekernel KERNCONF=X86_64_GENERIC rc=0 (fix_build.log), installed /boot/kernel/kernel (sha256 89b69eed...) + kernel.debug, rebooted to kern.version #1 Thu Jul 9 13:42:21 UTC 2026, and re-ran the identical 90s x 16-worker harness -> umount rc=0, no panic, no regression (run_patched.log). CAVEAT: because the baseline race is too narrow to black-box trigger in bounded time (fix_baseline_reproduced=false), there is no runtime before=panic/after=no-panic contrast; the validation rests on (a) clean compile, (b) clean boot, (c) no-regression stress run, and (d) the fix eliminates the stale-tail class by construction -- the cached tail is invalidated on tail removal and re-discovered on use, both under lk, so it can no longer point at a detached/freed node.
baseline #0 (unpatched): 90s x 16-worker churn -> churn complete -> TRYING_UMOUNT -> UMOUNT_RC=0 (race too narrow to fire; no panic, clean unmount). patched #1: identical harness -> churn complete -> TRYING_UMOUNT -> UMOUNT_RC=0 (no panic, no regression). fix build: `>>> Kernel build for X86_64_GENERIC completed on Thu Jul 9 13:46:14 UTC 2026` / `=== NK_DONE rc=0 ===`. boot: kern.version `#1: Thu Jul 9 13:42:21 UTC 2026` (the #0->#1 bump + today's timestamp confirm the patched kernel). Root cause eliminated: worklist_remove now does `if (item == softdep_worklist_tail) softdep_worklist_tail = NULL;` and add_to_worklist rediscover-walks when the cache is NULL -- the stale-tail insert at ffs_softdep.c:473 is no longer reachable.
Confirmed kernel references
- sys/vfs/ufs/ffs_softdep.c:462
- sys/vfs/ufs/ffs_softdep.c:464
- sys/vfs/ufs/ffs_softdep.c:473
- sys/vfs/ufs/ffs_softdep.c:381
- sys/vfs/ufs/ffs_softdep.c:617
- sys/vfs/ufs/ffs_softdep.c:631
- sys/vfs/ufs/ffs_softdep.c:633
- sys/vfs/ufs/ffs_softdep.c:2845
- sys/vfs/ufs/ffs_softdep.c:2868
- sys/vfs/ufs/ffs_softdep.c:4733
- sys/vfs/ufs/ffs_softdep.c:766
- sys/config/X86_64_GENERIC:23
Detail
Exploit chain
none -- this is NOT a write primitive and there is no escalation chain. The 'UAF write' is LIST_INSERT_AFTER(stale_tail, C) at ffs_softdep.c:473, which writes a pointer to the attacker-just-allocated C into the le_next field of a detached-but-still-allocated struct worklist that is about to be freed. The bytes written are a kernel pointer to C (a same-type worklist node), NOT attacker-controlled content into a victim object; there is no cross-type slab reuse, no refcount/uid/function-pointer corruption, no control-flow hijack. The only effects are an unreclaimable memory leak (orphan chain) and, eventually, the softdep_flushfiles looping panic at unmount. The valid hard blocker applies: the primitive does not yield attacker-controlled bytes into a victim object -- it is a list-management logic flaw whose ceiling is DoS (unmount panic) + memory leak. No uid0 chain derivable; impact is honestly dos+leak. No exploit.c/chain.c written because there is no write primitive to convert.
Evidence (decisive lines)
Code-level proof (the authoritative evidence for this race): ffs_softdep.c:464 `static struct worklist *worklist_tail;` (never invalidated on removal); ffs_softdep.c:473 `LIST_INSERT_AFTER(worklist_tail, wk, wk_list);` (stale insert); ffs_softdep.c:381 `LIST_REMOVE(item, wk_list);` with no tail invalidation; ffs_softdep.c:633 `FREE_LOCK(&lk);` (lock dropped before handler frees the item at :2868); ffs_softdep.c:4734 `process_worklist_item(NULL, LK_NOWAIT);` (the out-of-order-removal caller); ffs_softdep.c:766 `panic("softdep_flushfiles: looping");`. Runtime: unpatched #0 kernel 90s x 16-worker churn -> umount rc=0 (race did not fire in bounded time); patched #1 kernel identical run -> umount rc=0, no regression.
PoC changes
Authored the entire evidence pack from scratch (no pre-existing DF-0764 poc folder existed). Wrote softdep_churn.c (churn + vnode-lock-pressure harness), build.sh, run.sh (FFS+softdep image setup + churn + unmount), VERDICT.md (full mechanism trace), manifest.json, and fix.diff. Softdep_churn's first version had an alarm-propagation bug (parent's SIGALRM didn't reach forked children); rewrote v2 so the parent SIGTERM's children on its own alarm.
Verified recommended fix
Promote the function-local static struct worklist *worklist_tail in add_to_worklist to a file-scope softdep_worklist_tail (declared before worklist_remove), invalidate it in worklist_remove when item == softdep_worklist_tail (set to NULL), and in add_to_worklist rediscover the real tail by walking from LIST_FIRST when the cache is NULL or points at a node whose wk_state lacks ONWORKLIST. Both helpers already hold lk so the cache update+rediscover are atomic. This supersedes the finding markdown's 'walk-to-end or TAILQ_HEAD' proposal with a minimal, O(1)-on-the-common-path variant that preserves the LIST layout (no invasive type change). Full git-apply-able diff in findings/poc/DF-0764/fix.diff.
Verdict
REPRODUCED at the code level. The bug is unambiguously confirmed by line-by-line source trace: add_to_worklist() caches a function-static struct worklist *worklist_tail (ffs_softdep.c:464) and uses LIST_INSERT_AFTER(worklist_tail,...) at :473, but worklist_remove()/process_worklist_item() never invalidate that static tail when an item (including the tail) is removed via the LK_NOWAIT out-of-order scan at :617-627/:631. After WORKLIST_REMOVE+FREE_LOCK (:631-633) the handler sleeps in VFS_VGET (:2845) and later frees the item (:2868); a concurrent add_to_worklist() in that window inserts onto the detached tail, creating a phantom chain unreachable from LIST_FIRST. num_on_worklist is permanently inflated and softdep_flushfiles() eventually panics('softdep_flushfiles: looping') at :766. SOFTUPDATES is in X86_64_GENERIC and verified live. A black-box stress harness (90s x 16 workers on a softdep FFS image) did NOT trigger the runtime panic because the race is genuinely narrow (needs num_on_worklist>10000, all head D_DIRREM vnodes locked, tail selected, and a concurrent add in a few-ms FREE_LOCK..WORKITEM_FREE gap) -- consistent with the finding's own AC:H/Medium rating. num_on_worklist is not exposed via sysctl, so the orphan-chain growth cannot be observed from userspace; only the eventual unmount panic is externally visible.
No comments yet.