β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3331

nd6: ND6_LLINFO_NOSTATE neighbor-cache entries are never garbage-collected β€” unauthenticated peer pins unbounded kernel memory (~444 B per ND packet) on gif-class outputs

Summary

nd6_timer_dispatch()'s state switch handles WAITDELETE/INCOMPLETE/REACHABLE/STALE/DELAY/PROBE but has NO case for ND6_LLINFO_NOSTATE(-2), so NOSTATE llinfo_nd6 entries are skipped by every GC sweep forever. They are created by route cloning (RTM_RESOLVE) for any not-yet-cached source, and on interfaces whose output path never calls nd6_resolve() - gif(4), a stock X86_64_GENERIC driver; nd6_output() just calls if_output with no L2 to resolve - the state is terminal: nothing transitions it and nothing frees it. An unauthenticated peer that can deliver packets into the victim's gif interface (inner-network host of a site tunnel, or an outer-address spoofer, since gif authenticates nothing) sends NS without a source link-layer-address option from N distinct spoofed sources; the kernel's NA reply alone clones one immortal rtentry+radix+llinfo allocation per source. RS/RA/redirect variants add router/accept_rtadv exposure. Ether-only hosts self-heal (ether_resolve transitions NOSTATE->INCOMPLETE synchronously - proven by live control), so the immortal surface is precisely the non-ND-resolving output class. VERIFIED on stock INVARIANTS guest: gif0 self-tunnel + attacker-crafted inner NS via IPPROTO_IPV6 to ::1 (production ip6_input->nd6_ns_input path); 5,000 NS -> 5,002 entries surviving 150s of 1Hz sweeps; +50,000 NS in 0.45s -> 55,003 entries surviving 120 further seconds; INCOMPLETE control reaped in 11s; routetbl 49.5K -> 24.4M. Line-rate ~110k pps planting ~444-byte allocations each: the 390M kernel heap exhaustible in ~90s -> allocation failures/panic -> remote DoS. No memory-corruption primitive. Fix validated across two in-guest rebuilds (3 layers: NOSTATE case in the timer switch + creation grace at RTM_RESOLVE + enable the #if 0 no-lladdr guard per RFC 4861 7.2.3): identical attack -> count returns to baseline after grace+sweep, NS-with-SLLA still creates STALE entries, ping6 unaffected.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3331 Β· 18 files
FileTypeDescriptionSize
nsimmort3.c β€” 6.3 KB view raw
nsimmort2.c β€” 6.6 KB view raw
nsimmort.c β€” 5.6 KB view raw
build.sh β€” 135 B view raw
run.sh β€” 265 B view raw
run_fix.sh β€” 1.7 KB view raw
build.log β€” 9 B view raw
run.log β€” 3.5 KB view raw
fix_run.log β€” 2.2 KB view raw
fix_v2_run.log β€” 2.5 KB view raw
fix_build.notes.txt β€” 1.6 KB view raw
vmstat.before.txt β€” 9.4 KB view raw
vmstat.after.txt β€” 9.4 KB view raw
env.txt β€” 332 B view raw
fix.diff β€” 1.8 KB view raw
code_hashes.txt β€” 195 B view raw
VERDICT.md β€” 9.8 KB ↓ raw
README.md β€” 4.6 KB ↓ raw

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

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

VERDICT.md
↓ download raw

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_PREPENDs 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; SIOCFLUSH β†’ 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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to /usr/src in-guest, make nativekernel + installkernel, reboot into #2. Identical PoC (5,000 then 20,000 no-SLLA NS from distinct sources): entries appear transiently (5,001 at +3 s -- NA-reply clones under the grace) and return to baseline (1, gif0's own link-local) after grace(5 s)+sweep; netstat routes return to 15. Functionality preserved: NS-with-SLLA creates STALE entries with 23h59m58s nd6_gctimer expiry, ping6 ::1 rc=0, ping6 vtnet0-LL rc=0, INCOMPLETE lifecycle intact (control entry reaped t+11 s). A guard-only fix-v1 (kernel #1) had failed (5,001 entries persisted), proving the RESOLVE-path creation and driving the 3-layer fix; both validation runs logged.

fix_v2_run.log, fix_run.log, fix.diff, fix_build.notes.txt, VERDICT.md fix-validation section
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #2: Thu Sep 10 23:55:24 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

attacker with inner-tunnel or spoofed-outer delivery into victim's gif interface -> NS (hlim=255, valid checksum, no SLLA option) from fe80:: distinct sources -> nd6_ns_input (nd6_nbr.c:407) -> nd6_na_output reply -> ip6_output -> rtlookup clones prefix route -> nd6_rtrequest(RTM_RESOLVE) creates llinfo_nd6 with state=NOSTATE, expire=creation time (nd6.c:1318-1319) -> gif output never calls nd6_resolve() so the state is terminal -> nd6_timer_dispatch switch has no NOSTATE case -> entry (rtentry + radix nodes + llinfo, ~444 B) immortal -> repeat at line rate -> kernel heap (kmem) exhaustion -> allocation failures / panic -> unauthenticated remote denial of service.

Evidence (decisive lines)

["run.log: '5000 NS -> 5002 gif0 entries' then '+50000 -> 55003', all rows 'St=N ... expired', counts unchanged after 150s and 120s of 1 Hz timer sweeps", 'run.log memory section + vmstat.before.txt/vmstat.after.txt: routetbl 309 obj/49.5K -> 161K obj/24.4M (requests 590K -> 2.75M)', 'run.log rate: 50000 frames / 0.45 s (~110k pps single userspace loop)', 'fix_v2_run.log: patched kernel #2, identical 5000-NS attack -> 5001 transient at +3s -> 1 (baseline) after grace+sweep; 20000 more -> 1; SLLA entries St=S with 23h59m58s expiry; ping6 ::1 OK', 'fix_v2_run.log tail: valid INCOMPLETE control on vtnet0 (St=I, Prbs 1->2) REAPED at t+11s by the same timer', 'fix_run.log: kernel #1 (guard-only fix) failure -- 5001 entries still planted -- mechanism discovery for the NA-reply/RESOLVE path', 'fix.diff: 3 hunks (timer NOSTATE case at switch; RESOLVE grace time_uptime+nd6_delay; enabled #if 0 guard)', 'VERDICT.md: full narrative incl. why ether-only hosts are self-healing (nd6_resolve transitions NOSTATE synchronously) and why gif is terminal']

PoC changes

seed had to be replaced wholesale; three injection rigs were built and two rejected with root causes: (1) local source aliases + bound raw ICMPv6 sockets -- invalid because AF_INET6 raw sockets cannot spoof IPv6 sources and locally-owned sources grow own-address host routes that make nd6_cache_lladdr's first nd6_lookup(from,0) succeed, skipping the vulnerable creation path (a real attacker owns nothing locally); (2) BPF write on vtnet0 (BIOCSFEEDBACK) -- DFly ether_output() always M_PREPENDs a fresh 14-byte header and rebuilds it from dst->sa_data, so bpf writes emerged malformed and never transmitted (Opkts stayed 0); (3) final rig: gif(4) self-tunnel (outer ::1<->::1 via SIOCSIFPHYADDR_IN6 ioctl) carrying the attacker-crafted inner IPv6 packet as IPPROTO_IPV6(41) payload to ::1 -- kernel decapsulates on gif0 (stock ND interface) and runs the production ip6_input->icmp6->nd6_ns_input path with a fully attacker-chosen source. Two more corrections from observed drops: inner link-local source must carry NO embedded scope id (ip6_input.c:378 rejects nonzero on non-loopback ifaces, then re-embeds rcvif index at :401) and struct in6_aliasreq on DFly has no lifetime members. PoC script bug fixed during validation: earlier runs used 'ping6 -t1', an illegal DFly flag, which made the INCOMPLETE control vacuous -- replaced with a valid live control (entry St=I observed, reaped t+11s).

Verified recommended fix

nd6: add ND6_LLINFO_NOSTATE case to nd6_timer_dispatch (nd6_free after creation grace), give RTM_RESOLVE NOSTATE entries ln_expire = time_uptime + nd6_delay, and enable the disabled #if 0 no-lladdr guard in nd6_cache_lladdr (RFC 4861 7.2.3)

Verdict

nd6_timer_dispatch()'s state switch (sys/netinet6/nd6.c:496-572) has no case for ND6_LLINFO_NOSTATE(-2), so NOSTATE neighbor-cache entries are skipped by every garbage-collection sweep forever. They are created by route cloning (RTM_RESOLVE, nd6.c:1318-1319) whenever the kernel sends or processes ND for a not-yet-cached source on interfaces whose output path never calls nd6_resolve() (gif(4) demonstrated: nd6_output() just calls if_output, nd6.c:1961-1970; there is no L2 to resolve, so NOSTATE is terminal). Demonstrated vector: unauthenticated peer injects NS without a source link-layer-address option from N distinct spoofed sources into the victim's gif interface (inner-network host of a site tunnel, or outer-address spoofer -- gif authenticates nothing); the kernel's NA reply alone clones one NOSTATE entry per source (nd6_ns_input nd6_nbr.c:407 -> nd6_na_output -> ip6_output -> rtlookup -> RTM_RESOLVE). On the stock INVARIANTS guest, 55,000 crafted NS frames (110k pps, 0.45 s for the 50k burst) planted 55,003 entries (ndp St=N, Expire=expired) that survived 270+ seconds of 1 Hz timer sweeps unchanged (vmstat -m routetbl 49.5K -> 24.4M, ~444 bytes/entry); the same timer reaped an INCOMPLETE control entry in 11 s, proving it runs and simply has no NOSTATE case. Unbounded, line-rate, no per-entry cap: the 390M kernel heap is exhaustible in ~90 s at the measured rate -> allocation failure/panic DoS. Resource-exhaustion only; no memory-corruption primitive, so uid0 escalation is not applicable. fix.diff (3 layers: timer NOSTATE case, RTM_RESOLVE creation grace of nd6_delay, re-enabled #if 0 cache_lladdr no-lladdr guard) was validated by in-guest nativekernel rebuild: kernel #2 returns to baseline (1 entry) after the identical 5k+20k attack, NS-with-SLLA still creates STALE entries with 24h expiry, ping6 ::1 and vtnet0 link-local both work, and the INCOMPLETE lifecycle is preserved. An earlier one-layer fix (guard only) was built as kernel #1 and FAILED validation (5,001 entries still planted) -- that failure is what exposed the NA-reply/RESOLVE creation path and is logged in fix_run.log.