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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0764 Β· 15 files
FileTypeDescriptionSize
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
README.md readme build/run/expected + reproduction
↓ download 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 head D_DIRREM items with locked vnodes, tail selected by the LK_NOWAIT scan, and a concurrent add_to_worklist in the FREE_LOCKβ†’WORKITEM_FREE window), an orphan chain grows, num_on_worklist is inflated, and the final umount panics with panic("softdep_flushfiles: looping") at ffs_softdep.c:766. The race is narrow (CVSS AC:H); a single 90 s run usually does NOT trigger it.
  • Bug fixed (single-fix kernel): harness runs identically, umount succeeds 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.
VERDICT.md verdict full mechanism trace + escalation assessment + fix validation
↓ download raw

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).

  1. 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 at ffs_softdep.c:456 states "This routine requires that the lock be held."

  2. worklist_remove (the WORKLIST_REMOVE macro, line 373) removes an item but never touches the static worklist_tail: - LIST_REMOVE(item, wk_list); β€” ffs_softdep.c:381 - No invalidation of the cached tail. (Note: LIST_REMOVE does not clear the removed node's le_next; the static pointer silently becomes stale.)

  3. process_worklist_item (line 596) can remove an item out of order β€” including the tail β€” when called with LK_NOWAIT: - The LIST_FOREACH scan at ffs_softdep.c:617-627 skips any head item that is a D_DIRREM whose vnode is currently locked ((flags & LK_NOWAIT) == 0 || wk->wk_type != D_DIRREM breaks immediately for flags==0; for LK_NOWAIT it walks past locked D_DIRREM items). If every head item is a locked D_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

  4. 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)

  5. The LK_NOWAIT call site is request_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

  6. Race window. Between FREE_LOCK (ffs_softdep.c:633) and the handler's WORKITEM_FREE (ffs_softdep.c:2868), the lock is free. A concurrent thread doing a softdep operation (any unlink/rename/truncate that calls add_to_worklist β€” call sites at ffs_softdep.c:1845,1962,2010,2291,2496,2760, 2789,3517,3658,3671,3760) acquires lk and runs add_to_worklist(C): - LIST_FIRST(&softdep_workitem_pending) != NULL (other items still queued), so the code takes the LIST_INSERT_AFTER(worklist_tail, …) branch at ffs_softdep.c:473 β€” but worklist_tail now points at the detached node being freed by the handler. - LIST_INSERT_AFTER(stale_tail, C) writes C onto the stale tail's le_next. C is now reachable only through the about-to-be-freed node, i.e. on a phantom chain unreachable from LIST_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).

  7. Effect. C (and every item appended after it while the orphan chain persists) is permanently leaked; num_on_worklist is inflated by the size of the orphan chain. On unmount, softdep_flushfiles (line ~744) loops waiting for num_on_worklist to drain to zero; because the orphan items can never be reached by the head-walking process_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 enter request_cleanup),
  • every head D_DIRREM having a locked vnode at the instant of the LK_NOWAIT scan (so the scan walks to the tail),
  • the tail being selected+removed,
  • a concurrent add_to_worklist landing in the FREE_LOCKβ†’WORKITEM_FREE gap (a few milliseconds at most, dominated by VFS_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 a struct worklist, not a refcount/uid/function-pointer in a victim struct.
  • There is no cross-type reuse: the orphaned C is itself a struct worklist placed 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_flushfiles looping 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:

  1. Promote the static struct worklist *worklist_tail (function-local in add_to_worklist) to a file-scope softdep_worklist_tail, declared before worklist_remove so both helpers can maintain it.
  2. In worklist_remove, invalidate the cache when the removed item is the tail: if (item == softdep_worklist_tail) softdep_worklist_tail = NULL;
  3. 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). The ONWORKLIST check 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 in fix_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β†’#1 bump + 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)

Fix verification

fixed
baseline no→ patch + rebuild →patched clean

VALIDATED 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 9 13:42:21 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

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.