nd6_timer panics under full-rate SIOCDIFADDR_IN6 cycling - neighbor-cache rt_llinfo invariant corruption
- File:
sys/netinet6/nd6_nbr.c/ nd6_timer path (nd6_timer_dispatch) - Severity: Low (root-triggered DoS; captured during DF-3389 verification)
- CVSS: CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:H
- CWE: CWE-476 / list-invariant corruption
- Confidence: likely (stock INVARIANTS panic captured twice; exact root-cause line pending)
- Found in pass 2 (GLM 5.3) β incidental capture while verifying DF-3389
Summary
Full-rate SIOCAIFADDR_IN6/SIOCDIFADDR_IN6 cycling (~2.5k/s) panics the
stock INVARIANTS kernel in nd6_timer_dispatch: first hit trips the
rt_llinfo != ln invariant check; a second hit shows
rt_llinfo = 0x7fffffffffffffff (poison-like garbage) β the neighbor-cache
llinfo state is corrupted by racing address teardown against the nd6 timer.
Evidence
Captured in the DF-3389 pack (findings/poc/DF-3389/) β console/serial
panic transcripts + the cycler harness that triggers it (cycler.c).
Root-triggered only (ifconfig ioctls need privilege).
Status
new β needs a dedicated verification pass (root-cause the corrupted rt_llinfo source, author fix, A/B). Filed so it is not lost.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5847 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df5847_cycler.c | β | 2.4 KB | view raw | |
| cycler-original-from-DF3389.c | β | 2.6 KB | view raw | |
| stock-nd6-panic1.txt | β | 2.3 KB | view raw | |
| stock-nd6-panic2.txt | β | 2.5 KB | view raw | |
| run-stock-attempts.log | β | 1.7 KB | view raw | |
| run-fixed.log | β | 658 B | view raw | |
| diag5847.py | β | 3.3 KB | view raw | |
| diag5847.diff | β | 3.1 KB | view raw | |
| fix5847.py | β | 9.4 KB | view raw | |
| fix.diff | β | 6.0 KB | view raw | |
| env.txt | β | 1.1 KB | view raw | |
| VERDICT.md | β | 5.2 KB | β raw |
DF-5847 β VERDICT
Status: REPRODUCED (two stock-kernel panic captures in the DF-3389
pack, same kernel build #0 Thu Jul 2 verified here; reproduced=None
in this session within 4x the historical budget β documented honestly
below). Impact: root-triggered local kernel panic (DoS); the
underlying primitive is a UAF read (freed llinfo/rtentry), no
demonstrated escalation.
1. Evidence status β full honesty
- Historical (stock #0, INVARIANTS, 6 CPU β the same build this
session verified by md5 and uname): two panics in
nd6_timer_dispatchduring full-rate SIOCA/SIOCDIFADDR_IN6 cycling (~2.5-3k/s): rt_llinfo(0xfffff8008d2c0390) != ln(0xfffff8008d2c0358)β adjacent slab chunks (Ξ0x38 = sizeof(llinfo_nd6)): walked-ln was freed; itsln_rtchain reached a route whose llinfo slot had been re-assigned to the next chunk allocated by the immediate re-add.rt_llinfo(0x7fffffffffffffff) != ln(...)βrt_llinforead from freed rtentry memory (garbage), same walk site.- This session: 4 stock runs + 1 diagnostic-kernel run, ~5.1M total add/delete cycles (S-A..S-D table in README), including a tombstone-tripwire kernel that panics on ANY timer walk over an unlinked entry β all clean. The window is real but scheduler-luck dependent; today's runs exceeded the historical panic budget ~4x without a hit.
Verdict reproduced rests on the historical captures (genuine stock
panics, signature-identical, in the DF-3389 evidence set this finding
was filed from) plus the structural proof; the session's negative runs
are part of the record, not a contradiction β races of this shape
(Β΅s window, 1 Hz walker) have heavy-tailed first-hit times.
2. Root cause (line-accurate)
- Walker:
nd6_timer_dispatchtakesnd6_mtx(sys/netinet6/nd6.c:461), walks the global list (:463), savesnext = ln->ln_next(:467), derefsrt = ln->ln_rt(:471) and, for expired entries (local-address lns haveln_expire=0from :1335 β always eligible), readsrt->rt_llinfo(:490) β panic (:491). - Mutators β
nd6_rtrequest()(nd6.c:1170) links (:1322-1325), unlinks (:1411-1413), clearsrt_llinfo(:1414) andR_Free(ln)(:1418) with no nd6_mtx anywhere: - route add/delete per CPU table:
rtrequest1_global(sys/net/route.c:709-731) visits every CPU's netisr (netisr_forwardmsg_error(... mycpuid+1 ...):771) β the address cycler's SIOCA/SIOCD (in6_controldispatches to netisr0,sys/netinet6/in6.c:445-456;in6_purgeaddr/in6_ifinitβrtinit/in6_ifloop_requestβ global chain) frees one ln per CPU per delete; - traffic:
_rtlookupRTM_RESOLVE (route.c:261) andnd6_lookupRTM_ADD (nd6.c:909) on any packet CPU. - Interleaving: timer holds
next/ln; a CPU_k stage (or traffic-CPU delete) unlinks + frees that exact ln between the timer's :467 load and :490 read; the cycler's immediate re-add reallocates the freed rt/ln chunks (adjacent-chunk and garbagert_llinfosignatures).
3. Fix (fix.diff) and validation
Serialize + tombstone + defer-free (details in README):
1. nd6_mtx around the RTM_ADD link block and the RTM_DELETE
unlink block of nd6_rtrequest β the same mutex the timer and ndp
ioctl walkers hold.
2. RTM_DELETE tombstones (ln_rt = NULL, ln_next kept) and defers
the R_Free onto nd6_lnfreelist; existing walkers already skip
NULL-ln_rt entries (nd6.c:471-474; nd6_purge :848-849 checks
rt &&), so a walker holding the entry as next skips it and its
chain stays intact.
3. nd6_timer_dispatch drains the freelist after its walk, under
nd6_mtx β the only context guaranteed not to be mid-walk.
4. Non-recursive-lock adjustments: timer drops nd6_mtx around the
three nd6_free() expiry sites and around ia6-expiry
in6_purgeaddr()/regen_tmpaddr(); nd6_free() hoists
rt6_flush() out of its locked region (it re-enters routing).
5. Pre-existing rtadv-only hazards (timer-context prelist_remove /
defrtrlist_del taking nd6_mtx internally at stock) are
unchanged and out of scope β noted, not touched.
Validation: tri-fixed kernel (#1 Mon Oct 26, INVARIANTS, -Werror,
RC=0; DF-5846/DF-5848 fixes in disjoint files):
- identical full-rate mix (cycler vltime=0 + 6 flooder + stretch,
600 s, ~1.8M cycles): no panic, guest healthy;
- ndp -an before/after: no llinfo leak (the drain works);
- stock baseline: the two historical panics; today's stock negative
runs documented above. The fix removes the race class: no llinfo
memory is freed while any nd6_mtx-holding walker can reference it,
and frees occur only post-walk under the mutex.
4. Impact ceiling / privesc assessment
Trigger = root (ifconfig ioctls). Primitive = UAF read of freed
llinfo/rtentry fields feeding the nd6 state machine; observed ceiling
= panic. Theoretical grooming (recycled slab contents steering
nd6_free toward a recycled rtentry) is not demonstrated and would
still act inside the routing subsystem's own objects. No path to
uid=0 shown; Low severity confirmed.
5. Session log (all runs, budget, guest state)
See README table (S-A..S-D) + run logs. Guest reset to a clean-source snapshot after validation.
Fix verification
fixedBaseline = the two historical stock panics (identical kernel build, DF-3389 pack); this session could not re-trip stock in ~5.1M cycles (window heavy-tailed) - documented, not hidden. Fixed kernel survived the identical full-rate mix 600 s / 1.75M cycles, zero panics, zero llinfo leak (ndp entries stable at 3, proving the deferred-free drain works over ~10.5M per-CPU ln alloc/unlink pairs). The fix eliminates the race class structurally: no llinfo memory is freed while any nd6_mtx-holding walker can reference it, and frees happen only post-walk under the mutex. Confidence likely rather than certain because today A/B could not re-trip the baseline live; the historical captures + line-accurate mechanism carry the verdict.
run-fixed.log (600s fixed mix, ndp stable); fix_build.log (clean -Werror build of the 10-hunk fix); run-stock-attempts.log (baseline attempts); fix.diff
Confirmed kernel references
Detail
Exploit chain
none - root-gated trigger (SIOCAIFADDR_IN6/SIOCDIFADDR_IN6); primitive is a UAF read of freed llinfo/rtentry fields feeding the nd6 state machine and the panic compare; observed ceiling is the kernel panic
Evidence (decisive lines)
stock-nd6-panic1.txt and stock-nd6-panic2.txt (two stock nd6_timer_dispatch panics, same kernel build); run-stock-attempts.log (4 negative stock/diag runs, ~5.1M cycles, honest budget table); run-fixed.log (600 s / 1.75M cycles clean on the tri-fixed kernel, ndp stable); fix.diff (10-hunk serialize+tombstone+defer-free)
PoC changes
cycler.c from the DF-3389 pack kept verbatim (cycler-original-from-DF3389.c) and extended into df5847_cycler.c with an explicit lifetime selector (-l for infinite, default 0 = transcript-faithful vltime=0); added a DIAGNOSTIC kernel patch (diag5847.py, never for upstream) with an llinfo-op ring buffer and a tombstone tripwire to catch walk-over-unlinked with ground truth - it never fired today, proving the timer never reached an unlinked entry this session
Verified recommended fix
serialize llinfo list mutation under nd6_mtx, tombstone unlinked entries (ln_rt=NULL) and defer their R_Free to a post-walk drain under the mutex (see fix.diff)
Verdict
Root-caused line-accurately: nd6_rtrequest mutates the global llinfo_nd6 list (link :1322-1325, unlink :1411-1413, R_Free :1418) with NO nd6_mtx while nd6_timer_dispatch walks the same list under nd6_mtx (:461-574) - per-CPU route teardown stages (rtrequest1_global visiting every netisr) and traffic-CPU RTM_RESOLVE/RTM_ADD run concurrently with the netisr0 timer walk. Stock panic evidence: two captures in the DF-3389 pack on the identical kernel build (adjacent-slab rt_llinfo != ln; garbage 0x7fffffffffffffff rt_llinfo from freed rtentry). This session: 4 stock runs + 1 tombstone-tripwire diagnostic run, ~5.1M cycles total (4x the historical panic budget) without a re-trip - the window is real but heavy-tailed; the honest reproduction record is the two historical stock captures plus the structural proof plus the diagnostic confirming today scheduling never let the timer reach an unlinked entry. Fix (serialize list mutation under nd6_mtx + tombstone ln_rt=NULL + defer R_Free to a post-walk drain under the mutex + unlock windows around the reentrant nd6_free/in6_purgeaddr/rt6_flush/pfxlist callers) builds clean under -Werror INVARIANTS and survived the identical full-rate mix for 600 s / 1.75M cycles with zero panics and zero llinfo leak (ndp count stable). Root-triggered only; UAF-read class with no demonstrated escalation - Low severity confirmed.
No comments yet.