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

F_UNLCK of caller's own locks fails with ENOLCK when the uid POSIX-lock limit is reached (POSIX violation, lock-shedding wedge)

Field Value
ID DF-2823
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L
CWE CWE-770 (limit enforced against lock release)
File sys/kern/kern_lockf.c
Lines 510-524 (check at lf_count_change :171-173)
Area kern
Confidence certain
Discovered 2026-08-31
Pass 2 (GLM 5.3 second pass)
Bucket base:kern
Reported pending
Known CVE none
CVE match novel

Summary

lf_setlock() pre-charges a worst-case "+1 new lock" headroom through lf_count_change() before clipping, and that headroom passes the same per-uid limit check as lock creation. A process whose uid has reached RLIMIT_POSIXLOCKS/kern.maxposixlocksperuid therefore gets ENOLCK from a partial F_UNLCK of its own locks β€” POSIX.1-2008 2.9.7 requires F_UNLCK of one's own locks to succeed. The limit is live by default (maxposixlocksperuid = maxproc*4) and any process can lower its own rlimit unprivileged. Recovery only via whole-file unlock β€” a non-root daemon managing locks piecemeal gets wedged.

Proof of contest

VERIFIED on the guest (findings/poc/DF-2823/): setrlimit(2) + two adjacent locks + 33 filler locks to roll the per-cpu pup past PUP_LIMIT into ui_posixlocks (369 ENOLCK refusals prove the cap), then a clipping F_UNLCK[1,2] β†’ errno 77 ENOLCK where POSIX mandates success (3/3 runs); full-range unlock returns 0 (recovery). No escalation chain (not memory corruption). Fix (never subject the F_UNLCK headroom to the per-uid limit β€” thread an 'enforce' parameter) patch --dry-run verified, full diff in the pack.

See findings/poc/DF-2823/fix.diff.

Timeline

  • 2026-08-31 Discovered during pass-2 audit of kern_lockf.c (GLM 5.3); deterministic unpriv ENOLCK-on-unlock reproduced same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2823 Β· 12 files
FileTypeDescriptionSize
df2823_enolck.c β€” 4.4 KB view raw
build.sh β€” 126 B view raw
run.sh β€” 75 B view raw
build.log β€” 11 B view raw
run.log β€” 429 B view raw
run.1.log β€” 429 B view raw
run.2.log β€” 429 B view raw
run.3.log β€” 429 B view raw
run.first.log β€” 219 B view raw
env.txt β€” 287 B view raw
fix.diff β€” 1.8 KB view raw
VERDICT.md β€” 3.8 KB ↓ raw
VERDICT.md
↓ download raw

DF-2823 VERDICT β€” REPRODUCED

Bottom line

On a stock DragonFly 6.5-DEVELOPMENT INVARIANTS kernel, an unprivileged user process that has reached its uid's POSIX-lock limit (RLIMIT_POSIXLOCKS lowered to 2 by itself, or the default kern.maxposixlocksperuid reached by uid-wide lock churn) cannot release its own byte-range locks piecemeal: any partial F_UNLCK that clips into an existing own lock fails with ENOLCK. POSIX.1-2008 Β§2.9.7 requires F_UNLCK of the caller's own locks to succeed. The whole-file unlock still works (recovery), but a non-root daemon with a low rlimit that manages locks incrementally gets wedged.

How it reproduces (path:line)

  1. kern_fcntl F_SETLK/F_UNLCK β†’ VOP_ADVLOCK β†’ lf_advlock() (sys/kern/kern_lockf.c:195). start/end computed, op dispatched (kern_lockf.c:270–271 β†’ lf_setlock()).
  2. lf_setlock() finds first_match/last_match (caller's own overlapping ranges; kern_lockf.c:342–378).
  3. Worst-case headroom accounting, kern_lockf.c:510–524: c count = 0; if ((flags & F_POSIX) && !unlock_override) { if (!lf_match(first_match, type, flags) && !lf_match(last_match, type, flags)) { ... count = -1; /* an F_UNLCK clip may split one lock */ } if (count && lf_count_change(owner, -count)) { error = ENOLCK; /* <-- F_UNLCK must not fail here */ goto do_cleanup; } } For F_UNLCK, lf_match() is always false (ranges are F_RDLCK/F_WRLCK, the request is F_UNLCK), so count = -1 whenever the unlock clips at least one own lock and is not the whole-range unlock_override case (kern_lockf.c:487–492).
  4. lf_count_change(owner, +1) hits the limit check (kern_lockf.c:171–173: diff > 0 && uid != 0 && max != -1 && uip->ui_posixlocks >= max) and returns 1 without charging β†’ error = ENOLCK for an unlock.
  5. The uid-wide ui_posixlocks only rolls up from per-cpu pups past Β±32 (PUP_LIMIT, sys/sys/resourcevar.h:117; flush at kern_lockf.c:180–184), which is why the PoC creates 33+ locks before probing; the 369 ENOLCK refusals during filler creation prove the same ui_posixlocks >= max condition the unlock headroom tests was true.

Observed (run.log): F_UNLCK [1,2] (clips own A=[0,1] and B=[2,3]) β†’ rc=-1 errno=77 (ENOLCK). Subsequent full-range F_UNLCK(0, len 0) β†’ rc=0 (the unlock_override path at kern_lockf.c:490–492 skips the headroom, which is why close(2)/exit paths still work β€” impact is bounded).

Exploit chain

Not a memory-corruption primitive; no escalation chain exists or is needed. Impact ceiling: a non-root process (self, or any same-uid victim whose lock count an attacker can inflate β€” only same-uid creations charge the uid) is denied piecemeal lock release; recovery requires whole-file unlock or process exit. Deterministic, no race.

PoC changes vs the seeded idea

(None β€” first pack for this finding.) One harness iteration was needed: the initial gate required β‰₯33 filler successes, but A+B+31 fillers = 33 total charges is exactly what flushes the counter; the corrected gate keys on refusals > 0 (which directly proves ui_posixlocks >= max). First attempt preserved in run.first.log.

Fix validation

fix.diff authored against the read-only sys/ tree (add enforce flag to lf_count_change(); pass type != F_UNLCK at the headroom site). Not kernel-rebuilt: this is a Low-severity POSIX/DoS finding, not memory corruption, so the audit contract makes fix validation optional; the diff is minimal and mechanical (accounting still balanced: F_UNLCK headroom is charged, only the limit rejection is suppressed).

Guest state

Clean: temp file unlinked before locking; final whole-range unlock releases all ranges; no panics (guest stayed up across all 4 runs); guest_dirty=0.

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

fix.diff authored and verified to apply cleanly to the pristine tree (patch --dry-run OK); kernel rebuild not performed -- Low-severity non-corruption finding, fix validation optional per audit contract.

['fix.diff', 'VERDICT.md']
↓ fix.diffper-fix-DF-2823

Confirmed kernel references

Detail

Exploit chain

no escalation chain (not memory corruption): unpriv process -> setrlimit(RLIMIT_POSIXLOCKS,2) -> 33+ disjoint F_SETLKs (rolls per-cpu pup past PUP_LIMIT=32 into ui_posixlocks) -> partial F_UNLCK clipping two own locks -> ENOLCK where POSIX mandates success; recovery only via whole-range unlock

Evidence (decisive lines)

['run.log line 4: T1_RESULT: BUG-REPRODUCED F_UNLCK of own locks failed rc=-1 errno=77 (No locks available)', 'run.log line 3: filler locks ok=31 refused(ENOLCK)=369 proves ui_posixlocks >= max (same condition the unlock headroom tests)', 'run.log line 5: full-range F_UNLCK rc=0 (unlock_override recovery path works)', 'env.txt: kern.maxposixlocksperuid=32528 (limit live by default)', 'fix.diff: enforce-flag patch, verified to apply cleanly (patch --dry-run)']

PoC changes

First run miscounted the setup gate (required >=33 filler successes, but A+B+31 fillers = 33 total charges is exactly what flushes the per-cpu rollup); gate corrected to key on ENOLCK refusals > 0, which directly proves the limit condition. run.first.log preserves the first attempt.

Verified recommended fix

Pass an 'enforce' flag through lf_count_change(); headroom for F_UNLCK must not be subject to the per-uid limit (charge it, never fail) -- see fix.diff

Verdict

On a stock DragonFly 6.5-DEVELOPMENT INVARIANTS kernel, an unprivileged process whose uid has reached its POSIX-lock limit (self-lowered RLIMIT_POSIXLOCKS=2, or the default maxposixlocksperuid) cannot release its own byte-range locks piecemeal: any partial F_UNLCK that clips into an own lock fails with ENOLCK (kern_lockf.c:520 headroom pre-charge routed through the creation limit check at kern_lockf.c:171). POSIX.1-2008 2.9.7 requires F_UNLCK of one's own locks to succeed. Whole-file unlock still works (unlock_override path), bounding impact to a self/same-uid lock-shedding wedge for non-root processes; root bypasses the limit entirely.