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)
PoC verification
Evidence pack
findings/poc/DF-3376 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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_RTADVon 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 disciplineprelist_updatealready 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).
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/freesnd_defrouter/nd_prefixwithoutnd6_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 :410pfxlist_onlink_check()call β kernelX86_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_failedfix_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"]
Confirmed kernel references
- sys/netinet6/nd6_rtr.c:410
- sys/netinet6/nd6_rtr.c:403
- sys/netinet6/nd6_rtr.c:1146
- sys/netinet6/nd6_rtr.c:1181
- sys/netinet6/nd6_rtr.c:1204
- sys/netinet6/nd6_rtr.c:576
- sys/netinet6/nd6_rtr.c:739
- sys/netinet6/nd6_rtr.c:845
- sys/netinet6/nd6_rtr.c:847
- sys/netinet6/nd6.c:461
- sys/netinet6/nd6.c:577
- sys/netinet6/nd6.c:670
- sys/netinet6/nd6_nbr.c:770
- sys/netinet6/in6.c:1328
- sys/net/route.c:765
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).
No comments yet.