# VERDICT — DF-3331 (sys/netinet6/nd6.c, pass 2 GLM 5.3)

**Status: reproduced (baseline), fixed (fix.diff validated on rebuilt kernel).**
**Impact: dos — unauthenticated on-link kernel memory exhaustion.**

## The bug

`nd6_timer_dispatch()` (sys/netinet6/nd6.c:463-574) walks every
`llinfo_nd6` entry once per `nd6_prune` (1 s) and switches on
`ln->ln_state`. The switch handles WAITDELETE, INCOMPLETE, REACHABLE,
STALE, DELAY, PROBE — there is **no case for ND6_LLINFO_NOSTATE (-2)**
(nd6.h:76). Entries in NOSTATE are skipped by every sweep regardless of
`ln_expire` — the `REACHABLE` case's `if (ln->ln_expire)` guard and the
`ln->ln_expire > time_uptime` early-continue are irrelevant because no
case body ever runs for -2.

NOSTATE entries are created by the *entry-creation* path of
`nd6_cache_lladdr()` whenever an ND packet arrives from a source that is
not yet in the neighbor cache and the packet carries **no link-layer
address option**:

1. `nd6_ns_input()` (nd6_nbr.c:407) calls
   `nd6_cache_lladdr(cmpifp, &saddr6, lladdr, lladdrlen, ...)`
   **unconditionally** — with `lladdr == NULL` when the NS has no SLLA
   (the `lladdrlen` mismatch check at nd6_nbr.c:350 only fires when
   `lladdr != NULL`).
2. `nd6_cache_lladdr()` first tries `nd6_lookup(from, 0, ifp)`; for an
   attacker-chosen fresh source this fails, so it calls
   `nd6_lookup(from, 1, ifp)` (nd6.c:1718) → `rtlookup()` finds the
   covering prefix route and **clones** it via `rtrequest(RTM_RESOLVE)`
   → `nd6_rtrequest()` sets `ln_state = ND6_LLINFO_NOSTATE`,
   `ln_expire = time_uptime` (nd6.c:1318-1319).
3. Back in `nd6_cache_lladdr()`, case (6) (nd6.c:1780-1786,
   `newstate = ND6_LLINFO_NOSTATE`, `do_update = 1`) leaves the state
   NOSTATE and sets no new expiry.

**Decisive detail discovered during fix validation (fix layer 1 alone
was insufficient):** the entry creation does not even require the
`nd6_cache_lladdr()` path — the kernel's own **NA reply** to the spoofed
source (`nd6_ns_input` → `nd6_na_output` → `ip6_output` → `rtlookup`
clone → `RTM_RESOLVE` → NOSTATE) creates the same immortal entry. On
interfaces whose output path never invokes `nd6_resolve()` — **gif(4),
a stock driver** (`nd6_output()` just calls `if_output`, nd6.c:1961-
1970; there is no L2 to resolve) — the NOSTATE state is *terminal*:
nothing ever transitions it, and no GC path exists. On ethernet the
reply's `nd6_resolve()` moves the entry to INCOMPLETE within the same
output call, so ether-only hosts reap these entries in seconds (proven
by the CONTROL A reaping); the immortal surface is the
non-ND-resolving output class, demonstrated on gif0.

Result: an entry whose expiry is already in the past ("expired" in
`ndp -a`) that **no code path ever frees** — not the timer, not anything
else (the entry only leaves NOSTATE if the *victim initiates* traffic to
the attacker's address via `nd6_resolve()`, nd6.c:2087-2100; the attacker
simply chooses addresses the victim will never originate traffic to).

Each entry pins an `rtentry`, radix nodes and the `llinfo_nd6`
(`R_Malloc`/M_RTABLE — `vmstat -m` type `routetbl`).

## Threat model

Unauthenticated attacker with the ability to deliver an IPv6 packet
**into** a gif-class (non-ND-resolving) interface of the victim:

- a host on the **inner network** of any site-to-site gif tunnel (the
  tunnel authenticates nothing per-host — one compromised box in the
  remote site qualifies);
- an **off-path spoofer of the tunnel peer's outer address** (gif has
  no authentication; inner packets injected this way are accepted
  verbatim — classic gif(4) exposure);
- equivalently, any peer of a gif point-to-point link.

The demonstrated vector is NS without SLLA (nd6_nbr.c:407, the reply's
RESOLVE creates the entry); RS/RA/redirect variants add router-mode /
accept_rtadv exposure. NS processing requires only hlim=255 and a
correct ICMPv6 checksum. The attacker picks source addresses the victim
will never originate traffic to, so the NOSTATE→INCOMPLETE transition
(which would lead to cleanup) never happens.

## Reproduction (stock INVARIANTS kernel #0, guest "dfbsd")

Injection rig: gif(4) self-tunnel `gif0` (outer `::1 <-> ::1`).
`nsimmort3` sends the attacker-crafted inner IPv6 packet as IPPROTO_IPV6
(41) payload on a raw socket to ::1; the kernel decapsulates on gif0 and
processes it as a **received** packet (`rcvif = gif0`,
`nd6_need_cache(IFT_GIF)=1` — the production ND path) with a fully
attacker-chosen source. Two prior rig attempts failed and were replaced:
(a) local source aliases + bound raw ICMPv6 sockets — invalid because
locally-owned sources grow own-address host routes that make
`nd6_lookup(from, 0)` succeed, skipping the vulnerable creation path
(and AF_INET6 raw sockets cannot spoof sources); (b) BPF write on
vtnet0 — DFly `ether_output()` always `M_PREPEND`s a fresh 14-byte
header and rebuilds it from `dst->sa_data`, so bpf writes emerged
malformed and never transmitted (Opkts stayed 0). The gif rig is exact.

Crafted inner packets (wire-realistic): NS, hlim 255, correct checksum,
`dst = ff02::1:ff70:9151` (solicited-node of gif0's link-local),
`target = fe80::8c70:4df9:fc70:9151`, source `fe80::<i>` — **no SLLA
option**. Source carries no embedded scope id (wire format; ip6_input
rejects non-zero embedded ids on non-loopback interfaces, ip6_input.c:
378-385, and re-embeds the receiving interface's index at :401, which
makes `nd6_is_addr_neighbor()` (nd6.c:993) accept it exactly as for a
real on-link fe80 sender).

### Observed (run.log)

| step | result |
|---|---|
| baseline | 7 entries |
| 5,000 NS from 5,000 sources (0.08 s) | **5,002** gif0 entries, 1:1, `St=N`, `Expire=expired` |
| 150 s of timer sweeps | 5,003 — unchanged |
| +50,000 NS (0.45 s ≈ 110k pps) | **55,003** entries |
| +120 s of timer sweeps | 55,003 — unchanged |
| control: dead-neighbor INCOMPLETE on vtnet0 | reaped in <30 s by the same timer |
| `vmstat -m` routetbl | 309 obj / 49.5 K → 161 K obj / **24.4 M** |

Per-entry cost ≈ **444 bytes**. kmem limit on the guest is 390M ⇒
~900 k frames to exhaust the whole kernel heap ≈ **~90 s** at the
measured single-loop rate (wire rate is ≥10× that). Failure mode of
kmem exhaustion: kernel allocation failures/panic ⇒ **remote DoS**.

This is a resource-exhaustion bug; there is no memory-corruption
primitive (the entries are correctly allocated objects that simply never
get freed), so a uid0 chain is not applicable.

## Fix validation

`fix.diff` (three layers, all in sys/netinet6/nd6.c):

1. **Timer NOSTATE case** (the actual fix): `nd6_timer_dispatch()`'s
   switch gains `case ND6_LLINFO_NOSTATE: next = nd6_free(rt);` —
   terminal NOSTATE entries are reaped when their grace expires.
2. **Creation grace**: `nd6_rtrequest()` RTM_RESOLVE sets
   `ln_expire = time_uptime + nd6_delay` (5 s) instead of
   `time_uptime`, so fresh clones have a wide safety margin (ether-path
   RESOLVE→INCOMPLETE transitions happen synchronously in the same
   output call, microseconds after creation — the 5 s grace makes the
   cross-CPU window a non-issue; gif-path entries are pure junk with no
   lladdr, so reaping them cannot affect gif traffic).
3. **RFC-conformance guard**: the in-tree `#if 0` block in
   `nd6_cache_lladdr()` is enabled — ND messages without a link-layer
   address option no longer create cache entries at all
   (RFC 4861 §7.2.3).

An earlier fix-v1 (layer 3 alone) was built and **failed** validation —
5,000 entries still planted — which is what exposed the NA-reply/RESOLVE
creation path above (see fix_run.log, kernel #1); fix-v2 adds layers 1+2.
Applied to /usr/src in-guest, `make nativekernel` + `make installkernel`
+ reboot into the patched kernel, then re-run of the identical PoC
(see fix_v2_run.log, kernel #2): 5,000 + 20,000 NS from distinct sources
→ entries return to baseline (1 own-LL) within the grace+sweep window,
NS-with-SLLA still creates entries, `ping6 ::1` works, and the
ether-path INCOMPLETE lifecycle (functionality) is preserved.

## What was checked and killed (pass-2 negative results)

- Recursive `nd6_mtx` acquisition (nd6_timer_dispatch:461 → nd6_free:
  1042 → defrouter_select nd6_rtr.c:596; SIOC*FLUSH → prelist_remove
  nd6_rtr.c:832): **not a bug** — DFly mtx is recursive by design
  ("Recursion is allowed", sys/mutex.h:47-48).
- `nd6_option`/`nd6_options` walk (nd6.c:316-441): bounds-checked
  correctly — `&nd_opt_len >= last` reject, `olen==0` reject,
  `search > last` reject; `nd_opt_array[9]` only indexed with types 1-5
  (switch-restricted). Sound.
- `m_adj(m, ETHER_HDR_LEN)` on ln_hold in the INCOMPLETE case (nd6.c:
  519): DFly `m_adj()` (uipc_mbuf.c:1859) trims safely even when
  `len > m_len` (zeroes m_len walking the chain; `pkthdr.len -=
  req_len - len` cannot underflow). Correctness only.
- `SIOCGNBRINFO_IN6` uninit leak: every output field assigned
  (nd6.c:1655-1658).
- `nd6_purge()` NULL-deref on `TAILQ_FIRST` (nd6.c:803-805): the loop
  never deletes the first entry; not reachable.
- DF-0357 residual (SLLA/TLLA length vs `ifp->if_addrlen`): all three
  callers validate `((if_addrlen + 2 + 7) & ~7) == lladdrlen` when the
  option is present (nd6_nbr.c:350, 676; nd6_rtr.c:174, 391) — the
  ether read is exact; only >6-byte-addrlen links (FireWire) could read
  2 bytes past a minimal option, and DFly drivers for those are legacy.
- Unheld `rtentry` returns from `nd6_lookup()` / `nd6_neighbor_lookup()`
  and the SIOCGNBRINFO-under-mtx-but-deleter-without-mtx pattern:
  DF-0355/DF-0356 family (missing-serialization on the llinfo/route
  lifetime), not re-reported.

## 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.
