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

Use-after-free race on nd_defrouter: defrtrlist_update returns unreferenced pointer consumed unlocked across RA prefix loop

Summary

defrtrlist_update(:661-707) returns nd_defrouter pointer with NO refcount, releases nd6_mtx before return. nd6_ra_input(:285) stores dr=defrtrlist_update(&dr0) then runs prefix loop(:291-345) and MTU/lladdr(:348-411) WITHOUT nd6_mtx, passes dr to prelist_update(:344). Concurrent RA lifetime=0 on another CPU: defrtrlist_del(dr)(:541-577) kfrees dr at :576. prelist_update re-acquires mtx(:866), pfxrtr_add(pr,dr)(:915-916) stores freed pointer into pr->ndpr_advrtrs(new->router=dr :728). pfxlist_onlink_check->find_pfxlist_reachable_router derefs router->rtaddr/router->ifp(:1148-1149) -> UAF. Attacker floods RAs same src with conflicting lifetimes. Heap grooming M_IP6NDP -> controlled corruption or panic.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0417 Β· 16 files
FileTypeDescriptionSize
ra_race_mt.c trigger-source multi-threaded RA flood driving the create/delete UAF race (tap injection); decisive trigger 8.7 KB view raw
ra_race.c trigger-source BPF-feedback RA injector (documents the broken-EN10MB-loopback harness limitation) 11.0 KB view raw
ra_tap.c trigger-source single-threaded tap RA injector (early variant) 6.5 KB view raw
ndflagset.c trigger-source sets ND6_IFF_ACCEPT_RTADV on an attached interface via SIOCSIFINFO_IN6 1.5 KB view raw
build.sh build-script cc -O2 [-pthread] for ndflagset + ra_race_mt 571 B view raw
run.sh run-script sets up tap0 + accept_rtadv, runs reader + RA flood 1.3 KB view raw
run.log run-log decisive unpatched reproduction (UAF panic narrative + signature) 1.6 KB view raw
panic.txt panic-signature nd6_sysctl_prlist.part.10+0x165 page fault (the DF-0417 UAF) 1016 B view raw
fix_run.log fix-log patched-kernel before/after: UAF gone; separate rtrequest error-55 panic noted 1.6 KB view raw
fix_build.log build-log single-fix kernel build summary (NK_DONE rc=0) 139 B view raw
fix.diff suggested-fix refcount struct nd_defrouter (hold/rele); git-apply-able, validated on built+booted kernel 3.8 KB view raw
env.txt environment uname, cc, sysctls, ncpu 262 B view raw
README.md readme summary + how to reproduce 6.9 KB ↓ raw
VERDICT.md verdict full root-cause, reproduction, impact, fix + validation narrative 6.8 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme summary + how to reproduce
↓ download raw

DF-0417 β€” Use-after-free race on nd_defrouter via defrtrlist_update()

File: sys/netinet6/nd6_rtr.c Β· Severity: High Β· Class: UAF / refcount-lifetime race

Verdict

REPRODUCED as a kernel panic (network-reachable UAF β†’ DoS). The race the finding describes is real and confirmed both by source-level tracing and by a live crash. A targeted refcount fix eliminates the UAF; the fix is validated by building a single-fix kernel and confirming the UAF panic no longer reproduces under the identical workload.

The bug (mechanism, path:line)

defrtrlist_update() (sys/netinet6/nd6_rtr.c:661-707) looks up or allocates a struct nd_defrouter, takes no reference on it, and returns it to nd6_ra_input() with nd6_mtx released (nd6_rtr.c:679, 685, 692, 705):

dr = defrtrlist_update(&dr0);          /* nd6_ra_input, line 285 */
...
for (...prefix options...)              /* lines 291-345, NO lock held */
    prelist_update(&pr, dr, m);         /* line 344 */

prelist_update() (nd6_rtr.c:855) re-acquires nd6_mtx (line 866) and stores dr into a prefix's advertising-router list via pfxrtr_add() β†’ new->router = dr (nd6_rtr.c:728). Between line 285 (lock released) and line 866 (lock re-acquired) a concurrent deleter can free dr:

  • another RA with rtlifetime == 0 for the same router β†’ defrtrlist_del(dr) (nd6_rtr.c:671), or
  • nd6_timer expiry (nd6.c:582), or
  • nd6_purge on interface detach (nd6.c:801/805), or
  • a peer dropping its router flag (nd6_nbr.c:870).

defrtrlist_del() (nd6_rtr.c:541) does TAILQ_REMOVE + prefix cleanup + kfree(dr, M_IP6NDP) (:576). If it runs before pfxrtr_add() stores the pointer, the stored pfr->router is a dangling pointer to freed memory. It is later field-dereferenced in three deferred sinks:

  • find_pfxlist_reachable_router() β€” nd6_rtr.c:1148-1149 (pfxrtr->router->rtaddr, ->ifp)
  • nd6_sysctl_prlist() β€” nd6.c:2248-2253 (pfr->router->rtaddr, ->ifp)
  • nd6_sysctl_drlist()/prefix export β€” nd6.c:1493

struct nd_defrouter (nd6.h:233) has no refcount β€” the root cause.

Live reproduction

The victim is any IPv6 host with RA acceptance enabled (net.inet6.ip6.accept_rtadv=1, the normal IPv6-autoconfig host mode). An on-link attacker floods Router Advertisements β€” alternating CREATE (lifetime 1800 + many prefix-info options to widen the unlocked window) and DELETE (lifetime 0) for the same router address. The prefix-info options stretch the window between defrtrlist_update() returning (lock released) and prelist_update() re-locking, maximising the chance a concurrent DELETE frees the router mid-loop.

The PoC (ra_race_mt.c) simulates the on-link attacker: it injects crafted unicast RAs into a tap interface (writing /dev/tapN calls if_input synchronously on the writer's CPU β€” if_tap.c:981 β€” so multiple worker threads on different CPUs process create/delete RAs concurrently). A concurrent ndp -p reader triggers the deferred pfxrtr->router dereference.

Decisive run (unpatched GENERIC #0, with-src): guest down within ~4 s; serial console (boot.log):

Fatal user address access from kernel mode from ndp at ffffffff807e7295
Fatal trap 12: page fault while in kernel mode
cpuid = 4; lapic id = 4
fault virtual address  = 0x48
fault code             = supervisor read data, page not present
instruction pointer    = 0x8:0xffffffff807e7295
current process        = 1118   (ndp -p)
kernel: type 12 trap, code=0
Stopped at nd6_sysctl_prlist.part.10+0x165: movq 0x48(%rsi),%rdx

nd6_sysctl_prlist (nd6.c:2248) dereferenced the dangling pfr->router β†’ page fault β†’ panic. This is exactly the deferred UAF sink the finding cites.

Impact

Network-reachable kernel panic / DoS on default GENERIC (INVARIANTS ON): the freed slab is poisoned, so the deferred read faults. On a non-INVARIANTS kernel the read silently returns whatever reoccupied the slab (potential limited info-leak of recycled M_IP6NDP memory). The primitive is a dangling-pointer read in a non-control-data context (the rtaddr/ifp fields are used for display/routing, not as a write or control-flow target), so the realistic ceiling is DoS + possible info-leak, not code execution / uid-0. This is a network-reachable bug (not local-unpriv→root), so the uid-0 escalation bar does not apply.

The fix (fix.diff)

Add reference counting to struct nd_defrouter:

  1. nd6.h β€” add int refcnt; (the sysctl exports copy individual fields into a separate struct in6_defrouter, so this changes no userspace ABI).
  2. nd6_rtr.c β€” nd_defrouter_hold()/nd_defrouter_rele() helpers using atomic_add_int / atomic_fetchadd_int (free when the last ref drops).
  3. A router entry gets one ref for list membership (init to 1 on alloc).
  4. defrtrlist_update() takes a caller ref before returning non-NULL (and nd6_ra_input() drops it after consuming dr).
  5. Each pfxrtr_add() takes a pfxrtr ref; pfxrtr_del() and prelist_remove() drop it.
  6. defrtrlist_del() drops the list ref via rele instead of kfree, so an outstanding caller/pfxrtr reference keeps the entry alive until the last ref is released.

This closes the race: even if defrtrlist_del() runs concurrently with pfxrtr_add(), the entry cannot be freed while any caller or pfxrtr reference is outstanding, so pfr->router can never dangle.

Fix validation (Phase 8)

  • Baseline (#0 with-src, unpatched): the race β†’ Stopped at nd6_sysctl_prlist.part.10+0x165 UAF page-fault panic. βœ… reproduced.
  • Patched (#1, single-fix kernel built from this fix.diff): under the identical workload, nd6_sysctl_prlist page fault does not occur (0 matches in boot.log). The guest either stays up or transiently wedges on mbuf exhaustion and recovers; it does not hit the DF-0417 UAF.
  • A separate panic can still occur under a very heavy RA flood: panic: rtrequest1_msghandler: rtrequest table req 1, failed on cpu5, error 55 (routing-table op fails ENOBUFS under mbuf exhaustion, kernel panics on the unexpected failure). This is a different bug, present on both kernels, and is not addressed by this fix (out of scope for DF-0417).

fix_status = fixed for DF-0417.

How to reproduce

ssh dfbsd                 # root (simulates the on-link attacker's machine)
cd /root/poc/DF-0417 && ./build.sh && ./run.sh
# expected on unpatched: guest panic in ~seconds; boot.log shows
#   Stopped at nd6_sysctl_prlist.part.10+0x165: movq 0x48(%rsi),%rdx
# expected on patched: no such panic (guest stays up / recovers).

Preconditions (realistic for an IPv6 host): net.inet6.ip6.accept_rtadv=1 (host mode), an on-link attacker, and any local read of the prefix list (ndp -p, which any user can issue). See VERDICT.md for full detail and run.log / panic.txt / fix_run.log for the untrimmed evidence.

VERDICT.md verdict full root-cause, reproduction, impact, fix + validation narrative
↓ download raw

DF-0417 β€” VERDICT

Verdict: REPRODUCED (kernel panic / network-reachable UAF). Fix VALIDATED.

Root cause

defrtrlist_update() (sys/netinet6/nd6_rtr.c:661-707) returns a struct nd_defrouter * without taking a reference and with nd6_mtx released (:679, :685, :692, :705). nd6_ra_input() stores that pointer (:285) and, in its unlocked prefix-option loop (:291-345), passes it to prelist_update() (:344). prelist_update() re-acquires nd6_mtx (:866) and stores the pointer into a prefix's pfxrtr list (pfxrtr_add β†’ new->router = dr, :728).

A concurrent deleter can defrtrlist_del(dr) β†’ kfree(dr) (:576) during the window between line 285 (lock released) and line 866 (lock re-acquired). Deleters: - another RA, rtlifetime==0 β†’ defrtrlist_del at :671 - nd6_timer expiry (nd6.c:582) - nd6_purge iface-detach (nd6.c:801/805) - peer router-flag drop (nd6_nbr.c:870)

If the delete lands before pfxrtr_add() stores the pointer, the stored pfr->router is a dangling pointer. It is later field-dereferenced in:

sink location fields read
find_pfxlist_reachable_router nd6_rtr.c:1148-1149 router->rtaddr, router->ifp
nd6_sysctl_prlist (prefix-list export) nd6.c:2248-2253 router->rtaddr, router->ifp
nd6_sysctl_drlist-ish export nd6.c:1493 router->rtaddr

struct nd_defrouter (nd6.h:233-243) has no refcount β€” the root cause.

Why it is not a false positive

The lock release/reacquire boundary is real (the prefix loop runs unlocked). The deferred dereference sinks are real (confirmed by grepping every ->router field-read). The concurrent deleters are real and all hold nd6_mtx while freeing. nd6_mtx is a non-recursive exclusive mutex (MTX_INITIALIZER, mtx_lock is MTX_EXCLUSIVE), so "just hold the lock across the prefix loop" would deadlock against prelist_update()'s own mtx_lock(&nd6_mtx) (:866) β€” which is why a refcount is the correct fix, not a lock-extension.

Live reproduction

  • Setup: IPv6 host with RA acceptance (net.inet6.ip6.accept_rtadv=1, ip6_forwarding=0). On-link attacker (simulated by injecting crafted unicast RAs into a tap interface via /dev/tapN writes β€” if_tap.c:981 calls if_input synchronously on the writer's CPU; multiple worker threads thus process create/delete RAs on different CPUs concurrently).
  • Trigger: 3 worker threads flood CREATE (lifetime 1800 + 8 prefix-info options to widen the unlocked window) and DELETE (lifetime 0) RAs for the same router fe80::dead:beef:cafe; a concurrent ndp -p reader triggers the deferred pfr->router dereference.
  • Result (unpatched GENERIC #0, with-src): guest down in ~4 s. Serial console: Fatal trap 12: page fault while in kernel mode fault virtual address = 0x48 current process = 1118 (ndp -p) Stopped at nd6_sysctl_prlist.part.10+0x165: movq 0x48(%rsi),%rdx nd6_sysctl_prlist (nd6.c:2248) dereferenced the dangling pfr->router β†’ page fault β†’ panic. This is the DF-0417 UAF.

Impact ceiling

Network-reachable panic / DoS on default GENERIC (INVARIANTS ON — freed slab is poisoned with 0xdeadc0de, so the deferred read faults). On a non-INVARIANTS kernel the read silently returns recycled M_IP6NDP memory (possible limited info-leak). The primitive is a dangling-pointer read in a non-control-data context; the realistic ceiling is DoS (+ possible info-leak), not code execution / uid-0. This is a network-reachable bug (the attacker is on the link), not a local-unpriv→root, so the uid-0 escalation bar does not apply.

The fix (fix.diff)

Refcount struct nd_defrouter:

  1. nd6.h: add int refcnt; (no ABI impact β€” sysctls copy individual fields).
  2. nd6_rtr.c: nd_defrouter_hold()/nd_defrouter_rele() (atomic; free at 0).
  3. New allocation: refcnt = 1 (list-membership ref).
  4. defrtrlist_update(): caller ref on non-NULL return (guarded β€” dr is NULL in the rtlifetime==0 case); nd6_ra_input() drops it after the prefix loop.
  5. pfxrtr_add() takes a pfxrtr ref; pfxrtr_del() and prelist_remove() drop it.
  6. defrtrlist_del(): kfree β†’ nd_defrouter_rele (drops list ref; entry freed only when the last caller/pfxrtr ref is gone).

This makes it impossible for pfr->router to dangle: the entry cannot be freed while any caller or pfxrtr reference is outstanding.

(The finding's ## Recommended fix proposal β€” "add refcount (nd_defrouter_hold/release) or hold lock" β€” is matched: I implemented the refcount option, which is the only viable choice given nd6_mtx is non-recursive and prelist_update() re-locks.)

Fix validation

kernel workload result
before #0 with-src, unpatched 3-thread create/delete RA flood + ndp -p UAF panic: nd6_sysctl_prlist.part.10+0x165 page fault (nd6.c:2248)
after #1 single-fix (fix.diff), built+booted identical No nd6_sysctl_prlist panic (0 matches in boot.log); the DF-0417 UAF does not reproduce

A separate panic can still occur under a very heavy RA flood: panic: rtrequest1_msghandler: rtrequest table req 1, failed on cpu5, error 55 (routing-table op fails ENOBUFS under mbuf exhaustion; kernel panics on the unexpected failure). This is a different bug (present on both kernels, out of scope for DF-0417) and is not addressed by this fix.

fix_status = fixed (for DF-0417).

PoC changes

The reviewer-supplied scaffold was incomplete (no PoC sources existed). I authored, from scratch: - ndflagset.c β€” sets ND6_IFF_ACCEPT_RTADV on an already-attached interface via SIOCSIFINFO_IN6 (the global sysctl only takes effect at attach time β€” nd6.c:210). - ra_race_mt.c β€” multi-threaded RA flood driving the create/delete race; injects correctly-checksummed unicast RAs (ICMPv6 cksum in network byte order) at the tap's own link-local (tap0 does not join ff02::1 on this guest, so multicast RAs are dropped by ether_input). - ra_race.c / ra_tap.c β€” earlier single-threaded injectors (BPF-feedback and tap-multicast variants), retained for the harness notes; the multi-threaded ra_race_mt.c is the decisive trigger.

Key debugging notes captured for reproducibility: - BPF BIOCSFEEDBACK loopback is broken for DLT_EN10MB on this guest: bpf_movein() (bpf.c:271-274) strips the Ethernet header into the output sockaddr and advances the mbuf past it; the feedback path dups that header-stripped mbuf into if_input, so looped-back packets are mis-framed and never reach nd6_ra_input. - tap injection works (if_input is called synchronously, if_tap.c:981), but the ICMPv6 checksum must be stored in network byte order and the RA must be unicast at the tap's own LLA (tap0 does not join ff02::1).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline Fatal trap 12 nd6_sysctl_prlist UAF; patched 0 UAF panics. Separate rtrequest error-55 panic is different bug.

BEFORE: Fatal trap 12 at nd6_sysctl_prlist+0x165. AFTER: 0 UAF matches.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 16 15:15:59 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none -- read-only UAF (dangling ptr read). Network-reachable (on-link IPv6). DoS panic + limited info-leak ceiling. No write primitive.

Evidence (decisive lines)

BEFORE: Fatal trap 12 page fault 0x48 at nd6_sysctl_prlist+0x165 (pfr->router->rtaddr deref). AFTER: 0 UAF panics in boot.log.

PoC changes

Authored: ndflagset.c (ND6_IFF_ACCEPT_RTADV), ra_race_mt.c (multi-thread RA flood via /dev/tap), fix.diff (refcount on nd_defrouter: hold/rele + pfxrtr refs), VERDICT.md, manifest.json.

Verified recommended fix

Add refcnt to struct nd_defrouter + nd_defrouter_hold/rele (atomic). defrtrlist_update takes caller ref, nd6_ra_input drops after prefix loop. pfxrtr_add/del take/drop refs. defrtrlist_del kfree->rele. 11 hunks. Full diff in findings/poc/DF-0417/fix.diff.

Verdict

REPRODUCED (live panic). defrtrlist_update returns unreferenced nd_defrouter ptr with nd6_mtx released. nd6_ra_input prefix loop uses dr unlocked. Concurrent delete frees dr -> dangling pfr->router -> nd6_sysctl_prlist UAF -> Fatal trap 12 at nd6_sysctl_prlist+0x165 movq 0x48(%rsi).