β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3340

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3340 Β· 15 files
FileTypeDescriptionSize
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)

  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.
VERDICT.md
↓ download raw

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 FAILs, 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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-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.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (module-only fix: pf.ko rebuilt from patched /usr/src/sys/net/pf)

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.