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)
PoC verification
Evidence pack
findings/poc/DF-3436 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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).
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):
ipfw3_basic_modeventrunsip_fw3_state_modevent(MOD_UNLOAD)andip_fw3_table_modevent(MOD_UNLOAD)unconditionally β even whenip_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_ctxis never cleared).ip_fw3_table_fini_dispatchcallsrn_flush(table_ctx->node, ...)for allIPFW_TABLES_MAXids, but only configured tables ever ranrn_inithead(ip_fw3_table.c:100-105); unconfigured ids havenode == NULL, andrn_flush()(radix.c:1332-1343) dereferenceshead->rnh_walktreewith 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()inip_fw3_state_fini+ NULL guard inip_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 = NULLafter free +ipfw3_basic_modeventreturning 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
fixedTwo-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']
Confirmed kernel references
- sys/net/ipfw3_basic/ip_fw3_state.c:539
- sys/net/ipfw3_basic/ip_fw3_state.c:542
- sys/net/ipfw3_basic/ip_fw3_state.c:584
- sys/net/ipfw3_basic/ip_fw3_state.c:592
- sys/net/ipfw3_basic/ip_fw3_state.c:691
- sys/net/ipfw3_basic/ip_fw3_basic.c:651
- sys/net/ipfw3_basic/ip_fw3_table.c:576
- sys/net/ipfw3_basic/ip_fw3_table.c:583
- sys/net/radix.c:1332
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.
No comments yet.