dummynet3 MOD_UNLOAD never unregisters its ipfw3 hooks: stale filter_funcs[2][] and module registration make check_body()'s unguarded indirect call execute unmapped module text (deterministic panic)
Summary
ip_dn_init registers MODULE_DUMMYNET_ID(2) and filter_funcs[2][O_DUMMYNET_PIPE/QUEUE]=check_pipe, but ip_dn_stop_dispatch (MOD_UNLOAD) only flushes and NULLs the io/ctl ptrs - it never calls ip_fw3_unregister_module() unlike every sibling module. After kldunload dummynet3 (which succeeds even with live pipe rules): (1) ipfw3 rules with pipe/queue actions call filter_funcs[2][0] which still points into the unmapped module text via check_body's NULL-check-free indirect call; (2) fw3_modules still reports dummynet loaded, so NEW pipe/queue rules compile against the dead module. VERIFIED deterministic on stock guest: kldload ipfw3+basic+dummynet3, pipe rule, kldunload dummynet3 SUCCEEDS (the bug), then one unpriv ping -> Fatal trap 12, RIP == former dummynet3.ko text base (kldstat-proven), guest dead in DDB - no race required, the stale state is permanent. Ceiling: KLD VA reuse turns the call into execution of chosen module code, but setup is root-gated; honest impact panic/DoS. PLATFORM FACT: glue file is dead code (not in conf/files) but ip_dummynet3.c is live. Fix validated (unregister + EBUSY while rules reference, error propagated): unload refused, clean after rule delete, no panic.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3449 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.0 KB | β raw | |
| VERDICT.md | β | 5.0 KB | β raw | |
| trigger.sh | β | 1.4 KB | view raw | |
| trigger_fixed.sh | β | 1.4 KB | view raw | |
| run.sh | β | 190 B | view raw | |
| panic.txt | β | 1.3 KB | view raw | |
| console_full.txt | β | 2.2 KB | view raw | |
| run.patched.log | β | 2.2 KB | view raw | |
| env.txt | β | 477 B | view raw | |
| fix.diff | β | 1.6 KB | view raw | |
| code_hash.txt | β | 202 B | view raw | |
| verdict.json | β | 5.1 KB | view raw | |
| manifest.json | β | 1.2 KB | view raw |
DF-3449 β dummynet3 KLD unload leaves stale ipfw3 filter hooks β deterministic call into unmapped module text
Files
trigger.shβ full reproduction, runs detached on the guest (root).trigger_fixed.shβ fix-validation run (patched module).run.shβ step-by-step equivalent (documented; the automatedtrigger.shis what was actually executed because the panic kills ssh).panic.txt/console_full.txtβ serial-console capture of the panic.run.patched.logβ full untrimmed fixed-module run (guest alive at end).fix.diffβ git-apply-able patch againstsys/net/dummynet3/ip_dummynet3.c.env.txtβ guest uname / kldstat / securelevel.
Build (on the guest, as root)
for d in ipfw3 ipfw3_basic dummynet3; do
(cd /usr/src/sys/net/$d && make obj && make)
done
# artifacts:
# /usr/obj/usr/src/sys/net/ipfw3/ipfw3.ko
# /usr/obj/usr/src/sys/net/ipfw3_basic/ipfw3_basic.ko
# /usr/obj/usr/src/sys/net/dummynet3/dummynet3.ko
Run (baseline, unpatched)
sh /root/df3449_trigger.sh # uploaded from trigger.sh, run detached
The script must run detached (nohup ... &) because the panic kills ssh.
Note: ipfw3_basic.ko is loaded standalone BEFORE dummynet3 on purpose β
dummynet3 MODULE_DEPENDs on ipfw3_basic, and the kldunload dependency
cascade would otherwise unload basic first and trip the known, separate
DF-0669 rn_flush(NULL) panic in ipfw3_table.c before our bug can fire.
Expected output (baseline)
ipfw3 initialized, default to accept/ipfw3 module basic loaded/ipfw3 module dummynet loaded/DUMMYNET initialized (011031)on console.- pipe 1 configured, rule
00100 pipe 1added, ping through the pipe works (~44 ms: 10 ms delay + 1 Mbit/s shaping). kldunload dummynet3succeeds although rule 100 references module 2 β dummynet3's MOD_UNLOAD never callsip_fw3_unregister_module().- First packet after the unload:
Fatal trap 12: page fault while in kernel mode fault virtual address = 0xffffffff8260d000 fault code = supervisor read instruction, page not present instruction pointer = 0x8:0xffffffff8260d000 current process = Idle0xffffffff8260d000 is exactly the former load address of dummynet3.ko (seekldstatin run.patched.log:6 1 0xffffffff8260d000 4000 dummynet3.ko). The RIP equals the addressfilter_funcs[2][O_DUMMYNET_PIPE](=check_pipe) still holds: check_body() (ip_fw3.c:506) calls it with no NULL check after the module text was unmapped. Kernel drops into DDB; guest is dead.
Fix validation (patched)
Apply fix.diff in /usr/src, rebuild dummynet3.ko, run trigger_fixed.sh:
- kldunload dummynet3 with the rule present β Device busy (EBUSY), rule
and traffic keep working;
- after ipfw3 delete 100 the unload succeeds (ipfw3 module dummynet unloaded
on console), ping works, no panic;
- adding a new pipe rule after the unload is rejected by the CLI because the
module is no longer registered.
Full log: run.patched.log.
DF-3449 VERDICT
Status: reproduced (deterministic kernel panic). Fix: validated. Impact: panic (privileged-setup DoS; stale call site is a text-UAF of the freed/unmapped KLD β control-flow hijack if the VA is re-populated before the call, e.g. by a subsequent kldload).
Root cause (source)
sys/net/dummynet3/ip_dummynet3.c:1995-1999βip_dn_init()registers the module with ipfw3 and installs the filter functions:ip_fw3_register_module(MODULE_DUMMYNET_ID /* 2 */, "dummynet")andip_fw3_register_filter_funcs(2, O_DUMMYNET_PIPE /* 0 */, check_pipe)/... O_DUMMYNET_QUEUE /* 1 */, check_pipe.sys/net/dummynet3/ip_dummynet3.c:2013-2023βip_dn_stop_dispatch()(the MOD_UNLOAD body) flushes pipes and NULLsip_dn_io_ptr/ip_dn_ctl_ptr, but never callsip_fw3_unregister_module(2)and never clearsfilter_funcs[2][]. Contrast with every sibling module:ip_fw3_basic.c:640,ip_fw3_nat.c:1056,ip_fw3_layer2.c:201,ip_fw3_layer4.c:229β all unregister on unload, and the unregister also refuses (returns 1) while any rule still references the module.sys/net/ipfw3/ip_fw3.c:505-507β the packet pathcheck_body(): (filter_funcs[cmd->module][cmd->opcode])(&cmd_ctl, ...)is an unguarded indirect call; after kldunload unmaps the module text the slot still holds the oldcheck_pipeaddress.
Consequences of the missing unregister:
1. Rules containing pipe/queue actions survive the unload and their next
match calls into unmapped text (the reproduced panic).
2. fw3_modules[] still reports "dummynet" as loaded after the unload, so
new pipe/queue rules can be compiled against the dead module
(re-armed stale state).
3. filter_funcs[2][] stays populated, so even rule-less systems keep a
dangling slot reachable by any later rule add.
Reproduction (guest: X86_64_GENERIC, INVARIANTS, DF 6.5-DEVELOPMENT
0 Thu Jul 2 06:02:54 UTC 2026)
Setup is root-gated (kldload + ipfw3 raw-socket config), the trigger packet
is any traffic through the firewall (e.g. unprivileged ping).
sysctl net.filters_default_to_accept=1(keep ssh alive through pfil).kldload ipfw3.ko; kldload ipfw3_basic.ko(standalone β holds a reference so the unload's dependency cascade cannot unload basic first and trip the known DF-0669rn_flush(NULL)panic in ip_fw3_table.c:578);kldload dummynet3.ko.ipfw3 pipe 1 config bw 1Mbit/s delay 10ms; ipfw3 add 100 pipe 1 ip from any to any.- Sanity ping through the pipe: OK (~44 ms).
kldunload dummynet3β succeeds (no rule check β the bug).ping -c 2 127.0.0.1β Fatal trap 12: -fault virtual address = 0xffffffff8260d000-fault code = supervisor read instruction, page not present-instruction pointer = 0x8:0xffffffff8260d000-current process = Idle(netisr, pfil hook context)0xffffffff8260d000is the exact former load address ofdummynet3.ko(kldstat: 6 1 0xffffffff8260d000 4000 dummynet3.ko) β RIP is the stalecheck_pipepointer still sitting infilter_funcs[2][0]. Guest drops to DDB; dead. Seepanic.txt/console_full.txt.
Fix validation
fix.diff makes ip_dn_stop_dispatch() call
ip_fw3_unregister_module(MODULE_DUMMYNET_ID) first and reply EBUSY when
rules still reference the module; ip_dn_stop()/dummynet_modevent()
propagate the error so the linker keeps the module resident.
Patched run (run.patched.log, guest stays up):
- kldunload dummynet3 with rule 100 present β Device busy (RC=1);
ping through the pipe still works.
- ipfw3 delete 100 β kldunload dummynet3 β RC=0, console prints
ipfw3 module dummynet unloaded (register torn down), ping after unload
works, no panic.
- ipfw3 add 200 pipe 1 ... after the unload β rejected (module no longer
registered) β closes re-arming vector (2) above.
Notes
- The panic observed on a naive unload (without holding ipfw3_basic loaded)
is a different, already-known bug: DF-0669 (
rn_flush(NULL)on never-created tables during the dependency-cascade unload of ipfw3_basic, ip_fw3_table.c:578). It is documented here only because it masks this finding's trigger path unless basic is pinned. - The audited file
sys/net/dummynet3/ip_dummynet3_glue.cis itself dead code (absent fromsys/conf/files; the dummynet3 KMOD builds onlyip_dummynet3.c; nothing references the glue's symbols) β the liveip_dn_sockoptthe system dispatches to is the v1 twin's (sys/net/dummynet/ip_dummynet_glue.c,optional inet). This finding is filed against the glue file as the ipfw3-hook side of the dummynet3 interface, with the defect located inip_dummynet3.cas cited.
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
fixedBaseline (unpatched dummynet3.ko): kldunload succeeds with live pipe rule; first ping panics at RIP = former module text base. Patched (fix.diff applied in /usr/src, module rebuilt): unload refused with EBUSY while rule 100 exists (traffic keeps flowing through the pipe); after ipfw3 delete 100 the unload completes cleanly, post-unload ping works with no panic, and compiling a new pipe rule is rejected because the module registration is gone. Guest alive at end of run.
["run.patched.log (full untrimmed): UNLOAD_WITH_RULE_RC=1 / 'kldunload: can't unload file: Device busy'; UNLOAD_AFTER_DELETE_RC=0; PING_AFTER_UNLOAD_OK; ADDRULE_AFTER_UNLOAD_RC=64; FIX_VALIDATED", 'panic.txt: baseline Fatal trap 12 at 0xffffffff8260d000', 'fix.diff']
Confirmed kernel references
Detail
Exploit chain
root: kldload ipfw3(+basic pinned) + dummynet3; ipfw3 add 100 pipe 1 ...; kldunload dummynet3 (succeeds - the bug); any unpriv traffic (ping) -> ip_fw3.c:506 check_body calls stale filter_funcs[2][0] == unmapped check_pipe -> Fatal trap 12, RIP = former module base. Escalation ceiling: if another KLD is loaded into the freed VA before the trigger packet, the indirect call lands in attacker-chosen module code with an ipfw3 calling convention - chaotic control-flow hijack, not developed further because setup already requires root (kldunload): impact honestly capped at panic/DoS.
Evidence (decisive lines)
['panic.txt: fault virtual address = 0xffffffff8260d000, instruction pointer = 0x8:0xffffffff8260d000, supervisor read instruction page not present, current process = Idle', "run.patched.log: kldstat line '6 1 0xffffffff8260d000 4000 dummynet3.ko' proving RIP == former module base", 'run.patched.log: baseline-free fixed run - UNLOAD_WITH_RULE_RC=1 (Device busy), UNLOAD_AFTER_DELETE_RC=0, PING_AFTER_UNLOAD_OK, ADDRULE_AFTER_UNLOAD_RC=64, FIX_VALIDATED, guest alive', 'console_full.txt / run.patched.log console section: load/unload kprintf trail', 'fix.diff: ip_fw3_unregister_module(MODULE_DUMMYNET_ID) + EBUSY propagation']
PoC changes
Seed sketch had never been run: rewrote as a detached guest-side script (panic kills ssh mid-run); added net.filters_default_to_accept=1 (default-deny otherwise cuts ssh at kldload); added standalone pre-load of ipfw3_basic.ko because dummynet3's MODULE_DEPEND cascade otherwise unloads basic first and trips the known DF-0669 rn_flush(NULL) panic before our trigger can fire; configured pipe 1 before adding the rule so pre-unload sanity traffic flows.
Verified recommended fix
Call ip_fw3_unregister_module(MODULE_DUMMYNET_ID) at the head of ip_dn_stop_dispatch(), replying EBUSY while ipfw3 rules still reference the module, and propagate the error through ip_dn_stop()/dummynet_modevent() so the linker keeps the module resident (mirror ipfw3_basic/nat/layer2/layer4).
Verdict
Deterministic panic reproduced on the stock INVARIANTS guest: kldunload dummynet3 succeeds while an ipfw3 pipe rule references module 2 because dummynet3's MOD_UNLOAD never calls ip_fw3_unregister_module(), leaving filter_funcs[2][O_DUMMYNET_PIPE] pointing at the unmapped module text; the next packet through ipfw3 executes the stale pointer (RIP == former dummynet3.ko load address 0xffffffff8260d000, execute page-fault, Idle/netisr context). Root-gated setup, unprivilege-gated trigger packet; panic-grade DoS, text-UAF primitive. fix.diff (add unregister+EBUSY, propagate error) validated in-guest: unload refused while rule exists, clean unload after rule deletion, no panic, post-unload pipe-rule compilation rejected.
No comments yet.