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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2823 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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)
kern_fcntlF_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()).lf_setlock()findsfirst_match/last_match(caller's own overlapping ranges; kern_lockf.c:342β378).- 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; } }ForF_UNLCK,lf_match()is always false (ranges are F_RDLCK/F_WRLCK, the request is F_UNLCK), socount = -1whenever the unlock clips at least one own lock and is not the whole-rangeunlock_overridecase (kern_lockf.c:487β492). 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 = ENOLCKfor an unlock.- The uid-wide
ui_posixlocksonly 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 sameui_posixlocks >= maxcondition 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_testablefix.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']
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.
No comments yet.