mtx_wait_link lock-leak race: chain can grant lock during mtx_delete_link window, caller returns error despite holding the lock (permanent deadlock)
| Field | Value |
|---|---|
| ID | DF-0047 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-362 Race Condition; CWE-667 Improper Locking |
| File | sys/kern/kern_mutex.c |
| Lines | 1002-1028 (mtx_wait_link), 925-941 (mtx_delete_link default) |
| Area | kern |
| Confidence | likely |
| Discovered | 2026-06-29 |
| Reported | pending |
Summary
When mtx_wait_link's tsleep() returns a non-zero error (EINTR from a
PCATCH signal, or EWOULDBLOCK from a timeout) and link->state is still
MTX_LINK_LINKED_* at the unlocked switch read (:1002), the code calls
mtx_delete_link (:1012). Between that read and mtx_delete_link's
acquisition of MTX_LINKSPIN, a concurrent mtx_chain_link_ex/sh (triggered
by another CPU's _mtx_unlock) can grant the lock to this exact link: it
removes the link, sets mtx->mtx_owner = link->owner,
link->state = MTX_LINK_ACQUIRED, and wakes. mtx_delete_link then sees
ACQUIRED and hits its default "no change" case (:935). But mtx_wait_link
never re-checks link->state after mtx_delete_link returns β it falls through
to the default (:1014-1016, preserving the non-zero error) and overwrites
state to IDLE (:1023), returning EINTR/EWOULDBLOCK even though the mutex
is now exclusively held by the caller's thread. The caller, seeing a non-zero
return, does not call mtx_unlock, so the lock is permanently leaked β every
subsequent acquisition deadlocks.
Root cause
sys/kern/kern_mutex.c:1002-1028:
switch(link->state) { /* :1002 UNLOCKED read */
case MTX_LINK_ACQUIRED:
case MTX_LINK_CALLEDBACK:
error = 0;
break;
case MTX_LINK_ABORTED:
error = ENOLCK;
break;
case MTX_LINK_LINKED_EX:
case MTX_LINK_LINKED_SH:
mtx_delete_link(mtx, link); /* :1012 may race chain grant */
/* fall through */
default:
if (error == 0) /* :1015 no re-check of state */
error = EWOULDBLOCK;
break;
}
link->state = MTX_LINK_IDLE; /* :1023 clobbers ACQUIRED */
return error; /* :1028 returns non-zero despite owning lock */
mtx_delete_link correctly handles seeing ACQUIRED (default "no change"
:935-937, since the chain already removed the link), but mtx_wait_link
does not re-check link->state afterward. tsleep can return EINTR
even when the wakeup was also called (a pending signal sets error=EINTR).
Threat model & preconditions
- Attacker position: any local user on a code path that takes a mutex with
PCATCHor a timeout. The primary in-tree caller is the NFS client socket lock (mtx_lock_ex_linkwithslpflag=PCATCH,slptimeo=2*hzatsys/vfs/nfs/nfs_socket.c:2184). A local user doing NFS I/O who receives a signal (Ctrl-C / SIGINT) at the moment the socket lock is being released by another thread can hit the race. - Privileges gained or impact: permanent kernel deadlock (local DoS) β the leaked lock deadlocks all subsequent operations on that NFS mount, requiring a reboot to clear. Not remote on its own (needs a local signal target); the NFS path is reachable from any local user with NFS access.
- Required config or capabilities: a contended
mtx_lock_*_linkcaller withPCATCH/timeout (NFS); a local signal target. - Reachability: contended mutex acquisition + signal/timeout racing the unlock/chain-grant on another CPU.
Proof of concept (sketch)
- Local user mounts NFS and issues contended I/O (concurrent reads) contending on the NFS socket lock.
- One thread blocks in
mtx_wait_linkinsidenfs_rcvlock/nfs_sndlock(PCATCH). - Send
SIGINTto the process.tsleepreturnsEINTR. - The lock holder releases;
_mtx_unlockβmtx_chain_link_exgrants the lock to the signaled thread during itsmtx_delete_linkwindow. mtx_wait_linkreturnsEINTR; the NFS code treats it as not-acquired and does notmtx_unlock.- The socket lock is now permanently held (
MTX_EXCLUSIVE|1); all future NFS send/receive on that mount deadlocks. Repeat 1-3 to raise hit probability.
Impact
Permanent kernel deadlock (local DoS) via a lock-leak race on PCATCH/timeout
mutex acquisitions. Medium (AC:H = the race window; A:H = a full permanent
deadlock).
Recommended fix
Re-check link->state for ACQUIRED after mtx_delete_link returns in the
LINKED case, and return success so the caller unlocks:
--- a/sys/kern/kern_mutex.c
+++ b/sys/kern/kern_mutex.c
@@ -1010,6 +1010,16 @@
case MTX_LINK_LINKED_EX:
case MTX_LINK_LINKED_SH:
mtx_delete_link(mtx, link);
+ /*
+ * mtx_chain_link_{ex,sh}() may have granted us the lock
+ * (state -> ACQUIRED) while we were spinning on LINKSPIN
+ * inside mtx_delete_link(). If so we now own the lock and
+ * MUST return success so the caller unlocks it; otherwise
+ * the lock is silently leaked, deadlocking all future
+ * acquisitions.
+ */
+ if (link->state == MTX_LINK_ACQUIRED) {
+ error = 0;
+ break;
+ }
/* fall through */
default:
if (error == 0)
References
sys/kern/kern_mutex.c:1002-1028β the un-rechecked post-mtx_delete_linkpath.sys/kern/kern_mutex.c:925-941βmtx_delete_linkdefault "no change" forACQUIRED.sys/vfs/nfs/nfs_socket.c:2184β reachablePCATCHcaller.- CWE-362 Race Condition; CWE-667 Improper Locking.
Timeline
- 2026-06-29 Discovered during automated file-by-file audit of
sys/kern/kern_mutex.c. - pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0047 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger_stub.c | trigger-source | documents the NFS-intr-only race trigger + recipe | 3.1 KB | view raw |
| build.sh | build-script | no-op | 73 B | view raw |
| run.sh | run-script | no-op stub | 146 B | view raw |
| fix.diff | suggested-fix | re-check link->state after mtx_delete_link; treat granted-during-window as success | 723 B | view raw |
| env.txt | environment | uname, cc version | 232 B | view raw |
| VERDICT.md | verdict | full source-trace analysis + why not reproduced | 3.5 KB | β 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-0047 β mtx_wait_link lock-leak race (permanent kernel deadlock)
Verdict: NOT REPRODUCED on this guest β bug REAL by source trace, trigger is NFS-intr-only + non-deterministic race
mtx_wait_link() (sys/kern/kern_mutex.c:948-1029) has a genuine TOCTOU
window that, when won, permanently leaks the mutex and deadlocks the kernel.
The logic is unambiguous from the source:
kern_mutex.c:1002 switch(link->state) {
kern_mutex.c:1010 case MTX_LINK_LINKED_EX:
kern_mutex.c:1011 case MTX_LINK_LINKED_SH:
kern_mutex.c:1012 mtx_delete_link(mtx, link); /* acquires LINKSPIN */
kern_mutex.c:1013 /* fall through */
kern_mutex.c:1014 default:
kern_mutex.c:1015 if (error == 0) error = EWOULDBLOCK;
kern_mutex.c:1017 break;
kern_mutex.c:1018 }
kern_mutex.c:1023 link->state = MTX_LINK_IDLE; /* unconditional clobber */
The window: when tsleep() returns EINTR (PCATCH signal) the switch reads
link->state == LINKED_EX/SH and calls mtx_delete_link(). Between that
read and mtx_delete_link() acquiring LINKSPIN, a concurrent
mtx_chain_link_ex() (:750-799) on another CPU can grant the lock to this
link β it sets link->state = MTX_LINK_ACQUIRED and mtx->mtx_owner =
curthread (:772,:788) and wakeups us. mtx_delete_link() then sees
ACQUIRED β default no-op (:935-937). Back in mtx_wait_link, the
switch already dispatched on the old state and does not re-check; it
falls through and line 1023 unconditionally sets state = IDLE. The caller
returns the EINTR error despite now holding the mutex exclusively β it never
unlocks β permanent deadlock.
Why not reproduced on the audit guest
The race window only opens when mtx_lock_ex*() is called with PCATCH in
the flags (so tsleep can return EINTR). A grep of the entire sys/ tree
shows the only PCATCH-bearing mtx_lock_ex* calls are:
sys/vfs/nfs/nfs_socket.c:2112 mtx_lock_ex(mtx, slpflag, slptimeo); sys/vfs/nfs/nfs_socket.c:2184 mtx_lock_ex_link(mtx, &rep->r_link, ...);
where slpflag = PCATCH only when the NFS mount has NFSMNT_INT (the intr
mount option). No other kernel path passes PCATCH into a mutex wait, so
the bug is reachable exclusively from an interruptible NFS client
operation. The audit QEMU guest has no reachable NFS server β loopback
nfsd/mountd/mount_nfs does not come up cleanly on the minimal image β
so the code path is not reachable here (Phase 4 case d). Even when
reachable, the window is a ~10-cycle gap between kern_mutex.c:1002 and the
LINKSPIN acquire inside mtx_delete_link; it is a non-deterministic race
that will not reproduce in a bounded test.
This is a Medium-severity DoS-only race (permanent deadlock), not memory corruption and not privilege escalation.
Fix (authored; not build-validated since no reproduction exists to test against)
fix.diff re-checks link->state after mtx_delete_link() returns and, if
it transitioned to MTX_LINK_ACQUIRED during the window, returns success
(error = 0) so the caller knows to release the now-held lock. The fix is
minimal and conservative; no kernel build was performed for this finding
because there is no live trigger to validate against on the guest.
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
not_testableauthored
see evidence pack
Confirmed kernel references
β
Detail
Exploit chain
none
Evidence (decisive lines)
β
Verdict
Source-confirmed real. mtx_wait_link PCATCH EINTR TOCTOU -> lock leak/deadlock. NFS-intr-only, no NFS on guest.
No comments yet.