# 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`.
