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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0692 Β· 12 files
FileTypeDescriptionSize
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
README.md readme human reproduce doc
↓ download 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.

VERDICT.md verdict full source-level trace + why-no-live-panic + fix validation
↓ download raw

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

  1. CPU0 (fasttimeo) is processing entry B, having cached step.i_in6m = C (B's successor) at the end of the previous IN6_NEXT_MULTI. The cached pointer C is held across all of B's processing β€” including mld6_sendpkt(B) which blocks in ip6_output (mld6.c:395 β†’ mld6_sendpkt:407 β†’ ip6_output).
  2. CPUk (an unprivileged user's IPV6_LEAVE_GROUP β†’ ip6_setmoptions sys/netinet6/ip6_output.c:2400-2458 β†’ in6_delmulti(C)) runs LIST_REMOVE(C) + kfree(C) while CPU0 is still inside B's processing window. LIST_REMOVE does not clear C->le_next (BSD LIST_REMOVE only fixes neighbours), and kfree poisons the chunk with WEIRD_ADDR 0xdeadc0de when debug.use_weird_array=1 (kern_slaballoc.c:1566-1572).
  3. CPU0 finishes B, executes IN6_NEXT_MULTI (mld6.c:400): reads step.i_in6m->le_next from freed C β†’ UAF read returning 0xdeadc0de… (poisoned) or a stale/reused pointer. On the next iteration CPU0 dereferences that pointer β†’ fatal trap 12 page fault in mld6_fasttimeo. If C->in6m_timer had reached 0, the in6m->in6m_state = … store at mld6.c:396 is a UAF write into freed/reallocated M_IPMADDR slab 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_fasttimeo walks every 200 ms. Each walk is dominated by mld6_sendpkt on expiring entries, but on vtnet0 ip6_output queues to the device and returns in microseconds (no multicast router, no blocking), and under synchronized mass expiry (querier) the MGETHDR/MGET M_NOWAIT allocs in mld6_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'd in6_multi chunk is the first to be reallocated by the very next M_IPMADDR alloc (the churn's rejoin), overwriting the 0xdeadc0de poison with a valid le_next within ~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_in6m during 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: drop static from the mld6_token definition.
  • sys/netinet6/in6.c: lwkt_gettoken(&mld6_token) … LIST_INSERT_HEAD … lwkt_reltoken in in6_addmulti; lwkt_gettoken(&mld6_token) … LIST_REMOVE + kfree … lwkt_reltoken in in6_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.diff applies cleanly (patch -p1, all 4 hunks succeed).
  • make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ rc=0, kernel boots as DragonFly 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)

Fix verification

not_testable

compile 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).