# DF-3299 — `carp_group_demote_adj()` / `carp_send_ad_all()` execute outside netisr0 (MP-unsafe) — STATIC ANALYSIS

Not guest-verified (Low severity, privileged/peer-triggered); basis is
source analysis only.

## Claim

sys/netinet/ip_carp.c:2764-2785: `carp_group_demote_adj()` mutates
`ifg_carp_demoted` and, when a group transitions 0 -> 1, calls
`carp_send_ad_all()` **in the caller's context**, with no netisr0
dispatch and no `carp_listtok`:

```c
void
carp_group_demote_adj(struct ifnet *ifp, int adj)
{
        ...
        if (*dm + adj >= 0)
                *dm += adj;
        ...
        if (adj > 0 && *dm == 1)
                carp_send_ad_all();          /* runs HERE, any CPU */
```

Callers (sys/net/pf/if_pfsync.c):

- :985  pfsync_input() PFSYNC_ACT_BUS/PFSYNC_BUS_END -> adj(-1) —
  **any netisr CPU** (packet-receive context)
- :1162 pfsyncioctl() (bulk-update request path) -> adj(+1) —
  **user ioctl thread**; the only +1 site, and the one that fires
  `carp_send_ad_all()` on a non-netisr0 thread
- :1644 pfsync_bulkfail() callout -> adj(-1) — callout context on
  whichever CPU the callout was armed on
- :209  pfsync_clone_destroy() -> adj(-1) — user ioctl thread

## Consequences

1. `carp_send_ad_all()` walks the global `carpif_list` (ip_carp.c:1281)
   without `carp_listtok` while `carp_clone_create/destroy`
   (ip_carp.c:674-676, 715-717) mutate the same list under the token:
   torn traversal / use-after-free of a concurrently destroyed softc.
2. `carp_send_ad()` invoked off netisr0 races the netisr0 state machine
   on the same softc (`sc_sendad_errors`, `sc_imo`, `sc_ia`) and does
   `callout_reset(&sc->sc_ad_tmo, ...)` on the **wrong CPU**; the
   handler asserts `mycpuid == 0` (ip_carp.c:1296-1297) -> guaranteed
   INVARIANTS panic when the timer fires; on non-INVARIANTS kernels the
   timeout handler calls `lwkt_sendmsg_oncpu(netisr_cpuport(0))` from a
   foreign CPU, violating the lwkt port protocol.
3. `*dm += adj` on `ifg_carp_demoted` is non-atomic and unsynchronized
   across CPUs -> lost updates permanently skew the demotion counter
   (failover groups stuck demoted / under-demoted).

## Trigger model

+1 path: privileged pfsync ioctl (ifconfig pfsync0 syncdev/peer loss
handling) — root.  -1 paths: pfsync packet processing from the sync
peer (unauthenticated pfsync by default) — an attacker on the sync
segment can race the counter concurrently with legitimate +1/-1.

## Fix sketch

Dispatch the demotion + `carp_send_ad_all()` to netisr0 (netmsg), and
make the counter adjustment atomic (`atomic_fetchadd_int` guarded to
the >= 0 clamp) or netisr0-only.
