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 | 253 B | view raw |
| run.sh | run-script | no-op stub | 453 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.2 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 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.