pf synproxy PROXY:DST state purge emits spoofed RST from non-netisr context (purge thread / pfctl ioctls / module unload) -> guaranteed ip_output ASSERT_NETISR_NCPUS panic; default-config, remotely unauthenticated trigger
Summary
pf_unlink_state() calls pf_send_tcp() -> ip_output() to spoof a TH_RST|TH_ACK from the backend whenever a state is unlinked while src.state == PF_TCPS_PROXY_DST. A synproxy state enters PROXY:DST when the client completes the handshake with the firewall itself and the backend never answers; backend RSTs are silently dropped so the state sticks until tcp.first expiry (default 120s). The 1 Hz pf_purge_thread then unlinks it from a non-netisr thread and ip_output panics on its entry assert ASSERT_NETISR_NCPUS(mycpuid). VERIFIED on reference guest: 'panic: thread ... cpu2 is not within netisr_ncpus 6' with backtrace ip_output <- pf_send_tcp <- pf_unlink_state <- pf_purge_expired_states <- pf_purge_thread, ~6s after one TCP connect to a synproxy rule with a dead backend (tcp.first lowered). The same send panics from root-only contexts: DIOCCLRSTATES, DIOCKILLSTATES, and kldunload. Any unauthenticated remote host that can send two TCP packets (SYN, then handshake-completing ACK) to any synproxy-protected port whose backend is down or filtered - the exact scenario synproxy front-ends routinely face - creates a stuck PROXY:DST state; the kernel panics deterministically on the next purge pass. synproxy is a mainstream edge-firewall config, so default-config deployments panic on ordinary attacker background traffic + a dead backend. The assert is live in the stock default kernel (INVARIANTS at X86_64_GENERIC:56). Impact panic/DoS only (packet contents entirely pf-generated; no uid0 chain); non-INVARIANTS kernels compile the assert out but degrade to an architecture violation (network output from the purge thread under exclusively-held pf_consistency_lock, potentially sleeping inside ip_output). Fix validated (defer the RST to netisr via netmsg with parameter copy + drain at unload; direct fast path kept on netisr threads).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3340 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.4 KB | β raw | |
| VERDICT.md | β | 5.9 KB | β raw | |
| trigger.sh | β | 1.4 KB | view raw | |
| panic.txt | β | 623 B | view raw | |
| run.log | β | 1.1 KB | view raw | |
| serial_boot_full.log | β | 13.5 KB | view raw | |
| fix.diff | β | 4.9 KB | view raw | |
| fixcheck.sh | β | 998 B | view raw | |
| fix_build.log | β | 1.5 KB | view raw | |
| fix_run.log | β | 2.1 KB | view raw | |
| fix_unload_stress.log | β | 858 B | view raw | |
| env.txt | β | 404 B | view raw | |
| build.sh | β | 200 B | view raw | |
| run.sh | β | 170 B | view raw | |
| verdict.json | β | 4.9 KB | view raw |
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)
sh findings/poc/DF-3340/trigger.sh(pushed to guest): loads stockpf.ko, installsset 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 onenc 127.0.0.1 8080.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 inPROXY:DST(pfctl -s stateshows it).- ~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 1exactly 4.7 s after the connect (= tcp.first 5 s + purge cadence). kldunload pfwith 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 aftertcp.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_lockheld exclusively, can sleep inside ip_output's route path) β but no non-INVARIANTS crash was demonstrated. No memory-corruption primitive; not a uid0 path.
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)
- A
synproxy staterule makes pf terminate the TCP handshake with the client itself: on the client SYN,pf_create_state()setssrc.state = PF_TCPS_PROXY_SRC(pf.c:4268) and answers SYNACK (pf.c:4292). State timeout isPFTM_TCP_FIRST_PACKET(pf.c:4188),expire= creation (pf.c:4208). - 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 thenFAILs, so the timeout / expire refresh code at pf.c:4829-4846 is never reached. - 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:DSTuntil it ages out: expire +tcp.first(default 120 s, pf_ioctl.c:267). - The 1 Hz
pf_purge_thread(pf.c:1262) β a plain kernel thread whosetd_msgportis not a netisr port β unlinks the expired state:pf_purge_expired_states(pf.c:1591) βpf_unlink_state(pf.c:1483). pf_unlink_stateseesPF_TCPS_PROXY_DSTand callspf_send_tcp()withTH_RST|TH_ACK(pf.c:1487-1496) to spoof a RST from the backend to the client;pf_send_tcpcallsip_output(m, NULL, NULL, 0, NULL, NULL)directly (pf.c:2255).ip_output()asserts at entryASSERT_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 withoptions 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 6withip_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 INVARIANTSat sys/config/X86_64_GENERIC:56. The reference guest boots exactly that config, so a defaultnativekernelbuild 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_rstnetmsg copies everythingpf_send_tcpneeds (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()inpf_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):
- 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 14.7 s after the connect (= 5 s tcp.first + purge cadence). Baseline panicked at exactly this point. - Unload with live PROXY:DST state (DF-3337's context): clean unload,
pf fully unloaded(fix_unload_stress.log). - 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 evidencefix.diffβ validated fix (pf.c + pf_ioctl.c + pfvar.h)fixcheck.sh,fix_run.log,fix_unload_stress.log,fix_build.logβ fix validationenv.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.
Fix verification
fixedSingle-fix validation on the same guest/kernel: baseline stock pf.ko panicked deterministically on the expiry purge (panic.txt); pf.ko rebuilt with only fix.diff applied survived the identical trigger, purged the state, and still emitted the spoofed RST from netisr context (lo0 tcpdump 'Flags [R.]' at t+4.7s). The unload context (kldunload with live PROXY:DST state, DF-3337's panic) also survived, and an 8-state stress run left the serial console clean. Bounded in-flight netmsg drain added to pf_unload so the module cannot be freed under a queued netmsg.
fix_run.log / fix_unload_stress.log: expiry + unload + 8-state stress all survive on patched pf.ko; deferred RST still emitted (lo0 tcpdump); baseline panicked at the same point.
Confirmed kernel references
Detail
Evidence (decisive lines)
panic.txt (purge-thread backtrace); run.log (PROXY:DST state then connection death); fix_run.log (SURVIVED + tcpdump 'Flags [R.]' from 127.0.0.1.8080 at t+4.7s = deferred RST); fix_unload_stress.log (kldunload + 8-state stress); fix.diff; fix_build.log
PoC changes
Lead only asserted the unload-context panic (DF-3337 incidental). Built the minimal expiry rig (synproxy on lo0:8080, no backend, tcp.first 5) to reach the remotely-triggerable purge-thread context; verified PF_TCPS_PROXY_DST entry and RST-ignoring behavior via pfctl -s state and tcpdump; fix authored after verification and validated in the same session.
Verified recommended fix
Defer the synproxy PROXY:DST RST in pf_unlink_state() to a netisr via a parameter-copying heap netmsg (rtfree_async pattern), keep direct send when already on a netisr thread, and drain in-flight netmsgs in pf_unload().
Verdict
pf_unlink_state() sends a spoofed RST via pf_send_tcp()->ip_output() whenever a synproxy state is unlinked in PF_TCPS_PROXY_DST (backend never answered). On state expiry this runs on pf_purge_thread, a non-netisr thread, tripping the guaranteed INVARIANTS assert ASSERT_NETISR_NCPUS at ip_output.c:185 ('thread %p cpu%d is not within netisr_ncpus %d'). Reproduced on the reference INVARIANTS guest ~6 s after a single TCP connect to a synproxy rule with a dead backend (tcp.first shortened to 5 s; default 120 s) - backtrace ip_output<-pf_send_tcp<-pf_unlink_state<-pf_purge_expired_states<-pf_purge_thread (panic.txt). The same send panics from DIOCCLRSTATES/DIOCKILLSTATES/kldunload (root-only; the unload variant is DF-3337's incidental panic). Trigger is remotely reachable and unauthenticated against any synproxy-protected port whose backend is down, but the panic requires an INVARIANTS kernel: the assert compiles out on production GENERIC, leaving an architecture violation (network output from the purge thread under exclusive pf_consistency_lock) with no demonstrated crash. Not a memory-corruption primitive; no uid0 chain. fix.diff (defer via heap netmsg to netisr, rtfree_async pattern, fast path kept for netisr callers, in-flight drain in pf_unload) validated on a rebuilt in-guest pf.ko: expiry trigger survives with the spoofed RST still emitted on lo0, unload with live PROXY:DST state clean, 8-state stress clean, serial console clean.
No comments yet.