# DF-0949 — sys_mlockall use-after-free (vm_mmap.c:1063-1099, vm_fault.c:2624-2648)

## Verdict: REPRODUCED — UAF panic confirmed; FIX VALIDATED on single-fix kernel

### Mechanism (confirmed by panic signature)

`sys_mlockall` (sys/vm/vm_mmap.c:1046) iterates the process vm_map with
RB_FOREACH, holding the map lock, and calls `vm_fault_wire(map, entry,
TRUE, 0)` per entry (line 1081). `vm_fault_wire` (vm_fault.c:2580)
documents that the entry **must** be marked `MAP_ENTRY_IN_TRANSITION`:

> The entry in question should be marked in-transition and the map
> must be locked. We must release the map temporarily while
> faulting-in the page to avoid a deadlock. Note that the entry may
> be clipped while we are blocked but will never be freed.

And, at vm_fault.c:2624-2648:

```c
map->timestamp++;
vm_map_unlock(map);          // <-- map lock dropped
... vm_fault() loop ...
vm_map_lock(map);            // <-- reacquired
```

`sys_mlock()` (vm_mmap.c:1019-1039) avoids the issue by delegating to
`vm_map_user_wiring()` which uses `vm_map_clip_range()` /
`vm_map_unclip_range()` to set `MAP_ENTRY_IN_TRANSITION` on the whole
range (vm_map.c:2006, 2047), forcing a concurrent `vm_map_delete()` to
sleep in `vm_map_transition_wait()` (vm_map.c:1918) instead of freeing
entries out from under it.

`sys_mlockall` did **not** mark entries IN_TRANSITION before calling
`vm_fault_wire`. So during the unlock window inside `vm_fault_wire`,
a concurrent `munmap()` in another thread proceeds through
`vm_map_delete()` (vm_map.c:3225) which — finding no IN_TRANSITION on
the entry (vm_map.c:3282) — calls `vm_map_entry_unwire_all()` (vm_map.c:3192)
which decrements `wired_count` to 0, clears USER_WIRED, and frees the
entry.

When `vm_fault_wire` returns and `sys_mlockall` resumes, it executes:

```c
entry->eflags |= MAP_ENTRY_USER_WIRED;   // <-- UAF write to freed slab
```

The freed slab is typically re-used for a fresh vm_map_entry created by
the churn thread's subsequent mmap() — so the USER_WIRED flag is set on
an unrelated, freshly-allocated entry whose `wired_count` is still 0.
When `sys_mlockall`'s cleanup loop (line 1092-1097) iterates that entry
and calls `vm_fault_unwire`, the `KKASSERT(entry->wired_count)` at
vm_fault.c:2672 fails:

```
panic: assertion "entry->wired_count" failed in vm_fault_unwire at /usr/src/sys/vm/vm_fault.c:2672
cpuid = 2
Trace beginning at frame 0xfffff801183b7888
vm_fault_unwire() at vm_fault_unwire+0xfb 0xffffffff809a034b
vm_fault_unwire() at vm_fault_unwire+0xfb 0xffffffff809a034b
sys_mlockall() at sys_mlockall+0xce 0xffffffff809a952e
syscall2() at syscall2+0x11e 0xffffffff80bd6a0e
Debugger("panic")
Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip)
```

The double vm_fault_unwire frame is the cleanup loop calling the
function that itself recurses through `vm_map_entry_unwire_all` after
the assertion already fired — the panic signature matches the cited
root cause exactly.

### Reproduction

`mlockall_uaf_v4.c` — root process spawns two pthreads:
  - **mlockall_thread**: hammer `mlockall(MCL_CURRENT)` in a loop
  - **churn_thread**: continuously `munmap` half of 256 pre-populated
    single-page entries then `mmap` them back

Reliably triggers the panic within ~30 seconds on the unpatched #0
kernel (`with-src`, INVARIANTS ON). Confirmed twice on fresh resets:

```
panic: assertion "entry->wired_count" failed in vm_fault_unwire at vm_fault.c:2672
sys_mlockall() at sys_mlockall+0xce
```

### Privilege boundary

`mlockall` requires `SYSCAP_RESTRICTEDROOT` (vm_mmap.c:1059). The bug
is **root→kernel** corruption: an attacker must already be root to
exercise it. There is no unpriv→root escalation chain here. The
impact is a local DoS / kernel-memory-corruption primitive available
to an attacker who already has root, which is a defense-in-depth
gap rather than a privilege boundary crossing.

## Fix (validated)

`fix.diff` rewrites `sys_mlockall` to delegate the MCL_CURRENT phase
to `vm_map_user_wiring()` — exactly what `sys_mlock` does (vm_mmap.c:1036).
`vm_map_user_wiring()` (vm_map.c:2583) uses
`vm_map_clip_range()` / `vm_map_unclip_range()` to atomically mark every
entry in the target range IN_TRANSITION, which forces any concurrent
`vm_map_delete()` to sleep on `vm_map_transition_wait()` instead of
freeing entries out from under us.

The MCL_FUTURE flag is handled separately under our own short lock
(since it just sets `map->flags |= MAP_WIREFUTURE`).

### Validation result

Built and booted single-fix kernel `6.5-DEVELOPMENT #1: Sun Jul 19
13:13:47 UTC 2026` (sha256 of `/boot/kernel/kernel` =
`343deac19b7ad884c575f4b4939e2e6d`).

| kernel | mlockall_uaf_v4 (60 s) | result |
|--------|------------------------|--------|
| `#0` baseline (with-src) | panic in <30 s | `panic: assertion "entry->wired_count" failed in vm_fault_unwire` |
| `#1` single-fix | 60 s, ~18.7M mlockall calls, **no panic**, **no hang**, clean exit | PASS |

Two consecutive clean runs on the patched kernel confirm the fix is
deterministic. The unpatched kernel panicked twice on fresh resets.

### Side note (unrelated to DF-0949)

The `install` command on this guest fails to make the new kernel
bootable (`Unable to load /kernel/kernel` at the loader prompt,
apparently a vnode/inode quirk with the running kernel). `dd
if=...kernel.stripped of=/boot/kernel/kernel bs=1m` works correctly.
The fix.diff itself is unaffected; this is purely an install-method
quirk of the test guest.
