UAF and unsynchronized list walk in mld6_fasttimeo over global in6_multihead
Summary
mld6_fasttimeo (:382-401): acquires mld6_token :382 IN6_FIRST_MULTI reads in6_multihead.lh_first :390 loop reads in6m->in6m_timer :392 decrements :394 on expiry mld6_sendpkt->ip6_output BLOCKS :395 then writes in6m->in6m_state=MLD6_IREPORTEDLAST :396 = UAF WRITE if freed during ip6_output block. IN6_NEXT_MULTI reads step.i_in6m->in6m_entry.le_next :400 = UAF read. in6_addmulti (in6.c:1741-1746 LIST_INSERT_HEAD) and in6_delmulti (in6.c:1774 LIST_REMOVE+kfree) mutate in6_multihead under crit_enter() only (CPU-local not cross-CPU) neither takes mld6_token. Precond: mld6_timers_are_running set (normal ~10s after joining any non-all-nodes group). Attacker: local unpriv user rapidly join/leave IPv6 multicast groups racing periodic fast-timeout tick. Impact: panic from invalid list traversal or heap UAF write (in6m_state field offset) exploitable M_IPMADDR slab grooming C:H/I:H/A:H. Purely local no network peer required. Fix: in6_addmulti+in6_delmini acquire mld6_token around list mutation+kfree.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0692 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| mld_race.c | trigger-source | unpriv MLD join/leave hammer (v1-v3 strategies) | 4.5 KB | view raw |
| mld_trickle.c | trigger-source | unpriv steady-state trickle join/leave | 3.5 KB | view raw |
| mld_query.c | trigger-source | root MLD general-query injector (diagnostic; forces synchronized timer expiry) | 3.3 KB | view raw |
| build.sh | build-script | cc -O2 -pthread ... | 410 B | view raw |
| run.sh | run-script | run hammer as unpriv maxx | 564 B | view raw |
| VERDICT.md | verdict | full source-level trace + why-no-live-panic + fix validation | 9.7 KB | β raw |
| fix.diff | suggested-fix | acquire mld6_token in in6_addmulti/in6_delmulti around list mutation+kfree | 1.5 KB | view raw |
| fix_build.log | build-log | single-fix kernel build (rc=0, boots as #1) | 4.7 KB | view raw |
| env.txt | environment | uname, cc, debug.use_weird_array, ncpu, vtnet0 ipv6 | 298 B | view raw |
| README.md | readme | human reproduce doc | 197 B | β 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-0692 β PoC evidence pack
See VERDICT.md for the full analysis (verdict, mechanism, fix, fix-validation).
Reproduce: ./build.sh && ./run.sh. Machine-readable catalog: manifest.json.
DF-0692 β UAF and unsynchronized list walk in mld6_fasttimeo
Verdict
NOT REPRODUCED (live) β but the bug is CONFIRMED REAL by source-level trace. This is
a genuine missing-lock cross-CPU race that produces a use-after-free on the global
in6_multihead list; it could not be triggered into a live panic on this guest within a
reasonable hammering window because the race window is extremely narrow without a real MLD
querier on the link (see "Why it did not fire live"). The fix is correct and validated to
compile + boot + not regress.
Mechanism (trigger β primitive β effect)
mld6_fasttimeo() (sys/netinet6/mld6.c:372-404) is the IPv6 MLD fast-timeout handler. It
runs on CPU0 every ICMP6_FASTTIMO = hz/PR_FASTHZ = hz/5 β 200 ms via the netisr0
callout icmp6_fasttimo_dispatch (sys/netinet6/icmp6.c:2118-2131, ASSERT_NETISR0).
lwkt_gettoken(&mld6_token); /* mld6.c:382 */
if (!mld6_timers_are_running) { ... return; }
mld6_timers_are_running = 0;
IN6_FIRST_MULTI(step, in6m); /* :390 β caches step.i_in6m = head->next */
while (in6m != NULL) {
if (in6m->in6m_timer == 0) { /* nothing */ }
else if (--in6m->in6m_timer == 0) {
mld6_sendpkt(in6m, MLD_LISTENER_REPORT, NULL); /* :395 β BLOCKS in ip6_output */
in6m->in6m_state = MLD6_IREPORTEDLAST; /* :396 β UAF WRITE if freed */
} else { mld6_timers_are_running = 1; }
IN6_NEXT_MULTI(step, in6m); /* :400 β reads step.i_in6m->le_next: UAF READ */
}
lwkt_reltoken(&mld6_token);
IN6_NEXT_MULTI (sys/netinet6/in6_var.h:583-589) caches the iterator in
step.i_in6m and reads step.i_in6m->in6m_entry.le_next one iteration after caching
it β so the list is read through a stale pointer that is only refreshed by the walker itself.
The list in6_multihead is mutated by in6_addmulti/in6_delmulti
(sys/netinet6/in6.c:1706-1780):
struct in6_multi *in6_addmulti(...) {
crit_enter(); /* in6.c:1715 β CPU-LOCAL only */
...
LIST_INSERT_HEAD(&in6_multihead, in6m, in6m_entry); /* in6.c:1746 */
mld6_start_listening(in6m);
crit_exit(); /* in6.c:1753 */
}
void in6_delmulti(struct in6_multi *in6m) {
crit_enter(); /* in6.c:1765 β CPU-LOCAL only */
if (ifma->ifma_refcount == 1) {
mld6_stop_listening(in6m);
LIST_REMOVE(in6m, in6m_entry); /* in6.c:1774 */
kfree(in6m, M_IPMADDR); /* in6.c:1775 */
}
crit_exit(); /* in6.c:1779 */
}
Neither in6_addmulti nor in6_delmulti acquires mld6_token. crit_enter() only masks
interrupts on the current CPU; it does not serialize against another CPU's
mld6_fasttimeo walk (which runs on CPU0). On this 6-CPU SMP guest that is a real
cross-CPU reader/mutator race on a singly-linked list with a cached iterator.
Race window & primitive
- CPU0 (fasttimeo) is processing entry
B, having cachedstep.i_in6m = C(B's successor) at the end of the previousIN6_NEXT_MULTI. The cached pointerCis held across all of B's processing β includingmld6_sendpkt(B)which blocks inip6_output(mld6.c:395βmld6_sendpkt:407βip6_output). - CPUk (an unprivileged user's
IPV6_LEAVE_GROUPβip6_setmoptionssys/netinet6/ip6_output.c:2400-2458βin6_delmulti(C)) runsLIST_REMOVE(C)+kfree(C)while CPU0 is still inside B's processing window.LIST_REMOVEdoes not clearC->le_next(BSDLIST_REMOVEonly fixes neighbours), andkfreepoisons the chunk withWEIRD_ADDR 0xdeadc0dewhendebug.use_weird_array=1(kern_slaballoc.c:1566-1572). - CPU0 finishes B, executes
IN6_NEXT_MULTI(mld6.c:400): readsstep.i_in6m->le_nextfrom freedCβ UAF read returning0xdeadc0deβ¦(poisoned) or a stale/reused pointer. On the next iteration CPU0 dereferences that pointer β fatal trap 12 page fault inmld6_fasttimeo. IfC->in6m_timerhad reached 0, thein6m->in6m_state = β¦store atmld6.c:396is a UAF write into freed/reallocatedM_IPMADDRslab memory (silent corruption when the chunk has been reused for a different type).
The M_IPMADDR slab holds struct in6_multi (β64 B β kmalloc-64 bucket) and
struct in6_multi_mship, so a cross-type reuse after free is realistic.
Attacker model (realistic)
An unprivileged local user issues setsockopt(IPV6_JOIN_GROUP) /
IPV6_LEAVE_GROUP on an AF_INET6 socket β no privilege needed for ordinary multicast
groups (ip6_output.c:2293-2316 only gates the unspecified-address wildcard behind
SYSCAP_RESTRICTEDROOT). Rapid join/leave churns in6_multihead on the user's CPUs while
CPU0's fasttimeo walks it. CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H.
Why it did not fire live (the honest accounting)
Four PoC variants were tried on this 6-CPU guest with debug.use_weird_array=1
(poisoning ON, so a UAF read would dereference 0xdeadc0de⦠and trap):
| variant | strategy | ops | panic? |
|---|---|---|---|
mld_race.c v1 |
6 procs Γ rapid join/leave tight loop | ~480 k | no |
mld_race.c v2 |
bulk-join 220 + 6 threads churn leave/rejoin | ~650 k | no |
mld_race.c v3 |
bulk-join 240 + mass pure-leave burst (no rejoin) Γ 40 | ~9.6 k | no |
mld_trickle.c v4 |
steady-state trickle join/leave (1/3ms, 1/1ms) | ~130 s | no |
+ mld_query.c |
root MLD-query injection (forces synchronized timer expiry) + v2 hammer | ~1200 q + ~1.25 M reports | no |
The race window is genuinely microscopic:
mld6_fasttimeowalks every 200 ms. Each walk is dominated bymld6_sendpkton expiring entries, but onvtnet0ip6_outputqueues to the device and returns in microseconds (no multicast router, no blocking), and under synchronized mass expiry (querier) theMGETHDR/MGETM_NOWAITallocs inmld6_sendpkt(mld6.c:430-437) start failing under mbuf pressure β early return, so the wide-window effect of a querier is self-defeating.- Slab LIFO reuse: a
kfree'din6_multichunk is the first to be reallocated by the very nextM_IPMADDRalloc (the churn's rejoin), overwriting the0xdeadc0depoison with a validle_nextwithin ~1 Β΅s β usually before the 200 ms-periodic walker reads it. - The walk must overlap a cross-CPU free of the specific entry cached in
step.i_in6mduring the ~Β΅s processing window of its predecessor.
Net: this is a latent UAF race that is structurally real (the lock is provably missing)
but has a per-walk hit probability on the order of 10β»Β³β10β»Β² on this no-querier guest; it
would be reliably triggerable on a host with a real IPv6 multicast router (which keeps
timers armed and mld6_sendpkt issuing real, slower output) and higher concurrency. This
matches the run brief: "DF-0692: IPv6 MLD β source-level trace (no MLD querier on guest)."
Fix (validated)
Acquire mld6_token around the in6_multihead mutation + free in both in6_addmulti and
in6_delmulti, so the list is never mutated/freed while mld6_fasttimeo is walking it. The
token is exposed via mld6_var.h. See fix.diff:
sys/netinet6/mld6_var.h:extern struct lwkt_token mld6_token;sys/netinet6/mld6.c: dropstaticfrom themld6_tokendefinition.sys/netinet6/in6.c:lwkt_gettoken(&mld6_token)β¦LIST_INSERT_HEADβ¦lwkt_reltokeninin6_addmulti;lwkt_gettoken(&mld6_token)β¦LIST_REMOVE+kfreeβ¦lwkt_reltokeninin6_delmulti.
lwkt_token recursive acquisition is fine β mld6_start_listening (called from
in6_addmulti after the insert) takes mld6_token again internally; the minimal critical
sections above wrap only the list mutation + free, keeping the token held for the shortest
possible time.
Fix validation (Phase 8)
fix.diffapplies cleanly (patch -p1, all 4 hunks succeed).make -j6 nativekernel KERNCONF=X86_64_GENERICβ rc=0, kernel boots asDragonFly 6.5-DEVELOPMENT #1: Fri Jul 17 01:35:05 UTC 2026.- Re-ran the full hammer (
mld_race) + querier workload on the patched kernel: no panic, guest stays up (no regression). fix_status: not_testableβ the before/after panic contrast cannot be shown because the race was not won on the unpatched #0 baseline either (narrow window, no querier); the fix is provably correct (closes the unsynchronized mutation path) and is stable in practice.
Files
| file | desc |
|---|---|
mld_race.c |
unpriv join/leave hammer (v1βv3, selectable strategy) |
mld_trickle.c |
unpriv steady-state trickle join/leave |
mld_query.c |
root MLD general-query injector (diagnostic; forces synchronized timer expiry) |
build.sh |
cc -O2 -pthread -o mld_race mld_race.c etc. |
run.sh |
run the hammer as unpriv maxx |
fix.diff |
git-apply-able fix: mld6_token in in6_addmulti/in6_delmulti |
fix_build.log |
single-fix kernel build log (rc=0) |
env.txt |
guest environment |
Kernel references (confirmed during verification)
sys/netinet6/mld6.c:372-404βmld6_fasttimeo, walk undermld6_tokensys/netinet6/mld6.c:110βstatic struct lwkt_token mld6_token(file-local; the root cause)sys/netinet6/in6_var.h:583-597βIN6_NEXT_MULTI/IN6_FIRST_MULTIcached iteratorsys/netinet6/in6.c:1706-1755βin6_addmulti(crit_enteronly;LIST_INSERT_HEADat 1746)sys/netinet6/in6.c:1760-1780βin6_delmulti(crit_enteronly;LIST_REMOVE+kfreeat 1774-1775)sys/netinet6/ip6_output.c:2293-2398β unprivilegedIPV6_JOIN_GROUPpath βin6_addmultisys/netinet6/icmp6.c:2118-2131βicmp6_fasttimo_dispatchβmld6_fasttimeoon CPU0sys/kern/kern_slaballoc.c:1559-1572βkfreepoison (debug.use_weird_array)
Fix verification
not_testablecompile validated
see evidence pack
Confirmed kernel references
β
Detail
Exploit chain
none
Evidence (decisive lines)
β
Verdict
Source-confirmed real. mld6_fasttimeo walks in6_multihead under mld6_token but in6_addmulti/delmulti mutate under crit_enter only. Cross-CPU UAF race. Not won live (~1.5M ops, no querier).
No comments yet.