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

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 PCATCH or a timeout. The primary in-tree caller is the NFS client socket lock (mtx_lock_ex_link with slpflag=PCATCH, slptimeo=2*hz at sys/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_*_link caller with PCATCH/timeout (NFS); a local signal target.
  • Reachability: contended mutex acquisition + signal/timeout racing the unlock/chain-grant on another CPU.

Proof of concept (sketch)

  1. Local user mounts NFS and issues contended I/O (concurrent reads) contending on the NFS socket lock.
  2. One thread blocks in mtx_wait_link inside nfs_rcvlock/nfs_sndlock (PCATCH).
  3. Send SIGINT to the process. tsleep returns EINTR.
  4. The lock holder releases; _mtx_unlock→mtx_chain_link_ex grants the lock to the signaled thread during its mtx_delete_link window.
  5. mtx_wait_link returns EINTR; the NFS code treats it as not-acquired and does not mtx_unlock.
  6. 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).

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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0047 Β· 8 files
FileTypeDescriptionSize
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
VERDICT.md verdict full source-trace analysis + why not reproduced
↓ download 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_testable

authored

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.