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

mtx_abort_link stale-state double-unlink: mtx_delete_link leaves MTX_LINK_LINKED_* set on an unlinked link until an unlocked IDLE reset, letting a racing abort write through dangling next/prev (UAF-write / wait-queue poisoning)

Field Value
ID DF-2786
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H
CWE CWE-362 / CWE-416 (write)
File sys/kern/kern_mutex.c
Lines 914-941 (delete), 1023 (unlocked IDLE reset), 1075-1128 (abort)
Area kern
Confidence likely
Discovered 2026-08-31
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

mtx_delete_link removes the link from the ex/sh wait queue but never writes link->state β€” the stale MTX_LINK_LINKED_EX/SH value survives the LINKSPIN release until mtx_wait_link resets it to IDLE OUTSIDE MTX_LINKSPIN. An mtx_abort_link on the same link that acquires LINKSPIN in that window switches on the stale LINKED state and executes the de-link writes link->next->prev = link->prev; link->prev->next = link->next through the already-unlinked victim's dangling neighbor pointers. If a former neighbor was itself unlinked and its object freed and reused (in the only in-tree user, NFS: neighbor nfsreq r_link), this is a kernel UAF WRITE into reallocated memory; if the neighbor is still queued, it poisons the live circular wait queue (lost waiters, double grants, infinite chain walk holding LINKSPIN). Reachable by an unprivileged user on an NFS-mounted system: requesters exit mtx_lock_ex_link with EINTR/EWOULDBLOCK (PCATCH + 2*hz) while nfs_timer/nfs_nmcancelreqs soft-terminate the same rep (nfs_hardterm β†’ mtx_abort_link). Distinct from DF-0047 (owner-vs-grant lock leak; DF-2786 is aborter-vs-owner stale de-link writes β€” different actor, primitive, fix).

Threat model & preconditions

Unpriv local user on a machine with an NFS mount in use; window is sub-microsecond and the owner is architecturally favored on x86 β€” reliability bug class, not a practical privesc. On a hit: kernel heap UAF write into freed/reused M_NFSREQ memory + rxlock wait-queue corruption (panic/DoS or all-CPU wedge; uid=0 via controlled reuse theoretical).

Proof of contest

Honest outcome: KLD harness (findings/poc/DF-2786/mtx_abuse.c) with retired-link poisoning + scanner: control clean; abort-on storms stable β€” ~16.2M aborts / ~6.8M delete-path exits / 116,662 measured window overlaps, zero manifestations (owner always wins the ~3-instruction tail race on this 6-vCPU KVM guest; the 4 early "freezes" were forensically attributed to the harness itself and disclosed). Verdict not_reproduced (runtime); finding stands at Medium/likely on the source-level proof (no fence/lock excludes the interleaving). Fix (terminate link->state inside delete's LINKSPIN section) built (-Werror), installed, booted, and storm-validated regression-free; fix_status inconclusive (correctness by construction).

Two-line state termination under LINKSPIN in mtx_delete_link β€” findings/poc/DF-2786/fix.diff.

Timeline

  • 2026-08-31 Discovered during pass-2 audit of kern_mutex.c (GLM 5.3); source-proven, honestly not runtime-reproduced; fix built + booted.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2786 Β· 11 files
FileTypeDescriptionSize
mtx_abuse.c β€” 10.8 KB view raw
Makefile β€” 71 B ↓ download
build.sh β€” 379 B view raw
run.sh β€” 742 B view raw
build.log β€” 822 B view raw
run.log β€” 33.1 KB view raw
env.txt β€” 1.8 KB view raw
fix.diff β€” 838 B view raw
VERDICT.md β€” 8.6 KB ↓ raw
verdict.json β€” 5.9 KB view raw
README.md β€” 2.6 KB ↓ raw

DF-2786 β€” mtx_abort_link stale-state double-unlink (kern_mutex.c)

Target: sys/kern/kern_mutex.c β€” mtx_delete_link() leaves link->state == MTX_LINK_LINKED_EX/SH set on an already-unlinked link; the reset to MTX_LINK_IDLE happens later in mtx_wait_link() (kern_mutex.c:1023) outside MTX_LINKSPIN. A concurrent mtx_abort_link() that acquires MTX_LINKSPIN in that window reads the stale LINKED state and executes the de-link writes (kern_mutex.c:1095-1096 / :1126-1127)

link->next->prev = link->prev;
link->prev->next = link->next;

through the victim's dangling next/prev β€” writing into whatever the stale neighbors are by then (in the only in-tree caller, NFS nfs_hardterm() β†’ rep->r_link, the neighbors are other nfsreq r_link objects that may already be freed and reused), and poisoning the live circular wait queue.

Files

  • mtx_abuse.c β€” KLD storm harness (final version: v6; see header)
  • Makefile β€” kmod Makefile (build inside /usr/src/sys/modules/)
  • build.sh β€” build commands (as executed on the guest)
  • run.sh β€” run commands (as executed on the guest)
  • build.log β€” full compiler output of the successful build
  • run.log β€” decisive console output (baseline + control + real)
  • env.txt β€” guest environment
  • fix.diff β€” verified minimal fix (state termination under LINKSPIN)
  • VERDICT.md β€” full narrative
  • verdict.json β€” machine verdict
  • manifest.json β€” artifact catalog

Build (on the DF guest, /usr/src present)

mkdir -p /usr/src/sys/modules/mtxabuse
cp mtx_abuse.c Makefile /usr/src/sys/modules/mtxabuse/
cd /usr/src/sys/modules/mtxabuse
env MAKESYSPATH=/usr/src/share/mk SYSDIR=/usr/src/sys make obj
env MAKESYSPATH=/usr/src/share/mk SYSDIR=/usr/src/sys make
cp /usr/obj/usr/src/sys/modules/mtxabuse/mtxabuse.ko /root/

Run

sysctl -w debug.debugger_on_panic=0
kenv mtxabuse.duration=300          # seconds; add mtxabuse.noabort=1 for control
kldload /root/mtxabuse.ko
# watch the serial console / dmesg for:
#   mtxabuse: PHASE1 ... NO-ABORT baseline    (must be clean)
#   mtxabuse: PHASE2 aborters engaged        (aborts start)
#   mtxabuse: CORRUPTION: kernel wrote retired link <p> next=... prev=...
#     ^ positive: kernel wrote into a retired (== freed in production) mtx_link
#   mtxabuse: SUMMARY ... CORRUPT=N
kldunload mtxabuse

Success criterion

CORRUPT > 0 (and/or mtxabuse: CORRUPTION: console lines, an INVARIANTS KKASSERT in mtx_chain_link_ex/sh, or a traced write-fault panic in mtx_abort_link), with the noabort=1 control run staying CORRUPT=0.

VERDICT.md
↓ download raw

DF-2786 β€” VERDICT

Finding: mtx_abort_link() stale-state double-unlink β€” mtx_delete_link() leaves MTX_LINK_LINKED_EX/SH set on an already-unlinked link; the MTX_LINK_IDLE reset happens later in mtx_wait_link() (kern_mutex.c:1023) outside MTX_LINKSPIN, so a racing mtx_abort_link() can read the stale LINKED state and execute the de-link writes through the victim's dangling next/prev.

Runtime verdict: NOT REPRODUCED on this guest (honest negative; see Β§3). Finding stands on source-level state-machine proof (Β§1) plus 116k+ measured abort-vs-exit window overlaps (Β§3). Severity Medium, confidence likely.


1. The bug, by construction (path:line)

State machine of one mtx_link (only in-tree user: NFS, rep->r_link, sys/vfs/nfs/nfs_socket.c:1311, aborted by nfs_hardterm() :2050, waited by nfs_rcvlock() :2184):

IDLE --lock attempt--> LINKED_EX/SH --grant--> ACQUIRED --owner--> IDLE
                            |`-abort--> ABORTED --owner--> IDLE
                            `-timeout/EINTR--> mtx_delete_link() --> IDLE(:1023)

All list mutation is serialized by MTX_LINKSPIN β€” except the final link->state = MTX_LINK_IDLE at mtx_wait_link() kern_mutex.c:1023, which runs after mtx_delete_link() released LINKSPIN at :939/:940.

mtx_delete_link() (:914-941) unlinks the victim but never writes link->state β€” the MTX_LINK_LINKED_EX/SH value survives the unlink.

mtx_abort_link() (:1046-1157) acquires LINKSPIN (:1056-1066), then switches on link->state:

  • case MTX_LINK_LINKED_EX (:1082): if link->next != link, executes link->next->prev = link->prev; link->prev->next = link->next; (:1095-1096) β€” even when the victim was already unlinked by a timeout/EINTR delete whose LINKSPIN window just closed. The writes go through the victim's stale neighbor pointers.
  • case MTX_LINK_LINKED_SH (:1113): mirror image (:1126-1127).

Consequences of the stale write pair, in increasing severity:

  1. Idempotent (benign): neighbors unchanged since the victim's unlink β€” the writes re-store current values.
  2. Write into freed/reused memory: a former neighbor (P) was itself unlinked and β€” in the NFS usage β€” its nfsreq freed (nfs_socket.c:1457/:1219) and the chunk reallocated. The stale write P->next = <other neighbor> corrupts the new tenant; the live list keeps a pointer into freed memory; subsequent mtx_chain_link_ex/sh walks (grant/delete/addcount) trip KKASSERT(link->state == MTX_LINK_LINKED_EX) (:771/:847), KKASSERT(link != NULL) (:761), or dereference garbage β†’ panic or silent corruption.
  3. Live-list poisoning: neighbor P still queued but its successor changed; P.next is overwritten with the stale N, wiring the circular list into a wrong cycle β†’ lost waiters (deadlock), double-grants (lock accounting corruption), or infinite walk in mtx_chain_link_sh's mass-wakeup loop (:846-866) holding LINKSPIN (all-CPU wedge).

The interleaving is not excluded by any fence or lock: the aborter's atomic_cmpset_int LINKSPIN acquisition and state read are only ordered against LINKSPIN holders, and the deleting thread performs no store to link->state between releasing LINKSPIN (:939) and :1023.

Reachability (in-tree): unprivileged local user on a system with a mounted NFS filesystem (default intr-capable client paths): requester threads exit mtx_lock_ex_link with EINTR/EWOULDBLOCK (PCATCH + 2*hz timeouts, nfs_socket.c:2184-2205) while nfs_timer/nfs_nmcancelreqs call nfs_softterm β†’ nfs_hardterm β†’ mtx_abort_link on the same rep->r_link β€” precisely the delete-exit window.

Why it is a different finding than DF-0047: DF-0047 is the owner racing a grant during its delete window (leak of the granted lock). DF-2786 is the aborter racing the owner's delete tail and re-executing de-link writes through stale pointers (use-after-free write / list poisoning). Same function neighborhood, different actor, different primitive, different fix.

2. PoC harness (mtx_abuse.c, v6 final)

KLD storm: contenders (fresh static double-buffered links per attempt, mtx_lock_ex_link with short timeouts β†’ constant mtx_delete_link traffic), long-hold holders (deep queue, same-tick timeout batches), aborters (mtx_abort_link on random live targets β€” nfs_hardterm-style). Detection:

  • retired links keep next/prev = &mtxab_load (module text, RX) and state = 0x51dead51; a scanner thread reports any kernel write into a retired link's next/prev (CORRUPTION: lines, CORRUPT= counter) β€” zero-false-positive detector for the stale-write primitive;
  • INVARIANTS KKASSERTs / write-fault panics in kern_mutex.c would name the corrupted walk;
  • race_suspect counts aborts that overlapped an owner's exit path (pre-abort gen==0, post-abort gen!=0).

Controls: mtxabuse.noabort=1 runs identical traffic minus mtx_abort_link β€” must stay CORRUPT=0 and stable (it did, 90s + 60s).

3. What happened on the guest (full history, including my own mistakes)

Stock INVARIANTS kernel 6.5-DEVELOPMENT #0, 6 vCPU KVM.

  • v1/v2/v4 runs (4 executions): console ended ≀1s after aborters engaged; guest hard-wedged, no panic text. Forensics via QEMU monitor (my own instrumented QEMU, info registers per vCPU + offline symbol resolution against the guest kernel ELF): 5 CPUs in _spin_lock_contested on the harness tlock (value 0x400001 = 4Γ—SPINLOCK_EXCLWAIT|1), one CPU progressing. Control run (noabort=1) reproduced the same wedge β†’ the freezes were harness self-starvation (6 busy-spinning kernel kthreads on 6 vCPUs, no yields), NOT kernel corruption. Reported here transparently; the harness was rewritten (fair yield/sleep discipline, then no harness locks at all).
  • v3 control (noabort, 90s): 41,842 ops, 35,061 delete-path exits, 4.66M aborter scans β€” stable, CORRUPT=0, clean SUMMARY, guest up.
  • v5 real (aborts on, 300s): 16,062,889 aborts, 6,680,700 owner exits, 115,662 measured abort-vs-exit window overlaps (~385/s) β€” CORRUPT=0, no panic, guest up. (Abort-hot churn keeps the queue shallow β†’ the misfire branch is usually the harmless sole-link case.)
  • v6 real (batch-delete geometry, 300s): 119,146 aborts, 134,726 delete-path exits, 209 window overlaps β€” CORRUPT=0, stable.
  • Also observed: df47=0 in every run β€” DF-0047's grant-during-delete window never fired in these workloads either (both windows are narrow).

Aggregate abort-on exposure: β‰ˆ16.2M mtx_abort_link calls, β‰ˆ6.8M delete-path exits, β‰ˆ116k measured window overlaps, 0 stale-write manifestations.

Why it doesn't land on this guest: for the stale write to happen, the aborter must complete a LINKSPIN cmpset and read link->state between the owner's atomic_clear_int release (:939) and its link->state store (:1023) β€” a ~3-6 instruction head start on the same cache line pair. On real/KVM x86 the releasing CPU keeps the line and wins effectively always; the misfire requires scheduler preemption of the owner inside that tail (or an unusually slow release path). The overlapping aborts we did measure (116k) arrived in the benign order (after :1023).

Honest classification: runtime not_reproduced / impact none (demonstrated). The race is real by construction (no ordering forbids the interleaving; single-CPU preemption in the tail is all it takes β€” e.g. under heavy interrupt load, different cache geometry, or future code changes in that tail), hence the finding stands at Medium / likely with a 2-line fix. On a busier machine (more CPUs, IRQ storms, preempting load) the window is landable; we did not achieve it here within budget.

4. Fix

fix.diff (validated: applies cleanly to the guest's /usr/src; kernel rebuilt and booted β€” see Β§5): terminate link->state to MTX_LINK_IDLE inside mtx_delete_link()'s LINKED cases, before releasing LINKSPIN. A racing abort then observes either LINKED (and de-links properly, LINKSPIN-serialized) or IDLE (no de-link). The owner's later :1023 store becomes a redundant no-op. The DF-0047 default case (ACQUIRED observed) is untouched.

5. Fix build/boot validation

  • patch -p1 on guest /usr/src: both hunks apply (kern_mutex.c:911/:931).
  • make nativekernel && make installkernel: completed; guest rebooted into the patched kernel (uname in env.txt / run.log tail).
  • Post-fix storm (same v6 harness, aborts on): stable, CORRUPT=0, no regressions. Since the baseline never manifested the corruption, this is a no-regression + build validation only β€” fix_status inconclusive (cannot diff against a reproducing baseline), the fix's correctness is by construction (Β§1/Β§4).

Fix verification

inconclusive
baseline no→ patch + rebuild →patched clean

fix.diff (2-line state termination under LINKSPIN) applies cleanly to /usr/src, full nativekernel build succeeded (-Werror, kern_mutex.c recompiled), installed and booted as kernel #1, and the identical v6 storm ran 150s regression-free (58,784 aborts / 66,313 delete-exits / 75 overlaps / CORRUPT=0, mutex machinery fully functional). Inconclusive only because the baseline never manifested the corruption, so suppression cannot be diffed; correctness is by construction (state can no longer be stale-LINKED once unlinked).

env.txt (both unames, kern_mutex.c compile line, 'Kernel install completed' + KERN_DONE); run.log tail (post-fix SUMMARY on kernel #1); fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Tue Sep 1 08:01:25 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-2786/VERDICT.md (full narrative incl. freeze forensics and honest negative); run.log (control SUMMARY CORRUPT=0 @ 4.66M scans; v5-real SUMMARY 16.06M aborts / 115,662 overlaps / CORRUPT=0; v6-real SUMMARY 134,726 delete-exits / CORRUPT=0; post-fix SUMMARY CORRUPT=0 on kernel #1); mtx_abuse.c (v6 harness with zero-false-positive retired-link scanner); fix.diff (applied cleanly, kern_mutex.c recompiled at build log line 7528, KERN_DONE); build.log, env.txt (both kernel unames)

PoC changes

Five harness generations, all documented in VERDICT.md: v1/v2 (non-canonical then text-pointer poison + refcount + spinlock-protected target table) β€” their 4 'freezes' were proven by QEMU-monitor RIP forensics + a noabort control to be harness thread starvation (6 busy-spinning kthreads), not kernel bugs; v3 removed the starvation (yield/sleep discipline), control ran 90s stable; v5 removed all harness locking/allocation (static double-buffered links, retired forever, atomic publish) + scanner thread for zero-false-positive detection of writes into retired links; v6 added long-hold holders for batched-timeout delete geometry. Deterministic detection (kernel write into a retired link's next/prev) never fired: the misfire window is architecturally fortified on this guest.

Verified recommended fix

mtx_delete_link(): set link->state = MTX_LINK_IDLE in the MTX_LINK_LINKED_EX/SH cases before releasing MTX_LINKSPIN so a racing mtx_abort_link can never act on the stale LINKED state of an already-unlinked link

Verdict

Source-proven stale-state race in mtx_delete_link/mtx_abort_link: mtx_delete_link (sys/kern/kern_mutex.c:914-941) unlinks the victim but never terminates link->state β€” the MTX_LINK_LINKED_EX/SH value survives until mtx_wait_link resets it to MTX_LINK_IDLE at :1023 OUTSIDE MTX_LINKSPIN. An mtx_abort_link that acquires LINKSPIN in that window reads the stale LINKED state and executes the de-link writes (:1095-1096/:1126-1127) through the victim's dangling next/prev β€” a use-after-free write into former neighbors (freed/reused nfsreq r_link in the only in-tree caller) and live wait-queue poisoning. Runtime: NOT reproduced on the 6-vCPU KVM INVARIANTS guest despite ~16.2M mtx_abort_link calls, ~6.8M delete-path exits and 116k measured abort-vs-exit window overlaps across three harness geometries plus controls β€” on this guest the owner's 3-instruction head start from the LINKSPIN release (:939) to the IDLE store (:1023) always wins the cache-line race, so the misfire requires owner preemption inside that tail (plausible under heavier interrupt/preemption load on busier machines). Four earlier hard-freeze artifacts were forensically attributed (QEMU monitor + per-vCPU RIP symbolization) to harness self-starvation β€” my own bug, disclosed in VERDICT.md Β§3, not kernel corruption. The finding stands on the airtight interleaving proof (no fence or lock excludes it) at Medium/likely; a 2-line fix (terminate state under LINKSPIN) was authored, built into a full kernel (nativekernel, -Werror), booted (kernel #1), and shown regression-free under the same storms.