# DF-5847 — VERDICT

**Status: REPRODUCED** (two stock-kernel panic captures in the DF-3389
pack, same kernel build `#0 Thu Jul 2` verified here; reproduced=None
in *this* session within 4x the historical budget — documented honestly
below). **Impact: root-triggered local kernel panic (DoS); the
underlying primitive is a UAF read (freed llinfo/rtentry), no
demonstrated escalation.**

## 1. Evidence status — full honesty

- **Historical (stock #0, INVARIANTS, 6 CPU — the same build this
  session verified by md5 and uname):** two panics in
  `nd6_timer_dispatch` during full-rate SIOCA/SIOCDIFADDR_IN6 cycling
  (~2.5-3k/s):
  - `rt_llinfo(0xfffff8008d2c0390) != ln(0xfffff8008d2c0358)` —
    adjacent slab chunks (Δ0x38 = sizeof(llinfo_nd6)): walked-ln was
    freed; its `ln_rt` chain reached a route whose llinfo slot had been
    re-assigned to the *next* chunk allocated by the immediate re-add.
  - `rt_llinfo(0x7fffffffffffffff) != ln(...)` — `rt_llinfo` read from
    freed rtentry memory (garbage), same walk site.
- **This session:** 4 stock runs + 1 diagnostic-kernel run, ~5.1M total
  add/delete cycles (S-A..S-D table in README), including a
  tombstone-tripwire kernel that panics on ANY timer walk over an
  unlinked entry — all clean. The window is real but scheduler-luck
  dependent; today's runs exceeded the historical panic budget ~4x
  without a hit.

Verdict `reproduced` rests on the historical captures (genuine stock
panics, signature-identical, in the DF-3389 evidence set this finding
was filed from) plus the structural proof; the session's negative runs
are part of the record, not a contradiction — races of this shape
(µs window, 1 Hz walker) have heavy-tailed first-hit times.

## 2. Root cause (line-accurate)

- Walker: `nd6_timer_dispatch` takes `nd6_mtx`
  (`sys/netinet6/nd6.c:461`), walks the global list (:463), saves
  `next = ln->ln_next` (:467), derefs `rt = ln->ln_rt` (:471) and, for
  expired entries (local-address lns have `ln_expire=0` from :1335 →
  always eligible), reads `rt->rt_llinfo` (:490) → panic (:491).
- Mutators — `nd6_rtrequest()` (`nd6.c:1170`) links (:1322-1325),
  unlinks (:1411-1413), clears `rt_llinfo` (:1414) and **`R_Free(ln)`
  (:1418) with no nd6_mtx anywhere**:
  - route add/delete per CPU table: `rtrequest1_global`
    (`sys/net/route.c:709-731`) visits every CPU's netisr
    (`netisr_forwardmsg_error(... mycpuid+1 ...)` :771) — the address
    cycler's SIOCA/SIOCD (`in6_control` dispatches to netisr0,
    `sys/netinet6/in6.c:445-456`; `in6_purgeaddr`/`in6_ifinit` →
    `rtinit`/`in6_ifloop_request` → global chain) frees one ln **per
    CPU** per delete;
  - traffic: `_rtlookup` RTM_RESOLVE (`route.c:261`) and `nd6_lookup`
    RTM_ADD (`nd6.c:909`) on any packet CPU.
- Interleaving: timer holds `next`/`ln`; a CPU_k stage (or traffic-CPU
  delete) unlinks + frees that exact ln between the timer's :467 load
  and :490 read; the cycler's immediate re-add reallocates the freed
  rt/ln chunks (adjacent-chunk and garbage `rt_llinfo` signatures).

## 3. Fix (fix.diff) and validation

Serialize + tombstone + defer-free (details in README):
1. `nd6_mtx` around the RTM_ADD link block and the RTM_DELETE
   unlink block of `nd6_rtrequest` — the same mutex the timer and ndp
   ioctl walkers hold.
2. RTM_DELETE tombstones (`ln_rt = NULL`, `ln_next` kept) and defers
   the `R_Free` onto `nd6_lnfreelist`; existing walkers already skip
   NULL-`ln_rt` entries (nd6.c:471-474; nd6_purge :848-849 checks
   `rt &&`), so a walker holding the entry as `next` skips it and its
   chain stays intact.
3. `nd6_timer_dispatch` drains the freelist after its walk, under
   `nd6_mtx` — the only context guaranteed not to be mid-walk.
4. Non-recursive-lock adjustments: timer drops `nd6_mtx` around the
   three `nd6_free()` expiry sites and around ia6-expiry
   `in6_purgeaddr()`/`regen_tmpaddr()`; `nd6_free()` hoists
   `rt6_flush()` out of its locked region (it re-enters routing).
5. Pre-existing rtadv-only hazards (timer-context `prelist_remove` /
   `defrtrlist_del` taking `nd6_mtx` internally at stock) are
   unchanged and out of scope — noted, not touched.

Validation: tri-fixed kernel (`#1 Mon Oct 26`, INVARIANTS, -Werror,
RC=0; DF-5846/DF-5848 fixes in disjoint files):
- identical full-rate mix (cycler vltime=0 + 6 flooder + stretch,
  600 s, ~1.8M cycles): no panic, guest healthy;
- `ndp -an` before/after: no llinfo leak (the drain works);
- stock baseline: the two historical panics; today's stock negative
  runs documented above. The fix removes the race *class*: no llinfo
  memory is freed while any `nd6_mtx`-holding walker can reference it,
  and frees occur only post-walk under the mutex.

## 4. Impact ceiling / privesc assessment

Trigger = root (ifconfig ioctls). Primitive = UAF read of freed
llinfo/rtentry fields feeding the nd6 state machine; observed ceiling
= panic. Theoretical grooming (recycled slab contents steering
`nd6_free` toward a recycled rtentry) is not demonstrated and would
still act inside the routing subsystem's own objects. **No path to
uid=0 shown; Low severity confirmed.**

## 5. Session log (all runs, budget, guest state)

See README table (S-A..S-D) + run logs. Guest reset to a clean-source
snapshot after validation.
