# DF-2739 VERDICT — REPRODUCED (kernel memory corruption / UAF, panic proof)

## Bottom line

The veto path in `linker_file_unload()` (sys/kern/kern_linker.c:514-520)
drops the caller's reference when a module vetoes the unload. This is a
refcount underflow on a live `linker_file` that deterministically leads to a
premature full teardown underneath a live dependent module and then a
use-after-free when the dependent is unloaded. Reproduced end-to-end on the
stock INVARIANTS guest with nothing but kldload(2)/kldunload(2)/kldstat(2)
syscalls and two purpose-built KLDs (`vetoa.ko`, `depb.ko`).

## How it reproduces (baseline run, run.log + panic.txt)

| step | syscall sequence | kernel state (kern_linker.c) | observed |
|------|------------------|------------------------------|----------|
| 1 | `kldload("/tmp/kldtest/vetoa.ko")` | `linker_load_file` → `refs=1`, `userrefs=1` (sys_kldload:820) | `id=4 refs=1` |
| 2 | `sysctl kern.vetoa_veto=1; kldunload(4)` | module handler returns EBUSY → `module_unload(mod)!=0` → **`file->refs--` at :518 with the file still on `linker_files`**; `sys_kldunload` restores `userrefs` (:856) | `EBUSY`, kldstat: **`refs=0`, file loaded** |
| 3 | `kldload("/tmp/kldtest/depb.ko")` (`MODULE_DEPEND(depb, vetoa,1,1,1)`) | `linker_load_dependencies` → `modlist_lookup2("vetoa")` hit → `lfdep->refs++` (:1636) 0→1 | `refs=1` |
| 4 | `sysctl kern.vetoa_veto=0; kldunload(4)` | `refs==1` → NOT the `refs>1` fast path → **full teardown of vetoa**; `TAILQ_REMOVE(&linker_files,...)`, `kfree(file)` — while `depb->deps[0]==vetoa` | `kldunload=0`, `kldfind(vetoa)=ENOENT`, depb still loaded |
| 5 | `kldunload(5)` | `linker_file_unload(depb)` → dep loop `linker_file_unload(file->deps[0])` (:544-545) on the **freed** object → `freed->refs` read, `TAILQ_REMOVE` on freed memory, `freed->ops->unload` indirect call | **panic: `Bad link elm ... next->prev != elm`**, backtrace shows the nested `linker_file_unload` frames |

Decisive console lines (panic.txt):

```
vetoa: MOD_UNLOAD vetoed -> EBUSY
vetoa: MOD_UNLOAD accepted
depb: MOD_UNLOAD accepted
panic: Bad link elm 0xfffff8008bb64280 next->prev != elm
cpuid = 0
linker_file_unload() at linker_file_unload+0x478
linker_file_unload() at linker_file_unload+0x478
linker_file_unload() at linker_file_unload+0x14e
sys_kldunload() at sys_kldunload+0x81
```

## Primitive characterization

Freed object: `struct linker_file` (~200 bytes, `M_LINKER` kmalloc zone — the
same zone used by link_elf's `elf_file`, `pathname` and symbol-table
allocations, so zone grooming by the (privileged) attacker is
straightforward). The UAF gives:

- **write**: `file->refs--` on the freed chunk (fast path, :494-497) and
  `TAILQ_REMOVE(&linker_files, file, link)` (:540) — two pointer writes into
  the freed chunk plus list-head surgery;
- **indirect call**: `file->ops->unload(file)` (:556) and
  `file->ops->lookup_set` etc. — control flow through a pointer read from the
  freed chunk on non-INVARIANTS builds;
- **read**: `strlen(lf->filename)`, `lf->deps[]` walk.

On the INVARIANTS guest the TAILQ invariant check converts this into an
immediate panic (proof). On production (non-INVARIANTS) builds this is
silent heap corruption fully controllable by the already-privileged caller.

## Why it stops short of uid=0

The only trigger entry points are `kldunload(2)`/`kldload(2)`, both gated by
`caps_priv_check_self(SYSCAP_NOKLD)` (sys/kern/kern_linker.c:794, :841);
there is no unprivileged path that reaches the veto branch (netgraph's
auto-load paths are root-gated at ngc_attach, sys/netgraph7/socket/ng_socket.c:182).
So the honest impact ceiling is: **deterministic kernel UAF on the privileged
kld management path** — kernel robustness/memory-corruption, not an
unprivileged escalation. (Hard blocker: privilege gate at :841, verified by
tracing every `linker_file_unload` caller.)

## Fix validation

`fix.diff` removes the erroneous `file->refs--` (the caller keeps its
reference when unload fails). Applied to the guest's /usr/src, kernel
rebuilt with `make -j6 nativekernel KERNCONF=X86_64_GENERIC` + installkernel,
guest rebooted into `#1: Mon Aug 31 12:46:55 UTC 2026`, exact same PoC re-run
(run.fixed.log):

| step | baseline (stock #0) | patched (#1) |
|------|---------------------|--------------|
| 2 vetoed unload | EBUSY, **refs=0**, file live | EBUSY, **refs=1** |
| 3 depb load | vetoa refs=1 | vetoa **refs=2** |
| 4 second unload | **vetoa torn down** (kldfind ENOENT, depb dangling) | fast path refs 2→1, **vetoa stays loaded** |
| 5 depb unload | **panic: Bad link elm … next->prev != elm** (nested linker_file_unload frames) | clean unload, returns 0, guest up |

Bad behavior eliminated; both modules end cleanly loadable/unloadable
(`kldstat` tail in run.fixed.log).

## Cross-checks that rule out alternative explanations

- The `userrefs` handling in `sys_kldunload` (:853-856) is symmetric and not
  the cause: refs underflow is observable *before* any user accounting.
- `MODULE_DEPEND` refcounting itself is correct on both load and unload when
  no veto occurs (control runs without the veto: clean load/unload cycles).
- The panic backtrace's double `linker_file_unload` frame is exactly the
  dependency recursion at :544-545 — not an unrelated list bug.
