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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5847 Β· 12 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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_dispatch during 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; its ln_rt chain 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_llinfo read 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_dispatch takes nd6_mtx (sys/netinet6/nd6.c:461), walks the global list (:463), saves next = ln->ln_next (:467), derefs rt = ln->ln_rt (:471) and, for expired entries (local-address lns have ln_expire=0 from :1335 β†’ always eligible), reads rt->rt_llinfo (:490) β†’ panic (:491).
  • Mutators β€” nd6_rtrequest() (nd6.c:1170) links (:1322-1325), unlinks (:1411-1413), clears rt_llinfo (:1414) and R_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_control dispatches 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: _rtlookup RTM_RESOLVE (route.c:261) and nd6_lookup RTM_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 garbage rt_llinfo signatures).

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline = 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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #2: Mon Oct 26 06:04:19 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (tri-fixed: DF-5846+DF-5847+DF-5848)

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.