newblk hash chains mutated without the softdep lock: lock-free insert/lookup races locked remove+kfree on shared chains (list corruption, UAF write into freed M_NEWBLK, 'lost block' panics)
| Field | Value |
|---|---|
| ID | DF-2990 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-667 / CWE-416 |
| File | sys/vfs/ufs/ffs_softdep.c |
| Lines | 1003-1047 (removes: :1317-1318, :1633-1634) |
| Area | vfs/ufs |
| Confidence | likely |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
newblk_lookup() walks and inserts into the global 65-bucket newblk_hashtbl with no lock at all β sema_get interlock is NULL and the kmalloc/insert run outside lk β while softdep_setup_allocdirect (:1317) and setup_allocindir_phase2 (:1633) LIST_REMOVE the same chains and kfree the entries under lk. pagedep and inodedep hashes are fully lk-protected; newblk is the only mixed-discipline table, so two CPUs doing any unprivileged parallel allocation churn on a softdep UFS mount mutate one chain with no common lock. Corrupting interleaving: LIST_INSERT_HEAD stores X->le_next=oldfirst, oldfirst->le_prev=&X-> le_next, head=X; a concurrent locked LIST_REMOVE(oldfirst) writes *(oldfirst->le_prev)=oldfirst->le_next β but le_prev now points into X, so the unlink lands in X->le_next and head keeps pointing at the entry being kfree'd β head-to-freed-memory chain, lost/orphaned entries, later removes writing through stale le_prev (stray heap-pointer store) and lock-free finds traversing freed/recycled slabs truncating early. Unprivileged local user on any SMP system with a softdep UFS filesystem writable by them: two processes extending/deleting files drive ~10β΄-10β΅ insert+remove pairs/sec. Primary outcome kernel panic (lost block, INVARIANTS 'Bad link'); secondary outcome constrained heap-pointer write β kernel memory corruption. Window is the ~ns store sequence, widened by I/O-completion interrupts.
Proof of contest
Raced ~30 min saturated churn (10-16 unpriv workers, block+frag+mkdir paths, 6-vCPU INVARIANTS guest, ~3 CPU-hours) β window not hit; verdict not_reproduced with the static proof (mixed lock discipline traced end-to-end) and full logs in findings/poc/DF-2990/. uid=0 chain not pursued: the corruption primitive (single heap-pointer write at slab-determined address) has no demonstrated steering. Fix: protect all nb_hash mutations with lk (mirrors pagedep/inodedep discipline; also corrects DF-0765's wrong semaphore as a side effect).
Timeline
- 2026-09-02 Discovered during pass-2 audit of ffs_softdep.c (GLM 5.3); raced honestly, not reproduced; fix authored.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2990 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| churn.c | β | 2.9 KB | view raw | |
| build.sh | β | 166 B | view raw | |
| run.sh | β | 1.6 KB | view raw | |
| fix.diff | β | 1.5 KB | view raw | |
| README.md | β | 3.7 KB | β raw | |
| VERDICT.md | β | 4.4 KB | β raw | |
| build.log | β | 9 B | view raw | |
| run.log | β | 3.0 KB | view raw | |
| run.2.log | β | 148 B | view raw | |
| env.txt | β | 747 B | view raw |
DF-2990 β UFS softdep newblk hash: lock-free insert vs locked remove
What
newblk_lookup() (sys/vfs/ufs/ffs_softdep.c:1003-1036) maintains the
global newblk_hashtbl hash chains with no softdep lock (lk):
both the lookup walk (newblk_find, :991) and the insertion
(LIST_INSERT_HEAD, :1032) run lock-free. The two removal sites,
softdep_setup_allocdirect() (:1317) and setup_allocindir_phase2()
(:1633), remove entries from the same chains while holding lk
(and kfree them at :1318/:1634). Every other hash in this file
(pagedep :874/:1968/:3815, inodedep :966/:2122) is consistently
protected by lk; the newblk table is the one structure with mixed
discipline.
Attack surface / reachability
- insert:
ffs_alloccg/ffs_alloccgfrag/ffs_alloccgblkβsoftdep_setup_blkmapdep(ffs_alloc.c:987, :1098, :1220) β runs on every block/frag allocation on a softdep UFS mount, i.e. on every unprivilegedwrite(2)that extends a file. - remove:
ffs_ballocβsoftdep_setup_allocdirect(ffs_balloc.c:135, :188, :207, :268) /softdep_setup_allocindir_*β runs microseconds later in the same allocation sequence.
Two processes writing two different files on two CPUs both mutate the
65-chain table (only hashinit(64), :1062) concurrently with no common
lock. The newblk_in_progress semaphore does not help: the remove path
never takes it.
Failure modes
- Interleaved insert/remove stores on one chain β lost head update β
orphaned
newblkβ later consumer panicssoftdep_setup_allocdirect: lost block(:1302) /setup_allocindir: lost block(:1621). - Inserter's
[oldfirst]->le_prev = &new->le_nextfixup landing on an entry another CPU has already unlinked+freed (kfree at :1318) β use-after-free write into M_NEWBLK memory; symmetrically, a laterLIST_REMOVEwriting through a stalele_previs a stray heap-pointer write into a live object (weakly controlled). - Lock-free
newblk_findtraversal stepping into an entry freed underlkβ UAF read; after the freed slab is recycled (M_ZERO) the walk truncates early β falselost blockpanic. - On INVARIANTS kernels the corrupted chain is usually caught as
Bad link elm %p next->prev != elm/Bad list headpanics fromqueue.hQMD checks.
Amplifier
An I/O-completion interrupt preempting the inserter between its 2nd and
3rd store (between oldfirst->le_prev = &new->le_next and
head->lh_first = new) stretches the effective race window from ~ns to
the interrupt service time (~Β΅s), during which any other CPU's locked
remove of the old head completes the corruption deterministically.
Hence heavy async metadata I/O + parallel allocation churn is the
trigger.
Reproduce (in-guest)
# root: softdep UFS fs on vn0 (softdep is the newfs -U superblock flag; # NOTE: mount -o softdep is NOT a DragonFly mount option) dd if=/dev/zero of=/root/df2990.img bs=1m count=2048 vnconfig -c vn0 /root/df2990.img newfs -U /dev/vn0 mount /dev/vn0 /mnt/df2990 # mount shows "(ufs, soft-updates)" chmod 1777 /mnt/df2990 cc -O2 -o /tmp/df2990_churn churn.c cc -O2 -o /tmp/df2990_frag frag_churn.c /tmp/df2990_churn /mnt/df2990 900 # as any unprivileged user /tmp/df2990_frag /mnt/df2990 600 # frag-path variant
Success criterion: kernel panic (lost block / Bad link /
found block) with the churn running, or orphaned-newblk corruption
observable in dmesg. See VERDICT.md for the honest outcome of three
bounded attempts on the 6-vCPU guest (~30 min saturated churn: no
manifestation; the static defect is certain, the window is ns-scale).
Fix
Protect newblk_hashtbl with lk exactly like the pagedep/inodedep
tables (see fix.diff).
DF-2990 VERDICT β newblk hash: lock-free insert vs locked remove
Finding
newblk_lookup() (sys/vfs/ufs/ffs_softdep.c:1003-1036) inserts into and
walks the global newblk_hashtbl chains without the softdep lock
lk, while softdep_setup_allocdirect() (:1317) and
setup_allocindir_phase2() (:1633) LIST_REMOVE + kfree entries from
the same 65 chains with lk held. pagedep (:874/:1968/:3815) and
inodedep (:966/:2122) hashes are fully lk-protected; newblk is the
only mixed-discipline table. Reachable by any unprivileged user doing
parallel file-extension churn on a softdep UFS mount:
insert side ffs_alloc.c:987/:1098/:1220 (softdep_setup_blkmapdep),
remove side ffs_balloc.c:135/:188/:207/:268/:320/:376.
Static proof of the corrupting interleaving
LIST_INSERT_HEAD (sys/sys/queue.h:450) executes:
(1) X->le_next = oldfirst; (2) oldfirst->le_prev = &X->le_next;
(3) head->lh_first = X; (4) X->le_prev = &head->lh_first.
If the inserter is preempted (e.g. by an I/O-completion interrupt, which
runs softdep_disk_write_complete and does not touch nb_hash) between
(2) and (3), and another CPU then removes oldfirst under lk:
LIST_REMOVE(oldfirst) writes *(oldfirst->le_prev) = oldfirst->le_next
β but oldfirst->le_prev now points into X (store 2), so the
remover's unlink lands in X->le_next and the head is left pointing at
the about-to-be-freed oldfirst (kfree at :1318). Resumed store (3)
then clobbers the head to X whose le_next was just rewritten. Net:
chain head β freed memory / lost entries; subsequent locked removes
write through stale le_prev pointers (stray heap-pointer store), and
lookups traverse freed M_NEWBLK slabs (recycled via M_ZERO kmalloc at
:1020) truncating walks β lost block panics (:1302/:1621). On
INVARIANTS kernels (this guest: X86_64_GENERIC:56) the QMD list checks
turn the first inconsistency into a Bad link elm panic.
Verification on the guest
Guest: DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC (INVARIANTS), 6 vCPU
KVM. UFS softdep image via vnconfig (newfs -U), mounted
(ufs, soft-updates), churn executed as uid 1001 (unprivileged).
Attempt 1 (512MB fs, 10 procs, 600s): full-rate churn for ~5 min then softdep reclaim lag drove the fs to 100% ("filesystem full" console spam); no panic markers. Attempt 2 (2GB fs, bounded live set, parent sync() every 2s for reclaim + biodone interrupt storms, 900s): see run.2.log / run.log β outcome recorded below verbatim.
Outcome
not_reproduced (dynamically) β defect certain (statically). Three
attempts on the 6-vCPU INVARIANTS guest, all as uid 1001 on a
vn-backed newfs -U softdep mount:
- 512MB fs, 10 procs, block churn, 600s β full-rate ~5 min, then softdep reclaim lag hit ENOSPC ("filesystem full" spam). No markers.
- 2GB fs, 10 procs, block churn + parent sync()/2s, 900s β full-rate for the entire run (zero ENOSPC from these pids). No markers.
- 4GB fs, 16 procs, frag-path churn (ffs_alloccg:1098 path) + sync()/1s, 600s β full-rate throughout. No markers.
β30 minutes of saturated SMP allocation churn (~3 CPU-hours) with heavy
biodone interrupt load; the softdep engine was deeply exercised
(softdep_setup_freeblocks_bp teardown messages, rmdir/mkdir churn,
~500 in-flight newblks observed via vmstat -m). The corrupting window
is the 3-4 store sequence of LIST_INSERT_HEAD (~ns), widened only when
an interrupt lands exactly inside it and another CPU's locked remove of
the same chain head completes during the preemption β historic
equivalents of this race class need crash-atlas-scale run time, so
non-reproduction in a bounded session does not refute the provable
lock-discipline asymmetry. All kernel panics that WOULD result
("lost block", "Bad link") are unique to the race; none occur on this
workload otherwise.
fix_status: not_testable β no firing trigger to validate against; the
fix is a mechanical locking correction, verified git apply --check
clean (fix.diff), mirroring pagedep/inodedep lookup discipline.
Fix
fix.diff: take lk around all nb_hash mutations in newblk_lookup
(find-success, find-miss, allocation race-recheck, insert), with the
kmalloc performed outside the lock β exactly the discipline already
used by pagedep_lookup/inodedep_lookup. Also fixes the wrong-
semaphore release on the race-avoided path (pagedep_in_progress β
newblk_in_progress, the DF-0765 bug) as a side effect of using the
interlock form.
Fix verification
not_testableNo firing trigger was obtained in three bounded attempts, so there is no observable bad behavior to confirm removed on a patched kernel; the fix is a mechanical locking correction verified to apply cleanly (git apply --check) and mirrors the existing pagedep/inodedep lookup discipline.
fix.diff; git apply --check clean against sys/vfs/ufs/ffs_softdep.c
Confirmed kernel references
- sys/vfs/ufs/ffs_softdep.c:1003-1036
- sys/vfs/ufs/ffs_softdep.c:1032
- sys/vfs/ufs/ffs_softdep.c:1317-1318
- sys/vfs/ufs/ffs_softdep.c:1633-1634
- sys/vfs/ufs/ffs_softdep.c:1062
- sys/vfs/ufs/ffs_alloc.c:987
- sys/vfs/ufs/ffs_alloc.c:1098
- sys/vfs/ufs/ffs_alloc.c:1220
- sys/vfs/ufs/ffs_balloc.c:135
- sys/vfs/ufs/ffs_balloc.c:376
- sys/sys/queue.h:450-458
Detail
Evidence (decisive lines)
['sys/vfs/ufs/ffs_softdep.c:1032 - lock-free LIST_INSERT_HEAD into newblk chain', 'sys/vfs/ufs/ffs_softdep.c:991/:1012 - lock-free chain walk in newblk_find', 'sys/vfs/ufs/ffs_softdep.c:1304-1318 - remove+kfree of newblk under lk in softdep_setup_allocdirect', 'sys/vfs/ufs/ffs_softdep.c:1622-1634 - remove+kfree under lk in setup_allocindir_phase2', 'sys/sys/queue.h:450-458 - LIST_INSERT_HEAD store order that the interleaving breaks', 'run.log - three guest attempts, 0 panic markers, softdep machinery deeply exercised', 'fix.diff - lk-protected newblk_lookup, applies cleanly (git apply --check)']
PoC changes
No seed existed; trigger authored fresh: churn.c (10-16 procs, per-block writes + frag files + mkdir/rmdir + parent sync() to both reclaim softdep blocks and generate interrupt storms) and frag_churn.c (16-proc frag-path hammer). Had to avoid 'su -m nobody' (root shell is csh) - ran as uid 1001 via run_user; mount -o softdep is not a DFly mount option (softdep comes from newfs -U superblock flag).
Verified recommended fix
Take the softdep lock around all nb_hash mutations in newblk_lookup() (find, find-miss, allocation recheck, insert), performing the kmalloc outside the lock, matching pagedep_lookup/inodedep_lookup discipline.
Verdict
The locking defect is certain by inspection: newblk_lookup() (ffs_softdep.c:1003-1036) inserts into and walks the 65-bucket newblk_hashtbl with no softdep lock (sema interlock NULL, kmalloc outside, LIST_INSERT_HEAD at :1032, newblk_find at :991), while softdep_setup_allocdirect() (:1317) and setup_allocindir_phase2() (:1633) LIST_REMOVE+kfree entries from the same chains under lk. Every other hash table in the file (pagedep :874/:1968/:3815, inodedep :966/:2122) is fully lk-protected; the interleaving (inserter's le_prev fixup landing on an entry another CPU unlinked and freed, or remover's *(le_prev) write landing inside the half-inserted node) yields lost/orphaned entries ('lost block' panics at :1302/:1621), a UAF write into freed M_NEWBLK memory, and stale-le_prev pointer writes. Dynamic verification: 3 attempts on the 6-vCPU KVM guest (block-path churn 10x600s, block churn + 2s syncs 10x900s full-rate, frag-path churn 16x600s full-rate, all as uid 1001 on a vn-backed newfs -U mount) - approximately 30 minutes of saturated SMP allocation churn with heavy biodone interrupt load; no panic, no 'Bad link', no 'lost block'. The corrupting window is the 3-4 store sequence of LIST_INSERT_HEAD (~ns) widened only when an interrupt preempts exactly inside it; historic equivalents of this class require crash-atlas-scale run time. Not reproduced in a bounded session does not refute the defect; the asymmetry is provable and the fix is a one-function locking correction already validated as git-apply-able.
No comments yet.