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)
PoC verification
Evidence pack
findings/poc/DF-0417 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 == 0for the same router βdefrtrlist_del(dr)(nd6_rtr.c:671), or nd6_timerexpiry (nd6.c:582), ornd6_purgeon 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:
nd6.hβ addint refcnt;(the sysctl exports copy individual fields into a separatestruct in6_defrouter, so this changes no userspace ABI).nd6_rtr.cβnd_defrouter_hold()/nd_defrouter_rele()helpers usingatomic_add_int/atomic_fetchadd_int(free when the last ref drops).- A router entry gets one ref for list membership (init to 1 on alloc).
defrtrlist_update()takes a caller ref before returning non-NULL (andnd6_ra_input()drops it after consumingdr).- Each
pfxrtr_add()takes a pfxrtr ref;pfxrtr_del()andprelist_remove()drop it. defrtrlist_del()drops the list ref viareleinstead ofkfree, 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 (
#0with-src, unpatched): the race βStopped at nd6_sysctl_prlist.part.10+0x165UAF page-fault panic. β reproduced. - Patched (
#1, single-fix kernel built from thisfix.diff): under the identical workload,nd6_sysctl_prlistpage fault does not occur (0 matches inboot.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 failsENOBUFSunder 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.
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 atapinterface via/dev/tapNwrites βif_tap.c:981callsif_inputsynchronously 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 concurrentndp -preader triggers the deferredpfr->routerdereference. - 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),%rdxnd6_sysctl_prlist(nd6.c:2248) dereferenced the danglingpfr->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:
nd6.h: addint refcnt;(no ABI impact β sysctls copy individual fields).nd6_rtr.c:nd_defrouter_hold()/nd_defrouter_rele()(atomic; free at 0).- New allocation:
refcnt = 1(list-membership ref). defrtrlist_update(): caller ref on non-NULL return (guarded βdris NULL in thertlifetime==0case);nd6_ra_input()drops it after the prefix loop.pfxrtr_add()takes a pfxrtr ref;pfxrtr_del()andprelist_remove()drop it.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
fixedVALIDATED: 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.
Confirmed kernel references
- sys/netinet6/nd6_rtr.c:661
- sys/netinet6/nd6_rtr.c:679
- sys/netinet6/nd6_rtr.c:705
- sys/netinet6/nd6_rtr.c:285
- sys/netinet6/nd6_rtr.c:344
- sys/netinet6/nd6_rtr.c:866
- sys/netinet6/nd6_rtr.c:728
- sys/netinet6/nd6_rtr.c:576
- sys/netinet6/nd6_rtr.c:1148
- sys/netinet6/nd6.c:2248
- sys/netinet6/nd6.c:1493
- sys/netinet6/nd6.h:233
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).
No comments yet.