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)
PoC verification
Evidence pack
findings/poc/DF-3331 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 realip6_input -> icmp6_input -> nd6_ns_inputpath 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.crun.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_NOSTATEcase innd6_timer_dispatch()'s switch (reap terminal NOSTATE entries), (2) creation graceln_expire = time_uptime + nd6_delayin theRTM_RESOLVEpath, (3) re-enable the#if 0no-lladdr guard innd6_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 -mroutetbl: 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 β 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:
nd6_ns_input()(nd6_nbr.c:407) callsnd6_cache_lladdr(cmpifp, &saddr6, lladdr, lladdrlen, ...)unconditionally β withlladdr == NULLwhen the NS has no SLLA (thelladdrlenmismatch check at nd6_nbr.c:350 only fires whenlladdr != NULL).nd6_cache_lladdr()first triesnd6_lookup(from, 0, ifp); for an attacker-chosen fresh source this fails, so it callsnd6_lookup(from, 1, ifp)(nd6.c:1718) βrtlookup()finds the covering prefix route and clones it viartrequest(RTM_RESOLVE)βnd6_rtrequest()setsln_state = ND6_LLINFO_NOSTATE,ln_expire = time_uptime(nd6.c:1318-1319).- 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):
- Timer NOSTATE case (the actual fix):
nd6_timer_dispatch()'s switch gainscase ND6_LLINFO_NOSTATE: next = nd6_free(rt);β terminal NOSTATE entries are reaped when their grace expires. - Creation grace:
nd6_rtrequest()RTM_RESOLVE setsln_expire = time_uptime + nd6_delay(5 s) instead oftime_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). - RFC-conformance guard: the in-tree
#if 0block innd6_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_mtxacquisition (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_optionswalk (nd6.c:316-441): bounds-checked correctly β&nd_opt_len >= lastreject,olen==0reject,search > lastreject;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): DFlym_adj()(uipc_mbuf.c:1859) trims safely even whenlen > m_len(zeroes m_len walking the chain;pkthdr.len -= req_len - lencannot underflow). Correctness only.SIOCGNBRINFO_IN6uninit leak: every output field assigned (nd6.c:1655-1658).nd6_purge()NULL-deref onTAILQ_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) == lladdrlenwhen 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
rtentryreturns fromnd6_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
fixedfix.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
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::
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.
No comments yet.