# DF-0366 — Lockless sc_count pre-check in lagg_start races port destroy

## Verdict
**LATENT — code-level lock-order violation confirmed by source trace;
race not won in our test window (no panic).**

`lagg_start` checks `sc->sc_count == 0` as an early-exit guard
**without** holding `LAGG_RLOCK`. It then acquires `LAGG_RLOCK` and
calls `sc_select_tx_port`, which in round-robin mode executes
`p %= sc->sc_count`. If a concurrent `lagg_port_destroy` (under
`LAGG_WLOCK`) decrements `sc_count` 1→0 between the lockless check
and the RLOCK acquire, the modulo reads `sc_count == 0` and the CPU
traps a divide-by-zero → kernel panic.

## Bug mechanism (source trace)
File: `sys/net/lagg/if_lagg.c`.

### The lockless early-exit guard (line 1758–1763)
```c
1758: if (((ifp->if_flags & IFF_RUNNING) == 0)
1759:     || (sc->sc_proto == LAGG_PROTO_NONE)
1760:     || (sc->sc_count == 0)) {     // <-- LOCKLESS read of sc_count
1761:     ifsq_purge(ifsq);
1762:     return;
1763: }
1764:
1765:
1766: LAGG_RLOCK(sc);                   // <-- lock taken AFTER the check
1767: for (;;) {
       ...
1773:     ifp_p = (*sc_select_tx_port)(sc, m);
```

### RR select_tx_port divides by sc_count (line 1860)
```c
1853: static struct ifnet *
1854: lagg_rr_select_tx_port(struct lagg_softc *sc, struct mbuf *m)
       ...
1859: p = atomic_fetchadd_32(&sc->sc_seq, 1);
1860: p %= sc->sc_count;                  // <-- div-by-zero if sc_count==0
```

### Concurrent decrement under WLOCK (`sys/net/lagg/if_lagg.c:770`)
```c
740: lagg_port_destroy(struct lagg_port *lp, int runpd)
       ...
747: LAGG_WLOCK_ASSERT(sc);               // caller holds WLOCK
       ...
770: sc->sc_count--;                       // 1 -> 0
```

### Race window
```
CPU A (lagg_start):           CPU B (lagg_port_destroy):
  read sc_count==1 (NO LOCK)
                              acquire WLOCK
                              sc_count-- (1 -> 0)
                              release WLOCK
  acquire RLOCK (count is 0
   but RLOCK doesn't help;
   the bad read already
   happened)
  call sc_select_tx_port
   p %= sc_count   -> div0   PANIC
```

Once `LAGG_RLOCK` is held the count is stable — but the lockless
pre-check at line 1758 already let us pass when count was 1, then
count became 0 before the lock was acquired, and the locked region
operates on the now-zero count.

## Reproduction attempt on this guest
We created `lagg0` (roundrobin) with two `tap` ports, then
concurrently ran a UDP-TX flood through `lagg0` and a port-flap
loop (`-laggport tap1 ; -laggport tap0 ; laggport tap0 ;
laggport tap1`, 200 iterations). The guest stayed up — the race
window is narrow and we did not win it in this short test.

```
race setup:  lagg0 roundrobin {tap0,tap1}, UDP TX flood + 200 port flaps
result:      guest still up; no div0 panic triggered
```

This does **not** refute the bug — the code pattern is unambiguously
racy. With heavier traffic + a tighter port-flap loop + more CPUs
the race is winnable.

## Impact (code-confirmed)
- Kernel divide-by-zero trap → **panic** (DoS) from a context that
  can be reached by an unprivileged user once an admin has
  configured a lagg interface (root-only setup, but the trigger —
  sending traffic while the last port is removed — is unpriv/remote).
- Hot-plug events (physical link flap, driver reload) and any
  packet egress during the last-port removal race the same window.

## Recommended fix
Either:
(a) acquire `LAGG_RLOCK` BEFORE the early-exit check, so the read
of `sc_count` is consistent with the locked region; or
(b) re-validate `sc_count != 0` inside the RLOCK region before
calling `sc_select_tx_port`.

Option (a) is cleaner — see `fix.diff` for that variant.

## Files in this folder
- `race.sh`       — the shell-based race trigger we used
- `fix.diff`      — move the LAGG_RLOCK above the early-exit check
- `VERDICT.md`    — this file
- `manifest.json`
- `env.txt`
