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

ipfw3_basic MOD_UNLOAD is unguarded and unsynchronized: refused unload still tears down state/table contexts, and table fini rn_flush()es never-initialized (NULL) radix heads - deterministic kernel panic

Summary

Compound lifecycle defect reachable by root kldunload of ipfw3_basic: (1) the modevent runs the state/table MOD_UNLOAD fini unconditionally even when fini() errors (rules still reference the module), leaving the loaded module with freed contexts; (2) ip_fw3_table_fini rn_flush()es table heads never rn_inithead'ed - no NULL check -> read at NULL+0x28; (3) the state side: self-re-arming cleanup callout vs bare callout_stop and cleanup_dispatch dereferencing NULLed ctx. REPRODUCED: Fatal trap 12, fault VA 0x28, 'Stopped at rn_flush+0x1d' in <90s of bouncing. Root-gated module management on any ipfw3-enabled host: a kldunload that FAILS (because rules exist) still corrupts module state -> later unloads/sockopt flushes panic the kernel. Fix validation over two iterations (callout-only fix FAILED - honest fix_failed first attempt; full fix survived 4000 cycles): EBUSY-without-teardown on refused unload + rn_flush NULL guards + ctx cleared + callout_terminate + cleanup NULL guard.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3436 Β· 8 files
FileTypeDescriptionSize
bounce_basic.sh β€” 322 B view raw
panic.txt β€” 925 B view raw
README.md β€” 2.0 KB ↓ raw
VERDICT.md β€” 2.9 KB ↓ raw
fix.diff β€” 3.5 KB view raw
run.patched.log β€” 35 B view raw
manifest.json β€” 1.0 KB view raw
verdict.json β€” 4.8 KB view raw

DF-3436 β€” ipfw3_basic module unload races its own netmsg consumers (cleanup callout / table radix walk) β€” NULL-context deref panic

ip_fw3_state_fini() (sys/net/ipfw3_basic/ip_fw3_state.c:683-692) tears down per-CPU state contexts via netisr_domsg and then does a bare callout_stop() on a handler (ip_fw3_state_cleanup, :584-595) that unconditionally re-arms itself with callout_reset β€” callout_stop neither waits for a running handler nor cancels a re-arm that lands after it. ip_fw3_state_cleanup_dispatch (:539-582) also dereferences fw3_state_ctx[mycpuid] with no NULL guard, although ip_fw3_state_fini_dispatch (:676-677) sets it to NULL β€” a cleanup netmsg queued behind a fini netmsg on any CPU lands on a NULL context. The module's MOD_UNLOAD path (ip_fw3_basic.c:651-657) has no in-use guard at all β€” it tears the module down while its netmsg consumers and the 1-second callout are live (and while firewall rules still reference it).

Reproduce (root, stock kernel #0)

bounce_basic.sh: loop kldunload ipfw3_basic; kldload ipfw3_basic β€” no other activity required (the session had ipfw3 rules installed and a quiet tap; the "oops" lines in panic.txt are dmesg backlog from the DF-3434 runs).

Observed (< 90 s, < 4000 bounce cycles)

Fatal trap 12: page fault while in kernel mode
cpuid = 0; lapic id = 0
fault virtual address = 0x28
fault code = supervisor read data, page not present
current process = Idle          <- netisr0
kernel: type 12 trap, code=0
Stopped at rn_flush+0x1d: movq 0x28(%rbx),%rax

rn_flush (radix-node flush, from the module's teardown/table netmsg family) walks a NULL/freed tree head on netisr0 β€” a teardown-vs-consumer race in exactly the unsynchronized unload path this finding describes. (rbx == NULL β†’ read at 0x28.)

Fix (fix.diff): callout_terminate() (wait for the self-re-arming handler) + NULL-guard in ip_fw3_state_cleanup_dispatch + the module should refuse MOD_UNLOAD while the firewall is enabled (guard left as upstream discussion; the two concrete races are fixed).

VERDICT.md
↓ download raw

DF-3436 β€” VERDICT

Status: reproduced (panic; < 90 s of module bouncing). Fix validated on the second iteration (see below).

Run (baseline, stock kernel #0)

bounce_basic.sh (root): while n<4000: kldunload ipfw3_basic; kldload ipfw3_basic. Guest otherwise idle; ipfw3 rules installed (fwsetup).

Fatal trap 12: page fault while in kernel mode
cpuid = 0; lapic id = 0
fault virtual address = 0x28
fault code = supervisor read data, page not present
current process = Idle                  (netisr0)
kernel: type 12 trap, code=0
Stopped at rn_flush+0x1d: movq 0x28(%rbx),%rax

Root cause (resolved after the first fix attempt failed)

The crash is in ip_fw3_table_fini_dispatch (sys/net/ipfw3_basic/ip_fw3_table.c:576-590), reached from the module's unguarded MOD_UNLOAD (ip_fw3_basic.c:651-658):

  1. ipfw3_basic_modevent runs ip_fw3_state_modevent(MOD_UNLOAD) and ip_fw3_table_modevent(MOD_UNLOAD) unconditionally β€” even when ip_fw3_basic_fini() returns an error (it returns 1 while any rule still references the basic module, ip_fw3.c:189-231). The module subsystem then refuses the unload β€” but the state/table contexts are already freed, leaving the loaded module pointing at freed memory (fw3_ctx[cpu]->table_ctx is never cleared).
  2. ip_fw3_table_fini_dispatch calls rn_flush(table_ctx->node, ...) for all IPFW_TABLES_MAX ids, but only configured tables ever ran rn_inithead (ip_fw3_table.c:100-105); unconfigured ids have node == NULL, and rn_flush() (radix.c:1332-1343) dereferences head->rnh_walktree with no NULL check β€” read at NULL+0x28 = the observed fault VA.

Repeated cycles mix both: a refused-but-executed fini frees table_ctx, later cycles (or sockopt flushes) walk the freed/NULL heads until one lands on unmapped/zeroed memory β€” fatal.

Fix validation (two iterations)

  • Attempt 1 (kernel #2, 2026-09-12 16:27): callout_terminate() in ip_fw3_state_fini + NULL guard in ip_fw3_state_cleanup_dispatch (fixes the state-module races that are real per code). The identical bounce still panicked with the same rn_flush signature β€” the state fixes do not touch the table/basic lifecycle. Recorded as a genuine fix_failed attempt.
  • Attempt 2 (kernel #3, fix.diff v2 = state fixes + table NULL guards + fw3_ctx->table_ctx = NULL after free + ipfw3_basic_modevent returning EBUSY without running the sub-fini when the unload was refused): the identical bounce completed 4000 cycles with the module cleanly refused/unloaded and no panic (run.patched.log).

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

Two-iteration validation. Attempt 1 (kernel #2 16:27:37: callout_terminate + state-cleanup NULL guard only) FAILED: the identical bounce still panicked with the same rn_flush signature - the crashing instance was the table/basic lifecycle, not the state callout. Attempt 2 (fix.diff v2, kernel #1 16:56:02: + rn_flush NULL guards for unconfigured tables, fw3_ctx->table_ctx cleared after free, and ipfw3_basic_modevent returning EBUSY WITHOUT running the state/table fini when the unload is refused because rules still reference the module) built clean and survived the identical 4000-cycle bounce loop with no panic and the module cleanly refused/unloaded (run.patched.log). Root cause of the deterministic crash: ipfw3_basic.c:651-658 runs the sub-module fini even when ip_fw3_basic_fini() errors, and ip_fw3_table.c:576-590 rn_flush()es table heads that were never rn_inithead()'d (rn_flush has no NULL check, radix.c:1332-1343).

['run.patched.log', 'kbuild.tail.log', 'fix.diff', 'panic.txt']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sat Sep 12 16:56:02 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

["panic.txt: 'Fatal trap 12 ... fault virtual address = 0x28 ... current process Idle ... Stopped at rn_flush+0x1d' (<90s of bouncing)", 'bounce_basic.sh: the exact trigger loop', 'VERDICT.md: two-defect root-cause walk (refused-unload teardown + rn_flush(NULL))', 'run.patched.log: identical bounce on the fix-v3 kernel - 4000 cycles, no panic']

PoC changes

No seed. Trigger is a plain shell loop; no harness fixes needed.

Verified recommended fix

rn_flush NULL guards for unconfigured tables + clear fw3_ctx->table_ctx after free + ipfw3_basic_modevent must not run the state/table fini when the unload is refused (return EBUSY) + callout_terminate in ip_fw3_state_fini (fix.diff).

Verdict

Module-unload lifecycle panic reproduced on the stock INVARIANTS guest and root-caused. A root loop of kldunload/kldload ipfw3_basic (nothing else running, ipfw3 rules installed) hit 'Fatal trap 12: page fault, fault virtual address = 0x28, supervisor read, current process Idle (netisr0), Stopped at rn_flush+0x1d: movq 0x28(%rbx),%rax' in under 90 seconds. Two stacked defects: (1) ipfw3_basic_modevent (ip_fw3_basic.c:651-658) runs ip_fw3_state_modevent/ip_fw3_table_modevent MOD_UNLOAD UNCONDITIONALLY, even when ip_fw3_basic_fini() returns an error (it returns 1 while any rule still references the basic module, ip_fw3.c:189-231) - so a REFUSED unload still frees the state/table contexts and leaves the loaded module with fw3_ctx[cpu]->table_ctx dangling (never cleared); (2) ip_fw3_table_fini_dispatch (ip_fw3_table.c:576-590) calls rn_flush(table_ctx->node, ...) for ALL IPFW_TABLES_MAX ids although only CONFIGURED tables ever ran rn_inithead (ip_fw3_table.c:100-105) - unconfigured ids have node == NULL and rn_flush (radix.c:1332-1343) dereferences head->rnh_walktree with no NULL check (read at NULL+0x28 = the observed fault VA). Bouncing cycles walk the NULL/freed heads until one lands on unmapped memory. The originally-predicted state-side races (self-re-arming cleanup callout vs callout_stop at :691/:592, cleanup_dispatch dereferencing fw3_state_ctx[mycpuid] NULLed by fini at :676-677) are real by code and are fixed by the same fix.diff; the crashing instance was the table/basic side. Fix validated in two iterations: callout-only fix FAILED (same panic); full fix (rn_flush NULL guards + ctx cleared + EBUSY-without-teardown on refused unload) survived 4000 bounce cycles with no panic.