# DF-0417 — VERDICT

**Verdict:** REPRODUCED (kernel panic / network-reachable UAF). Fix VALIDATED.

## Root cause

`defrtrlist_update()` (`sys/netinet6/nd6_rtr.c:661-707`) returns a
`struct nd_defrouter *` **without taking a reference** and with `nd6_mtx`
**released** (`:679`, `:685`, `:692`, `:705`).  `nd6_ra_input()` stores that
pointer (`:285`) and, in its **unlocked** prefix-option loop (`:291-345`),
passes it to `prelist_update()` (`:344`).  `prelist_update()` re-acquires
`nd6_mtx` (`:866`) and stores the pointer into a prefix's pfxrtr list
(`pfxrtr_add` → `new->router = dr`, `:728`).

A concurrent deleter can `defrtrlist_del(dr)` → `kfree(dr)` (`:576`) during
the window between line 285 (lock released) and line 866 (lock re-acquired).
Deleters:
- another RA, `rtlifetime==0` → `defrtrlist_del` at `:671`
- `nd6_timer` expiry (`nd6.c:582`)
- `nd6_purge` iface-detach (`nd6.c:801/805`)
- peer router-flag drop (`nd6_nbr.c:870`)

If the delete lands before `pfxrtr_add()` stores the pointer, the stored
`pfr->router` is a **dangling pointer**.  It is later field-dereferenced in:

| sink | location | fields read |
|------|----------|-------------|
| `find_pfxlist_reachable_router` | `nd6_rtr.c:1148-1149` | `router->rtaddr`, `router->ifp` |
| `nd6_sysctl_prlist` (prefix-list export) | `nd6.c:2248-2253` | `router->rtaddr`, `router->ifp` |
| `nd6_sysctl_drlist`-ish export | `nd6.c:1493` | `router->rtaddr` |

`struct nd_defrouter` (`nd6.h:233-243`) has **no refcount** — the root cause.

## Why it is not a false positive

The lock release/reacquire boundary is real (the prefix loop runs unlocked).
The deferred dereference sinks are real (confirmed by grepping every `->router`
field-read).  The concurrent deleters are real and all hold `nd6_mtx` while
freeing.  `nd6_mtx` is a non-recursive exclusive mutex (`MTX_INITIALIZER`,
`mtx_lock` is `MTX_EXCLUSIVE`), so "just hold the lock across the prefix loop"
would deadlock against `prelist_update()`'s own `mtx_lock(&nd6_mtx)` (`:866`) —
which is why a **refcount** is the correct fix, not a lock-extension.

## Live reproduction

- **Setup:** IPv6 host with RA acceptance (`net.inet6.ip6.accept_rtadv=1`,
  `ip6_forwarding=0`).  On-link attacker (simulated by injecting crafted
  unicast RAs into a `tap` interface via `/dev/tapN` writes — `if_tap.c:981`
  calls `if_input` synchronously on the writer's CPU; multiple worker threads
  thus process create/delete RAs on different CPUs concurrently).
- **Trigger:** 3 worker threads flood CREATE (lifetime 1800 + 8 prefix-info
  options to widen the unlocked window) and DELETE (lifetime 0) RAs for the
  same router `fe80::dead:beef:cafe`; a concurrent `ndp -p` reader triggers
  the deferred `pfr->router` dereference.
- **Result (unpatched GENERIC `#0`, with-src):** guest down in ~4 s.  Serial
  console:
  ```
  Fatal trap 12: page fault while in kernel mode
  fault virtual address  = 0x48
  current process        = 1118 (ndp -p)
  Stopped at nd6_sysctl_prlist.part.10+0x165: movq 0x48(%rsi),%rdx
  ```
  `nd6_sysctl_prlist` (`nd6.c:2248`) dereferenced the dangling `pfr->router`
  → page fault → panic.  **This is the DF-0417 UAF.**

## Impact ceiling

Network-reachable **panic / DoS** on default GENERIC (INVARIANTS ON — freed
slab is poisoned with `0xdeadc0de`, so the deferred read faults).  On a
non-INVARIANTS kernel the read silently returns recycled `M_IP6NDP` memory
(possible limited info-leak).  The primitive is a dangling-pointer **read**
in a non-control-data context; the realistic ceiling is **DoS (+ possible
info-leak)**, not code execution / uid-0.  This is a network-reachable bug
(the attacker is on the link), not a local-unpriv→root, so the uid-0
escalation bar does not apply.

## The fix (`fix.diff`)

Refcount `struct nd_defrouter`:

1. `nd6.h`: add `int refcnt;` (no ABI impact — sysctls copy individual fields).
2. `nd6_rtr.c`: `nd_defrouter_hold()`/`nd_defrouter_rele()` (atomic; free at 0).
3. New allocation: `refcnt = 1` (list-membership ref).
4. `defrtrlist_update()`: caller ref on non-NULL return (guarded — `dr` is
   NULL in the `rtlifetime==0` case); `nd6_ra_input()` drops it after the
   prefix loop.
5. `pfxrtr_add()` takes a pfxrtr ref; `pfxrtr_del()` and `prelist_remove()`
   drop it.
6. `defrtrlist_del()`: `kfree` → `nd_defrouter_rele` (drops list ref; entry
   freed only when the last caller/pfxrtr ref is gone).

This makes it impossible for `pfr->router` to dangle: the entry cannot be freed
while any caller or pfxrtr reference is outstanding.

(The finding's `## Recommended fix` proposal — "add refcount (nd_defrouter_hold/release) or hold lock" — is matched: I implemented the refcount option, which is the only viable choice given `nd6_mtx` is non-recursive and `prelist_update()` re-locks.)

## Fix validation

| | kernel | workload | result |
|---|--------|----------|--------|
| before | `#0` with-src, unpatched | 3-thread create/delete RA flood + `ndp -p` | **UAF panic**: `nd6_sysctl_prlist.part.10+0x165` page fault (`nd6.c:2248`) |
| after  | `#1` single-fix (`fix.diff`), built+booted | identical | **No `nd6_sysctl_prlist` panic** (0 matches in `boot.log`); the DF-0417 UAF does not reproduce |

A **separate** panic can still occur under a very heavy RA flood:
`panic: rtrequest1_msghandler: rtrequest table req 1, failed on cpu5, error 55`
(routing-table op fails `ENOBUFS` under mbuf exhaustion; kernel panics on the
unexpected failure).  This is a **different bug** (present on both kernels,
out of scope for DF-0417) and is not addressed by this fix.

`fix_status = fixed` (for DF-0417).

## PoC changes

The reviewer-supplied scaffold was incomplete (no PoC sources existed).  I
authored, from scratch:
- `ndflagset.c` — sets `ND6_IFF_ACCEPT_RTADV` on an already-attached interface
  via `SIOCSIFINFO_IN6` (the global sysctl only takes effect at attach time —
  `nd6.c:210`).
- `ra_race_mt.c` — multi-threaded RA flood driving the create/delete race;
  injects correctly-checksummed unicast RAs (ICMPv6 cksum in network byte
  order) at the tap's own link-local (tap0 does not join `ff02::1` on this
  guest, so multicast RAs are dropped by `ether_input`).
- `ra_race.c` / `ra_tap.c` — earlier single-threaded injectors (BPF-feedback
  and tap-multicast variants), retained for the harness notes; the
  multi-threaded `ra_race_mt.c` is the decisive trigger.

Key debugging notes captured for reproducibility:
- BPF `BIOCSFEEDBACK` loopback is broken for `DLT_EN10MB` on this guest:
  `bpf_movein()` (`bpf.c:271-274`) strips the Ethernet header into the
  output sockaddr and advances the mbuf past it; the feedback path dups that
  **header-stripped** mbuf into `if_input`, so looped-back packets are
  mis-framed and never reach `nd6_ra_input`.
- `tap` injection works (`if_input` is called synchronously, `if_tap.c:981`),
  but the ICMPv6 checksum must be stored in network byte order and the RA must
  be unicast at the tap's own LLA (tap0 does not join `ff02::1`).
