# DF-3376 VERDICT — reproduced (fatal panic 2/2, race isolated by controls; fix validated)

## Executive summary

**REPRODUCED.** An unauthenticated on-link attacker (with the victim
accepting RAs — `ND6_IFF_ACCEPT_RTADV`, `!ip6_forwarding`) drives the
kernel to a fatal panic within 2-5 minutes at ~1-2k packets/sec, 2 out of
2 full runs, with byte-identical panic signatures:

```
panic: rtrequest1_msghandler: rtrequest table req 1, failed on cpu1, error 55
cpuid = 1
Trace: rtrequest1_msghandler() -> netmsg_service_loop() -> Debugger("panic")
```

Root cause: `nd6_ra_input()` ends every accepted RA with
`pfxlist_onlink_check()` **without `nd6_mtx`** (nd6_rtr.c:410) while
`nd6_timer_dispatch()` (nd6.c:461, netisr CPU0, holding `nd6_mtx`) frees
the exact objects that walk touches (`kfree(dr)` nd6_rtr.c:576,
`kfree(pfxrtr)` nd6_rtr.c:739, `kfree(pr)` nd6_rtr.c:845). RA input runs
on any netisr CPU → true SMP race. The walk also **writes**
`pr->ndpr_stateflags |=/&= NDPRF_*` (nd6_rtr.c:1204-1209) — a UAF write —
and dereferences `pfxrtr->router->rtaddr/.ifp` (nd6_rtr.c:1146-1152) and
`pr->ndpr_next` (nd6_rtr.c:1181/1192/1233).

## Why the panic is this race (attribution, 2×2 controls)

| run | configuration | volume | result |
|---|---|---|---|
| 1 | 6×(churn+walk) full race | ~1.3k pps agg | **panic @ ~4 min** (18:26→18:31 window) |
| 2 | 6×(churn+walk) full race | ~1.3k pps agg | **panic @ ~2-3 min** (18:33:40→18:37) |
| A | 6× churn-only (`-W 0`) | 6.1M RAs / 10 min (~10k pps) | clean, uptime intact |
| B | 6× walk-only (`-C 0`) | 7.8M RAs / 8 min (~16k pps) | clean |

* churn-only runs the FREE side at ~5× the packet rate of the fatal runs
  (every churner RA creates a router expiring in 1s and a prefix expiring
  in 2s; the nd6_timer sweep kfree()s them every second, plus all default-
  route and prefix-route add/delete churn) — no crash.
* walk-only runs the lockless `pfxlist_onlink_check()` storm at ~10× the
  walk rate of the fatal runs, against a static list (nothing freed) — no
  crash.
* only the two sides TOGETHER (walker reads/writes racing the sweep's
  kfrees on other CPUs) kills the kernel, 2/2, same signature.

Proximate mechanism: the walker's stale `pr`/`pfxrtr`/`dr` dereferences
and its `ndpr_stateflags` writes land in freed-and-reused `M_IP6NDP`
chunks (the RA traffic itself re-allocates that zone constantly), corrupt
ND state, and cascade into the per-CPU route tables; the replication
invariant check in `rtrequest1_msghandler` (route.c:733-770) turns the
divergence (RTM_ADD ok on cpu0, ENOBUFS on cpu1) into a fatal panic.
(No kgdb on the guest; the microscopic corruption path is inferred from
the 2×2 controls + code, not single-stepped.)

## Exploitation ceiling (honest)

Demonstrated: remote-triggerable fatal panic (DoS) — reliable, minutes,
trivial bandwidth.

Write primitive available to an attacker: 2-bit set/clear at fixed offset
(`ndpr_stateflags`) inside a freed `M_IP6NDP` chunk + pointer-chasing
reads through freed `ndpr_next`/`pfr->router` + `LIST_REMOVE` writes
through corrupted list pointers on the free side. A full `uid=0` chain
would require deterministic grooming of the `M_IP6NDP` zone against a
6-CPU probabilistic race and steering the bit-write into a victim type
whose field at that offset is security-relevant; that is research-grade
work and is the documented blocker — not completed in this run. The race
window is attacker-amplifiable (unlimited attempts, both sides
attacker-driven: lifetimes set expiry timing, SLLA RAs set walk rate), so
the primitive should be treated as exploitable-in-principle.

## Sibling sites (same class, other files — for the follow-up pass)

* nd6_nbr.c:770 — `nd6_na_input()` → `pfxlist_onlink_check()` lockless.
* in6.c:1328 — `in6_unlink_ifa()` → `pfxr->ndpr_refcnt--` +
  `pfxlist_onlink_check()` lockless (SIOCDIFADDR path, process context).
* nd6.c:786-834 — `nd6_purge()` walks/frees `nd_defrouter`/`nd_prefix`
  without `nd6_mtx` (ifdetach path).
* nd6_rtr.c:809-830 — `prelist_remove()` prologue (vltime/pltime writes,
  `nd6_prefix_offlink`) runs before it takes the lock at :832.
The shipped fix.diff covers the reproduced sink (nd6_rtr.c:410); the
siblings need the same serialization pass.

## Fix validation

* Baseline (stock INVARIANTS kernel `X86_64_GENERIC #0`): panic 2/2 above.
* Patched (`fix.diff`: `mtx_lock(&nd6_mtx)` around ONLY the :410
  `pfxlist_onlink_check()` call — kernel `X86_64_GENERIC #1,
  19:14:38 UTC 2026`, nd6_rtr.c compiled clean with -Werror):
  **SAME fatal panic ~3.5 min into the identical 6-blaster hammer**
  (`fix_panic.txt`, byte-identical signature). fix_status = **fix_failed**.

  What this teaches (and why it matters for triage): serializing the one
  call site is NOT enough. The RA tail's remaining lockless machinery is
  still racing the sweep — most plausibly `nd6_cache_lladdr()` itself
  (nd6_rtr.c:403, lockless: shared `llinfo_nd6` state machine across CPUs,
  `ln_hold` release → `nd6_output`, and `nd6_lookup(create=1)` RTM_RESOLVE
  per-CPU route cloning interacting with the sweep's global route deletes
  through defrtrlist_del/prelist_remove/defrouter_select). Neither sub-load
  alone reproduces (controls A/B), so the corruption needs the concurrent
  free+walk mix that only the full RA tail produces. A correct fix must
  serialize the entire nd6_ra_input tail (cache_lladdr + onlink_check)
  under nd6_mtx and audit the nd6_lookup clone/sweep interplay; the
  single-call patch is preserved here as the falsified first attempt.

DF-0417 (dr-pointer window) was considered and EXCLUDED as the driver:
a non-NULL dr returned by defrtrlist_update has expire ≥ now+1s, so the
1-second nd6_timer sweep cannot free it during the same RA's microsecond
PIO loop; the PoC never sends lifetime-0 RAs from churner sources, so no
other CPU can delete the entry mid-RA either.

## Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest `make -j6 nativekernel KERNCONF=X86_64_GENERIC` completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
