# 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`).

```c
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`):

```c
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. CPU*k* (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)

- `sys/netinet6/mld6.c:372-404` — `mld6_fasttimeo`, walk under `mld6_token`
- `sys/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_MULTI` cached iterator
- `sys/netinet6/in6.c:1706-1755` — `in6_addmulti` (`crit_enter` only; `LIST_INSERT_HEAD` at 1746)
- `sys/netinet6/in6.c:1760-1780` — `in6_delmulti` (`crit_enter` only; `LIST_REMOVE`+`kfree` at 1774-1775)
- `sys/netinet6/ip6_output.c:2293-2398` — unprivileged `IPV6_JOIN_GROUP` path → `in6_addmulti`
- `sys/netinet6/icmp6.c:2118-2131` — `icmp6_fasttimo_dispatch` → `mld6_fasttimeo` on CPU0
- `sys/kern/kern_slaballoc.c:1559-1572` — `kfree` poison (`debug.use_weird_array`)
