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

lockmgr_release() multi-count blind decrement loses the grant/wakeup transition under concurrent LK_KERNTHREAD release (lost wakeup: waiter sleeps on a free lock)

Field Value
ID DF-2750
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L
CWE CWE-667 Improper Locking
File sys/kern/kern_lock.c
Lines 874-886 (blind fetchadd on stale :753 read)
Area kern
Confidence certain
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket base:kern
Reported pending
Known CVE none
CVE match novel

Summary

The exclusive multi-count branch of lockmgr_release() decrements lk_count with a blind atomic_fetchadd_long(-1) based on a non-atomic entry read, unlike the three single-count cases which use validated fcmpset and perform grant transfer, EXREQ2/CANCEL clearing, SHARED pre-setting, and wakeup(lkp). When lk_lockholder == LK_KERNTHREAD any cpu may legally release (the buffer-cache biodone hand-off), so two concurrent releasers that both read LKC_XMASK==2 drive the count 2β†’0 with neither taking a single-count case: no wakeup is issued, LKC_EXREQ2 stays stranded, LKC_CANCEL uncleared, LKC_SHARED unset. A thread parked in lockmgr_exclusive()'s EXREQ2 tsleep then sleeps indefinitely on a completely free lock.

Threat model & preconditions

Realistic ceiling: uninterruptible hang (local DoS) of a kernel thread blocked on a zero-holder lock until unrelated traffic issues a wakeup. In-tree reachability is currently gated (no consumer today KERNPROC-transfers a recursive exclusive lock and double-releases concurrently); the defect converts any such future consumer race from a loud panic into a silent permanent wedge β€” a robustness hole in the core primitive used by the entire buffer cache.

Proof of concept

VERIFIED on the stock INVARIANTS guest (findings/poc/DF-2750/lkmc.c KLD harness staging XMASK=2 + KERNTHREAD + parked EXREQ2 waiter with cpu-pinned barrier-released releasers): ~98% hits per 20,000 rounds (19,750/19,756/19,617), every hit revived only by a manual wakeup (healed==hits proves no kernel wakeup); lk_count stuck at exactly LKC_EXREQ2 with zero holders. No memory-corruption angle (cannot underflow past 0). Fix (validated fcmpset retry so the loser falls into the proper single-count case) validated on a rebuilt kernel: 0 hits across 60,000+ staged races vs ~98% on stock.

See the validated diff in findings/poc/DF-2750/fix.diff (fcmpset loop in the multi-count branch; uncontended behavior unchanged).

Timeline

  • 2026-08-30 Discovered during pass-2 audit of kern_lock.c (GLM 5.3); 98% reproducible KLD race + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2750 Β· 11 files
FileTypeDescriptionSize
lkmc.c β€” 9.7 KB view raw
Makefile β€” 48 B ↓ download
build.sh β€” 122 B view raw
run.sh β€” 377 B view raw
build.log β€” 987 B view raw
run.log β€” 1.3 KB view raw
run.fixed.log β€” 1.2 KB view raw
env.txt β€” 4.0 KB view raw
VERDICT.md β€” 7.3 KB ↓ raw
fix.diff β€” 1.3 KB view raw
code_hash.txt β€” 73 B view raw
VERDICT.md
↓ download raw

DF-2750 β€” lockmgr_release() multi-count blind decrement: lost wakeup under concurrent LK_KERNTHREAD release ============================================================================

1. WHAT WAS FOUND (code defect, sys/kern/kern_lock.c)

lockmgr_release() handles the release of an exclusively-held lock with three validated single-count cases followed by a "multiple exclusive counts" else-branch:

  • kern_lock.c:781-813 (XMASK==1, no UPREQ/EXREQ) -> clears EXREQ2 and CANCEL, pre-sets LKC_SHARED, wakeup(lkp) if waiters
  • kern_lock.c:814-841 (XMASK==1 + UPREQ) -> grants upgrade, wakeup
  • kern_lock.c:842-873 (XMASK==1 + EXREQ) -> grants excl, wakeup
  • kern_lock.c:874-886 (else: XMASK>=2) -> *** BLIND *** decrement

The else-branch does:

    count = atomic_fetchadd_long(&lkp->lk_count, -1);

based on a `count' value that was read non-atomically at function entry (kern_lock.c:753). There is no re-validation.

The lock-holder check at kern_lock.c:774-775 permits ANY thread to release when lk_lockholder == LK_KERNTHREAD β€” this is the buffer-cache biodone hand-off protocol (BUF_KERNPROC, sys/sys/buf2.h:101-109), used by ~30 consumers (vfs_cluster.c, hammer/hammer2 io, nvme, dm, ccd, nfs, swap pager, ...).

Consequence: two threads releasing concurrently (both legal holders via KERNTHREAD) that both read LKC_XMASK == 2 drive the count 2 -> 0 with NEITHER thread executing a single-count case. In that transition:

  • no wakeup(lkp) is issued at all;
  • LKC_EXREQ2 is left set (stranded);
  • LKC_CANCEL is not cleared;
  • LKC_SHARED is not pre-set.

Any thread parked in lockmgr_exclusive()'s EXREQ2 tsleep (set at kern_lock.c:365-385 when the lock is exclusively held) now sleeps on a lock that is completely FREE (count == LKC_EXREQ2 only). It remains asleep indefinitely β€” until unrelated traffic on the same lock instance happens to issue a wakeup (e.g. a later acquire+release cycle). For a buffer lock that typically means the I/O strategy blocked in BUF_LOCK hangs (unkillable if the sleep has no PCATCH/TIMO, which buffer locks do not).

A serialized double-release (the common case) is caught loudly by the panic at kern_lock.c:767-768 ("LK_RELEASE: no lock held") β€” only the exactly-concurrent window wedges silently.

Reachability today: requires a consumer that (a) recurses an exclusive lockmgr lock to XMASK>=2, (b) transfers it to LK_KERNTHREAD, and (c) has two threads release it concurrently β€” i.e. an upstream hand-off bug (no in-tree consumer currently does this; there is no BUF_RECURSE macro and the biodone hand-off releases exactly once). The defect is a robustness/liveness hole in the core primitive: it converts a future consumer race into a silent permanent hang instead of either a clean state transition or the existing loud panic. Class: local DoS (hang) in the making; defense-in-depth for the lockmgr core.

2. REPRODUCTION (full logs in env.txt / run.log)

Harness: findings/poc/DF-2750/lkmc.c (KLD). Deterministically stages the exact precondition each round:

lockinit(&lk, "lkmc", 0, LK_CANRECURSE); lockmgr(&lk, LK_EXCLUSIVE); / XMASK = 1 / lockmgr(&lk, LK_EXCLUSIVE); / XMASK = 2 (recursive) / lockmgr_kernproc(&lk); / holder = KERNTHREAD / [waiter thread]: lockmgr(&lk, LK_EXCLUSIVE); / parks on EXREQ2 /

then releases two lwkt threads pinned to cpu0/cpu1 from a shared spin barrier so both enter lockmgr_release() within nanoseconds, each reading count == XMASK(2)|EXREQ2 at entry.

HIT = after both releases complete: lk_count == 0x08000000 (LKC_EXREQ2 only, no counts) AND the waiter still asleep (revived only by the driver's manual wakeup()).

Guest: DragonFly 6.5-DEVELOPMENT, QEMU/KVM, 6 vCPUs, stock INVARIANTS kernel #0 (Thu Jul 2 2026).

RESULTS (stock kernel, 20000 rounds/run): 19750, 19756, 19617 hits (v3 harness; 98.1-98.8% hit rate) 15857, 19739, 19647, 19760 hits (v2 harness; identical staged race) every hit: bad_count == 0x08000000, healed == hits (waiter never self-completed), stuck=0, guest fully healthy after each run.

The lost wakeup is therefore 100% reproducible at the staged race rate of ~98%; it is not a rare cosmic-ray window.

3. FIX (fix.diff) AND VALIDATION

Convert the blind decrement into a validated fcmpset retry:

    ncount = count - 1;
    if (atomic_fcmpset_64(&lkp->lk_count, &count, ncount)) {
            if (lkp->lk_lockholder != LK_KERNTHREAD)
                    COUNT(td, -1);
            break;
    }
    /* fcmpset failed, count reloaded: retry */

The outer for(;;) then re-evaluates with the fresh count; the loser of the race re-reads XMASK==1 and falls into the proper single-count case (grant + wakeup + EXREQ2/CANCEL clear + SHARED pre-set). XMASK can never again transition to 0 outside a single-count case. Uncontended behavior is identical (one fcmpset instead of one fetchadd; same COUNT).

Validated in-guest: fix.diff applied to /usr/src, kernel rebuilt with `make nativekernel KERNCONF=X86_64_GENERIC', installed, rebooted (#1). Same harness, same load:

fixed:  rounds=20000 hits=0   (x3, plus one v2-harness run and
                               1/100-round shakedown runs: hits=0)
stock:  rounds=20000 hits=~19700 (x3)

The previously-observed bad state (count == 0x08000000 with the waiter asleep) never occurs; the waiter completes on its own in 100% of 60000+ staged races. fix_status: fixed.

4. HONEST CAVEATS / DEVELOPMENT NOTES

  • Impact ceiling: demonstrated impact is a hang of a kernel thread on a free lock (dos), not memory corruption. lk_count never underflows via this path (the else-branch is only entered with XMASK>=2, and n concurrent blind decrements from n can reach 0 but not wrap), so there is no count-corruption/privesc angle.
  • In-tree reachability requires a future/racy consumer (see Β§1); the finding is filed as Low severity hardening of a core primitive, not as an exploitable-today vulnerability.
  • Two guest deaths during harness development were caused by HARNESS thread-lifecycle flaws (stale per-invocation threads double-releasing a 2-count lock -> the existing "LK_RELEASE: no lock held" panic at kern_lock.c:767), not by the target defect; the final v3 harness (persistent, generation-safe threads + bounded spins + drain) ran all reported results with zero bail-outs (stuck=0) and a healthy guest.
  • An unrelated QEMU/KVM artifact was observed: freshly created cpu-pinned lwkt threads occasionally take seconds to get first-scheduled on idle vCPUs. This caused earlier harness bail-outs (stuck=1/10/2) and is mitigated in v3 by persistent threads parked in 1-tick polling tsleeps.

5. VERDICT

reproduced: YES (deterministic staging, ~98% of staged races on stock kernel; zero on patched kernel). impact: dos (uninterruptible hang of a kernel thread on a free lock; real-world severity gated on a consumer that KERNPROC-transfers a recursive exclusive lock and double-releases it concurrently). confidence: certain (code path proven by direct state observation; root cause isolated to the single blind fetchadd; fix eliminates it exactly and only there).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied fix.diff to in-guest /usr/src/sys/kern/kern_lock.c, rebuilt (make nativekernel, 1300s), installed and rebooted into #1. Re-ran the identical PoC: baseline stock kernel reproduced the lost wakeup at 98%+ of staged races; patched kernel showed 0 hits across 60000+ staged races (waiter always completes via the single-count case). The previously-observed stranded state (lk_count == LKC_EXREQ2 with zero holders and waiter asleep) never occurs on the patched kernel. Guest healthy before/after.

['run.fixed.log: patched-kernel dmesg (3x20000 + shakedown runs, hits=0, stuck=0)', 'run.log: stock-kernel baseline (hits=~19700/20000)', 'fix.diff: the exact diff applied and validated']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Mon Aug 31 17:16:25 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

No escalation chain (liveness bug, not memory corruption). Demonstrated impact: kernel thread parked in lockmgr_exclusive()/BUF_LOCK sleeps forever on a lock with zero holders after two concurrent KERNTHREAD releases race the blind decrement; uninterruptible hang of e.g. an I/O strategy thread until unrelated traffic on the same lock happens to wakeup() it.

Evidence (decisive lines)

['run.log: stock-kernel dmesg, 3x20000 rounds, hits=19750/19756/19617, bad_count=0000000008000000 (== LKC_EXREQ2 only), healed==hits (waiter revived only by harness wakeup), stuck=0', 'run.fixed.log: same harness on fcmpset-patched kernel, 0 hits in 60000+ races', 'env.txt: full transcripts incl. kernel ids (#0 stock INVARIANTS / #1 patched), guest env, build/run commands', 'VERDICT.md: root-cause walk (kern_lock.c:753,774,781,814,842,874) and honest caveats (in-tree reachability gated on future consumer race; harness dev panics were harness-side double-releases caught by the existing :767 guard)', 'lkmc.c: KLD harness; staging code commented line-by-line', 'fix.diff: 12-line fcmpset retry conversion, git-apply-able against sys/kern/kern_lock.c']

PoC changes

Seed PoC rewritten entirely: KLD lwkt harness (cpu-pinned releasers + parked EXREQ2 waiter + spin barrier), because the defect is a core-primitive race not reachable from unprivileged userland in-tree. Three harness iterations: v1 (tsleep polling) starved the guest; v2 (all-spin, bounded caps) reproduced at 79-99% but exposed a QEMU/KVM scheduling flake for freshly created pinned threads; v3 (persistent threads parked in 1-tick polling tsleeps, bounded spins, drain-on-bail, generation tokens) is flake-free: stuck=0 on every reported run.

Verified recommended fix

Replace the blind atomic_fetchadd(-1) in lockmgr_release()'s multi-count branch with a validated atomic_fcmpset_64(count-1) retry so the losing releaser re-runs the single-count grant/wakeup cases (fix.diff).

Verdict

lockmgr_release()'s exclusive multi-count branch (sys/kern/kern_lock.c:874-886) decrements lk_count with a blind atomic_fetchadd(-1) from a stale entry read. With lk_lockholder == LK_KERNTHREAD (the biodone hand-off protocol, buf2.h BUF_KERNPROC) any cpu may legally release, so two concurrent releasers that both read LKC_XMASK==2 drive the count 2->0 with neither executing a single-count case: no wakeup(lkp), LKC_EXREQ2 stranded, LKC_CANCEL uncleared, LKC_SHARED never pre-set. A thread parked in lockmgr_exclusive()'s EXREQ2 tsleep then sleeps indefinitely on a completely free lock. Reproduced with an in-guest KLD harness staging the exact precondition and racing two cpu-pinned releasers: 19750/19756/19617 hits per 20000 rounds on the stock INVARIANTS kernel (98%+ of staged races), every hit requiring a manual wakeup to revive the waiter, guest otherwise healthy. Patching only that branch to a validated fcmpset retry (fix.diff, kernel rebuilt and rebooted) yields 0 hits across 60000+ staged races. In-tree reachability today requires a consumer that KERNPROC-transfers a recursive (XMASK>=2) exclusive lock and double-releases it concurrently, so this is filed as Low-severity hardening of a core primitive (silent permanent hang instead of the loud panic the serialized case gets), not an exploitable-today bug; no memory-corruption angle exists (count cannot underflow past 0 on this path).