# DF-3340 — pf synproxy PROXY:DST purge sends TCP RST from non-netisr context → guaranteed INVARIANTS panic (remotely triggerable)

## What

`pf_unlink_state()` (sys/net/pf/pf.c:1483) emits a spoofed TCP RST via
`pf_send_tcp()` → `ip_output()` (sys/net/pf/pf.c:2255) whenever a pf state
is unlinked while its source peer is stuck in `PF_TCPS_PROXY_DST` — i.e. a
`synproxy state` rule where the client completed the handshake *with the
firewall itself* but the backend never answered SYNACK.

`ip_output()` asserts at entry `ASSERT_NETISR_NCPUS(mycpuid)`
(sys/netinet/ip_output.c:185, macro at sys/net/netisr2.h:136-139) that the
caller is a netisr thread. Four unlink contexts are **not** netisr threads:

| context | path | who reaches it |
|---|---|---|
| state expiry | `pf_purge_thread` → `pf_purge_expired_states` → `pf_unlink_state` (pf.c:1262/1591) | **any remote host, no auth** |
| DIOCCLRSTATES | pf_ioctl.c:1591 (`pfctl -F states`) | root |
| DIOCKILLSTATES | pf_ioctl.c:1621,1676 (`pfctl -k`) | root |
| module unload | `shutdown_pf` → `pf_clear_states` (pf_ioctl.c:3071,3187) | root — the DF-3337 incidental panic |

## Reproduce (guest, root)

1. `sh findings/poc/DF-3340/trigger.sh` (pushed to guest): loads stock
   `pf.ko`, installs `set timeout tcp.first 5` +
   `pass in quick on lo0 inet proto tcp from 127.0.0.0/8 to 127.0.0.1 port 8080 flags S/SA synproxy state`,
   then one `nc 127.0.0.1 8080`.
2. `nc`'s SYN creates the state (`PROXY_SRC`), PF answers SYNACK itself;
   the client ACK completes the handshake **with the firewall**
   (`PROXY_DST`, pf.c:4935) and PF sends a spoofed SYN at the backend
   (pf.c:4948). Nothing listens on 8080 → the stack's RST is dropped by the
   PROXY_DST check (pf.c:4955-4959) → state sticks in `PROXY:DST`
   (`pfctl -s state` shows it).
3. ~5 s later (tcp.first; **120 s at default timeout**, pf_ioctl.c:267) the
   purge thread unlinks the state → RST send → panic.

## Expected output (baseline)

```
--- states at t+2s (expect SYN_PROXY / PROXY:DST):
all tcp 127.0.0.1:8080 <- 127.0.0.1:1309       PROXY:DST
[ssh dies ~5-7 s after the connect]
```

serial console (panic.txt):
```
panic: thread 0xfffff800905f5f80 cpu2 is not within netisr_ncpus 6
ip_output() at ip_output+0x64e
ip_output() at ip_output+0x64e
pf_send_tcp() at pf_send_tcp+0x4f3
pf_unlink_state() at pf_unlink_state+0xc6
pf_purge_expired_states() at pf_purge_expired_states+0x89
pf_purge_thread() at pf_purge_thread+0xb4
Debugger("panic")
```

## Remote analogue

On a real edge firewall the same two packets (SYN, then handshake-completing
ACK) from *any* unauthenticated host to any synproxy-protected port whose
backend is down/filtered create the same stuck `PROXY:DST` state; the panic
fires on the next purge pass after `tcp.first` (default 120 s). Loopback is
used in the rig only because the QEMU guest has one interface.

## Fix validation (fix.diff)

`fix.diff` defers the RST to a netisr via a heap netmsg (`rtfree_async`
pattern, sys/net/route.c:311-338) with all parameters copied (state and
rule are freed before dispatch), keeps a direct-send fast path when already
on a netisr thread (packet path, pf.c:5006), and drains in-flight netmsgs
in `pf_unload()` so the module can't be freed under a queued netmsg.

Patched pf.ko (built in-guest, -Werror clean):

* expiry trigger: **SURVIVED**, state purged, and the deferred spoofed RST
  still leaves the box — tcpdump on lo0:
  `03:50:14.023682 IP 127.0.0.1.8080 > 127.0.0.1.1485: Flags [R.], seq 0, ack 1`
  exactly 4.7 s after the connect (= tcp.first 5 s + purge cadence).
* `kldunload pf` with a live PROXY:DST state (DF-3337's context): clean.
* 8-state stress run: survived, all states purged, serial console clean.

## Severity honesty

* The panic is **guaranteed on INVARIANTS kernels** — and the stock default
  config ships INVARIANTS (`options INVARIANTS`, sys/config/X86_64_GENERIC:56);
  this audit's reference kernel (uname: `.../usr/obj/usr/src/sys/X86_64_GENERIC`)
  is exactly that build. Default-config DragonFly + a synproxy rule = remotely
  triggerable unauthenticated panic after `tcp.first` (default 120 s).
* Kernels custom-built without INVARIANTS compile the assert out; the send
  then runs on the purge thread — an architecture violation (network output
  outside netisr serialization, `pf_consistency_lock` held exclusively, can
  sleep inside ip_output's route path) — but **no non-INVARIANTS crash was
  demonstrated**. No memory-corruption primitive; not a uid0 path.
