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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2750 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
fixedApplied 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']
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).
No comments yet.