# DF-3340 VERDICT

**Status: reproduced (panic, remote-unauthenticated trigger class) — fix validated (`fixed`).**
**Impact: panic/DoS — guaranteed on INVARIANTS kernels only; not a memory-corruption/uid0 primitive.**

## Root cause (line-accurate)

1. A `synproxy state` rule makes pf terminate the TCP handshake with the
   client itself: on the client SYN, `pf_create_state()` sets
   `src.state = PF_TCPS_PROXY_SRC` (pf.c:4268) and answers SYNACK (pf.c:4292).
   State timeout is `PFTM_TCP_FIRST_PACKET` (pf.c:4188), `expire` = creation
   (pf.c:4208).
2. The client ACK completes the handshake *with the firewall*:
   `src.state = PF_TCPS_PROXY_DST` (pf.c:4935); pf sends a spoofed SYN at the
   backend (pf.c:4948). Every SYNPROXY path then `FAIL`s, so the timeout /
   expire refresh code at pf.c:4829-4846 is **never reached**.
3. If the backend never SYNACKs (down / filtered / RSTs — an RST is dropped
   by the reverse-direction check at pf.c:4955-4959), the state is stuck in
   `PROXY:DST` until it ages out: expire + `tcp.first` (default **120 s**,
   pf_ioctl.c:267).
4. The 1 Hz `pf_purge_thread` (pf.c:1262) — a plain kernel thread whose
   `td_msgport` is not a netisr port — unlinks the expired state:
   `pf_purge_expired_states` (pf.c:1591) → `pf_unlink_state` (pf.c:1483).
5. `pf_unlink_state` sees `PF_TCPS_PROXY_DST` and calls `pf_send_tcp()`
   with `TH_RST|TH_ACK` (pf.c:1487-1496) to spoof a RST from the backend to
   the client; `pf_send_tcp` calls `ip_output(m, NULL, NULL, 0, NULL, NULL)`
   directly (pf.c:2255).
6. `ip_output()` asserts at entry `ASSERT_NETISR_NCPUS(mycpuid)`
   (ip_output.c:185; macro netisr2.h:136-139) →
   `panic: thread %p cpu%d is not within netisr_ncpus %d`.
   KASSERT/KKASSERT are compiled only with `options INVARIANTS`
   (sys/sys/systm.h:95 ff.).

Same send, root-only contexts: DIOCCLRSTATES pf_ioctl.c:1591,
DIOCKILLSTATES pf_ioctl.c:1621/1676, module unload
(`pf_unload` → `shutdown_pf` pf_ioctl.c:3187 → `pf_clear_states`
pf_ioctl.c:3071) — the last one is the panic observed incidentally during
the DF-3337 run (`findings/poc/DF-3337/panic_unload_incidental.txt`).

## Reproduction (this run, guest X86_64_GENERIC #0 Thu Jul 2 2026, INVARIANTS, 6 CPUs)

`trigger.sh`: pf on lo0, `tcp.first 5`, synproxy rule on 127.0.0.1:8080
(no listener), one `nc 127.0.0.1 8080`.

* t+2 s: `pfctl -s state` → `all tcp 127.0.0.1:8080 <- 127.0.0.1:1309  PROXY:DST`
* t≈+6 s: ssh dies; serial console (`panic.txt`):
  `panic: thread 0xfffff800905f5f80 cpu2 is not within netisr_ncpus 6` with
  `ip_output ← ip_output ← pf_send_tcp ← pf_unlink_state ←
  pf_purge_expired_states ← pf_purge_thread` — the **purge-thread expiry**
  backtrace, distinct from and complementary to DF-3337's unload backtrace.
  This is the remotely-triggerable variant.

Remote analogue: two packets (SYN + handshake-completing ACK) from any
unauthenticated host against any synproxy-protected port with a dead
backend; panic on the next purge pass after `tcp.first` (120 s default).
synproxy is a mainstream edge-firewall configuration (DDoS protection), and
"backend down" is the exact situation it exists for, so exposure is
realistic wherever INVARIANTS kernels are deployed.

## Why not worse (honest ceiling)

* The trap is an INVARIANTS assert — and the **stock default config ships
  it**: `options INVARIANTS` at sys/config/X86_64_GENERIC:56. The reference
  guest boots exactly that config, so a default `nativekernel` build is a
  remotely-triggerable, unauthenticated, guaranteed panic. (Kernels custom
  built *without* INVARIANTS compile the assert out; there the send executes
  on the purge thread — an architecture violation with no demonstrated
  crash.)
* No attacker-controlled data reaches the packet beyond addr/port/seqs pf
  itself generated → no memory-corruption primitive, no uid0 chain.
  (Assessed against the escalation objective; this is a pure DoS.)

## Fix (fix.diff) and validation

Defer the RST to a netisr via heap netmsg — the established
`rtfree_async` pattern (sys/net/route.c:311-338):

* `struct pf_synproxy_rst` netmsg copies everything `pf_send_tcp` needs
  (af, addrs, ports, seq/ack, tag, rtableid, ALTQ qid/ecn_af) — the state
  and rule are freed before dispatch, so no pointer survives.
* Fast path preserved: when already on a netisr thread (packet path,
  pf.c:5006) the original direct send is kept.
* `pf_synproxy_rst_drain()` in `pf_unload()` (pf_ioctl.c:3394) waits
  (bounded, 10 × 100 ms) for in-flight netmsgs so the module is never
  freed under a queued netmsg referencing module text.

Validation on the same guest, patched pf.ko built in-guest (`-Werror`
clean, fix_build.log):

1. **Expiry trigger re-run** (`fixcheck.sh`): SURVIVED; state purged;
   the deferred spoofed RST still leaves the host — tcpdump on lo0:
   `03:50:14.023682 IP 127.0.0.1.8080 > 127.0.0.1.1485: Flags [R.], seq 0, ack 1`
   4.7 s after the connect (= 5 s tcp.first + purge cadence). Baseline
   panicked at exactly this point.
2. **Unload with live PROXY:DST state** (DF-3337's context): clean unload,
   `pf fully unloaded` (fix_unload_stress.log).
3. **8-state stress**: survived, 0 remaining states, `dmesg`/serial clean.

Baseline vs patched, same kernel, same rig, same module build path — the
only delta is fix.diff.

## Files

* `trigger.sh` — baseline panic rig (stock pf.ko)
* `panic.txt`, `run.log`, `serial_boot_full.log` — baseline evidence
* `fix.diff` — validated fix (pf.c + pf_ioctl.c + pfvar.h)
* `fixcheck.sh`, `fix_run.log`, `fix_unload_stress.log`, `fix_build.log` — fix validation
* `env.txt` — guest identity

## Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest `make -j6 nativekernel KERNCONF=X86_64_GENERIC` completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
