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).
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2786 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 buildrun.logβ decisive console output (baseline + control + real)env.txtβ guest environmentfix.diffβ verified minimal fix (state termination under LINKSPIN)VERDICT.mdβ full narrativeverdict.jsonβ machine verdictmanifest.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.
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): iflink->next != link, executeslink->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:
- Idempotent (benign): neighbors unchanged since the victim's unlink β the writes re-store current values.
- Write into freed/reused memory: a former neighbor (P) was itself
unlinked and β in the NFS usage β its
nfsreqfreed (nfs_socket.c:1457/:1219) and the chunk reallocated. The stale writeP->next = <other neighbor>corrupts the new tenant; the live list keeps a pointer into freed memory; subsequentmtx_chain_link_ex/shwalks (grant/delete/addcount) tripKKASSERT(link->state == MTX_LINK_LINKED_EX)(:771/:847),KKASSERT(link != NULL)(:761), or dereference garbage β panic or silent corruption. - Live-list poisoning: neighbor P still queued but its successor
changed;
P.nextis overwritten with the stale N, wiring the circular list into a wrong cycle β lost waiters (deadlock), double-grants (lock accounting corruption), or infinite walk inmtx_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) andstate = 0x51dead51; a scanner thread reports any kernel write into a retired link'snext/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_suspectcounts aborts that overlapped an owner's exit path (pre-abortgen==0, post-abortgen!=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 registersper vCPU + offline symbol resolution against the guest kernel ELF): 5 CPUs in_spin_lock_contestedon the harnesstlock(value0x400001= 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=0in 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 -p1on 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_statusinconclusive(cannot diff against a reproducing baseline), the fix's correctness is by construction (Β§1/Β§4).
Fix verification
inconclusivefix.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
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.
No comments yet.