# DF-3331 — nd6: NOSTATE neighbor-cache entries are never garbage-collected → unauthenticated on-link kernel memory exhaustion (DoS)

## What

`nd6_cache_lladdr()` (sys/netinet6/nd6.c) creates `ND6_LLINFO_NOSTATE`
neighbor-cache entries for **every ND packet whose source is not yet
cached and that carries no link-layer address option**, and *nothing ever
frees them*: `nd6_timer_dispatch()`'s state switch (nd6.c:496-572) has
cases for WAITDELETE/INCOMPLETE/REACHABLE/STALE/DELAY/PROBE — **no case
for NOSTATE(-2)**. Entries therefore survive every timer sweep forever
(until interface detach or reboot).

```c
nd6.c:407  (nd6_nbr.c:407) nd6_ns_input() -> nd6_cache_lladdr(cmpifp, &saddr6,
            lladdr /*NULL when NS has no SLLA*/, lladdrlen, ...)
nd6.c:1718   rt = nd6_lookup(from, 1, ifp);   /* create: rtlookup() clones the
            prefix route via RTM_RESOLVE */
nd6.c:1318   ln->ln_state = ND6_LLINFO_NOSTATE; ln->ln_expire = time_uptime;
nd6.c:1783   newstate = ND6_LLINFO_NOSTATE;    /* case (6): no lladdr */
nd6.c:496-572  switch (ln->ln_state) { ... }   /* NO NOSTATE case => leak */
```

Every entry pins an `rtentry` + radix nodes + `llinfo_nd6`
(`routetbl` allocations), measured at **~444 bytes per injected NS frame**.

## Threat model

- **Unauthenticated on-link attacker** (any host with L2 adjacency: same
  LAN, same VM bridge, compromised peer). NS/RS/RA processing requires no
  auth; the only constraints are hlim=255 and a correct ICMPv6 checksum,
  both trivially met by the attacker's own stack.
- Vectors: **NS without SLLA** (default hosts — the demonstrated vector),
  RS without SLLA (router-mode hosts, nd6_rtr.c:181), RA without SLLA
  (accept_rtadv hosts, nd6_rtr.c:403), redirect without TLLA
  (icmp6.c:2314). NA never creates entries (discards when absent,
  nd6_nbr.c:728).
- The attacker picks source addresses the victim will never contact, so
  the NOSTATE→INCOMPLETE transition (which *would* lead to cleanup) never
  happens: the entries are permanently immortal.
- Impact ceiling: **unbounded kernel memory exhaustion** — on the test
  guest the whole 390M kernel heap is ~900k frames ≈ **90 seconds at the
  measured injection rate** (110k pps through one userspace loop; wire
  rate is far higher). Result: kmem exhaustion → allocation failures /
  panic → remote denial of service.

## Files

- `nsimmort3.c` — injector (v3). Builds a gif(4) self-tunnel rig
  (`gif0 tunnel ::1 <-> ::1`) and sends attacker-crafted inner IPv6 NS
  packets as proto-41 payload to ::1: the kernel decapsulates them on
  gif0 (a production ND interface, `nd6_need_cache(IFT_GIF)=1`) and runs
  the real `ip6_input -> icmp6_input -> nd6_ns_input` path with a fully
  attacker-chosen source address — bit-identical to what an on-link peer
  sends a real victim. Root is needed only for the rig (raw socket + gif
  ioctl).
- `build.sh` — `cc -O2 -Wall -o nsimmort3 nsimmort3.c`
- `run.sh` — full baseline: 5,000 + 50,000 NS from distinct sources,
  persistence checks over 270s of timer sweeps, INCOMPLETE-reaped
  control, memory accounting.
- `fix.diff` — 3-layer fix: (1) `ND6_LLINFO_NOSTATE` case in
  `nd6_timer_dispatch()`'s switch (reap terminal NOSTATE entries),
  (2) creation grace `ln_expire = time_uptime + nd6_delay` in the
  `RTM_RESOLVE` path, (3) re-enable the `#if 0` no-lladdr guard in
  `nd6_cache_lladdr()` (RFC 4861 §7.2.3).
- `run.log` / `fix_run.log` / `fix_v2_run.log` — decisive outputs
  (fix_run.log records the guard-only fix-v1 failure that exposed the
  NA-reply/RESOLVE creation path).

## Reproduce

```
# in guest, as root:
sh build.sh
sh run.sh          # baseline: 55,003 immortal entries planted & persisting

# fix validation:
(cd /usr/src/sys/netinet6 && patch -p0 < /root/df-3331/fix.diff)
cd /usr/src && make nativekernel && make installkernel && reboot
cd /root/df-3331 && sh run_fix.sh
# -> entries return to baseline after grace+sweep; SLLA path still works
```

## Expected (baseline)

- `ndp -an | grep -c '%gif0'` == NS count sent (1:1, no failures, no cap)
- entries show `St = N` (NOSTATE), `Expire = expired` — a past expiry the
  timer never acts on — and survive **every** 1-second sweep indefinitely
- control: a dead-neighbor INCOMPLETE entry on vtnet0 is reaped in <30s
  by the *same* timer (proves the timer runs; it simply has no NOSTATE
  case)
- `vmstat -m` routetbl: 49.5K -> 24.4M for 55k entries (~444 B/entry)

## History / prior art

This is KAME-lineage code; the guard exists in-tree, disabled by `#if 0`
(nd6.c:1712-1716) — re-enabling it matches RFC 4861 §7.2.3 (nothing to
record without a source link-layer address).
