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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3449 Β· 13 files
FileTypeDescriptionSize
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 automated trigger.sh is 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 against sys/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)

  1. ipfw3 initialized, default to accept / ipfw3 module basic loaded / ipfw3 module dummynet loaded / DUMMYNET initialized (011031) on console.
  2. pipe 1 configured, rule 00100 pipe 1 added, ping through the pipe works (~44 ms: 10 ms delay + 1 Mbit/s shaping).
  3. kldunload dummynet3 succeeds although rule 100 references module 2 β€” dummynet3's MOD_UNLOAD never calls ip_fw3_unregister_module().
  4. 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 = Idle 0xffffffff8260d000 is exactly the former load address of dummynet3.ko (see kldstat in run.patched.log: 6 1 0xffffffff8260d000 4000 dummynet3.ko). The RIP equals the address filter_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.

VERDICT.md
↓ download raw

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") and ip_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 NULLs ip_dn_io_ptr/ ip_dn_ctl_ptr, but never calls ip_fw3_unregister_module(2) and never clears filter_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 path check_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 old check_pipe address.

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).

  1. sysctl net.filters_default_to_accept=1 (keep ssh alive through pfil).
  2. 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-0669 rn_flush(NULL) panic in ip_fw3_table.c:578); kldload dummynet3.ko.
  3. ipfw3 pipe 1 config bw 1Mbit/s delay 10ms; ipfw3 add 100 pipe 1 ip from any to any.
  4. Sanity ping through the pipe: OK (~44 ms).
  5. kldunload dummynet3 β†’ succeeds (no rule check β€” the bug).
  6. 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) 0xffffffff8260d000 is the exact former load address of dummynet3.ko (kldstat: 6 1 0xffffffff8260d000 4000 dummynet3.ko) β€” RIP is the stale check_pipe pointer still sitting in filter_funcs[2][0]. Guest drops to DDB; dead. See panic.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.c is itself dead code (absent from sys/conf/files; the dummynet3 KMOD builds only ip_dummynet3.c; nothing references the glue's symbols) β€” the live ip_dn_sockopt the 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 in ip_dummynet3.c as 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline (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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (KLD rebuilt from patched /usr/src, module-level fix)

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.