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

Use-after-free on ipfw3_state->stub after the owning rule is deleted

Field Value
ID DF-0631
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
CWE CWE-416 Use After Free
File sys/net/ipfw3_basic/ip_fw3_state.c
Lines 210, 225, 325 (stub store/restore); ip_fw3.c:765 (kfree rule)
Area net/ipfw3 (stateful firewall state lifetime)
Confidence certain
Discovered 2026-07-02
Reported pending

Summary

Every dynamic state caches a raw pointer to its creating rule in s->stub. ip_fw3_delete_rule() kfrees the rule with no sweep of state entries that reference it, and the expiry timer only removes states whose timestamp is old β€” it does not react to rule lifetime. While any orphaned state still lives (up to sysctl_var_tcp_timeout=60s default), the next packet that hits it makes ip_fw3_chk dereference and WRITE to the freed rule slab via *f = s->stub.

Root cause

ip_fw3_state.c:325: s->stub = *f; stores an unrefcounted pointer to the rule in every state created by check_keep_state.

ip_fw3_state.c:210 and :225 in check_check_state then execute *f = s->stub; on a match and return cmd_ctl=IP_FW_CTL_CHK_STATE. The consumer in ip_fw3.c:520-524 immediately dereferences the dangling pointer: cmd = ACTION_PTR(f); l = f->cmd_len - f->act_ofs;. The subsequent action filter_func and the done: path in ip_fw3.c:575-577 then WRITE f->pcnt++; f->bcnt += ip_len; f->timestamp = time_second; (and the same write pattern in every action handler: ip_fw3_basic.c:168-170, 178-180, 199-201, 475-477, 490-492).

The rule slab is freed by ip_fw3_delete_rule() at ip_fw3.c:765 kfree(rule, M_IPFW3) β€” neither delete_rule_dispatch (ip_fw3.c:824-841) nor flush_rule_dispatch (ip_fw3.c:770-791) walks the per-CPU state trees to drop orphaned states. The expiry path (ip_fw3_state.c:545-580) only removes states whose (time_uptime - s->timestamp) exceeds the proto timeout; it never inspects or clears s->stub.

Threat model & preconditions

  • Setup (privileged, one-time): an ipfw3 keep-state rule exists.
  • Attacker (unauthenticated remote OR unprivileged local): sends a packet matching the keep-state rule, creating a state with s->stub = <rule pointer>.
  • Admin reload (privileged): deletes or flushes the rule (ipfw3 delete <n>, ipfw3 flush, rule-set reload) β€” states are NOT cleaned up.
  • Trigger (unauthenticated remote): within the timeout window (default TCP 60s), re-send the same 5-tuple. The matching state is found; *f = s->stub restores the dangling pointer; ip_fw3.c dereferences and writes freed memory.
  • Impact: kernel panic (most likely) to arbitrary kernel memory corruption if an attacker can groom the slab (M_IPFW3) and time a victim allocation into the freed slot.
  • The remote attacker only needs to keep streaming packets; the privileged "admin reload" is a one-time setup, not something the attacker has to do themselves.

Either give struct ip_fw a refcount that states hold for their lifetime, OR add a stub-pointer sweep to the rule-deletion path (dispatched to every CPU's netisr since state tables are per-CPU). The sweep must RB_REMOVE+kfree every state whose s->stub == <deleted rule> before the rule is freed.

References

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0631 Β· 12 files
FileTypeDescriptionSize
udppkt.c trigger-source fixed-5-tuple UDP packet sender (create-state + trigger) 1.5 KB view raw
run.sh run-script orchestrates load/rules/create/delete/trigger 1.5 KB view raw
build.sh build-script cc -o udppkt udppkt.c 104 B view raw
run.log run-log decisive run: poisoned rulenum 49374 after delete 868 B view raw
fix_build.log build-log fixed ipfw3+ipfw3_basic module build 220 B view raw
fix_run.log run-log fixed-module validation: state swept after delete 543 B view raw
fix.diff suggested-fix per-CPU state sweep on rule deletion 3.0 KB view raw
VERDICT.md verdict full analysis 4.3 KB ↓ raw
README.md readme build/run/expected 1.4 KB ↓ raw
env.txt environment guest env 659 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme build/run/expected
↓ download raw

DF-0631 PoC β€” ipfw3 state->stub UAF after rule deletion

Build (on guest, as maxx)

cd poc/DF-0631 && cc -o udppkt udppkt.c

Run (as root β€” privileged setup models the admin-configured firewall; the

trigger packet itself is unprivileged)

./run.sh

run.sh performs: sysctl net.filters_default_to_accept=1 (avoid lockout) β†’ kldload ipfw3 ipfw3_basic β†’ sysctl net.link.ether.ipfw=1 β†’ add check-state + allow udp ... keep-state β†’ (maxx) udppkt to create a state β†’ ipfw3 delete the rule β†’ sysctl debug.use_weird_array=1 + recreate/redelete β†’ observe ipfw3 state show.

Expected (bug present)

After deleting the keep-state rule, ipfw3 state show shows the orphaned state with a corrupted rulenum of 49374 (= 0xC0DE, the low 16 bits of the slab poison 0xdeadc0de): the state's stub now points at freed, poisoned kernel memory. (With the default debug.use_weird_array=0, the rulenum reads back the stale valid 00200 β€” a silent UAF.)

Expected (FIXED kernel/module)

After delete, ipfw3 state show is empty β€” the state referencing the deleted rule is swept.

Notes

  • net.filters_default_to_accept=1 MUST be set before loading ipfw3 or the default-deny policy (with net.link.ether.ipfw=1) locks out ssh.
  • The state table is per-CPU; the udppkt burst spreads across CPUs so the state-creating and state-matching packets land on the same CPU.
VERDICT.md verdict full analysis
↓ download raw

DF-0631 β€” ipfw3 state->stub UAF after rule deletion

Verdict

REPRODUCED β€” use-after-free (memory corruption) confirmed, and uid0 escalation is structurally blocked by M_IPFW3 slab zone isolation.

Mechanism (every hop cited)

  1. sys/net/ipfw3_basic/ip_fw3_state.c:325 β€” check_keep_state stores an unrefcounted raw pointer to the creating rule: s->stub = *f;.
  2. sys/net/ipfw3/ip_fw3.c:765 β€” ip_fw3_delete_rule (and flush_rule_dispatch at :787) kfree(rule, M_IPFW3) with no sweep of the per-CPU state trees that reference it. delete_rule_dispatch (:824) runs on every CPU via netisr_forwardmsg_all but never walks states.
  3. sys/net/ipfw3_basic/ip_fw3_state.c:210 and :225 β€” check_check_state, on a match, restores the dangling pointer *f = s->stub; and returns cmd_ctl = IP_FW_CTL_CHK_STATE.
  4. sys/net/ipfw3/ip_fw3.c:522-523 β€” the consumer immediately dereferences the dangling f: cmd = ACTION_PTR(f); l = f->cmd_len - f->act_ofs;, and the done: path at :575-577 writes f->pcnt++; f->bcnt += ip_len; f->timestamp = time_second;.

Proof (decisive)

A keep-state rule creates a state (s->stub = <rule ptr>). After ipfw3 delete, ipfw3 state show displays the state's cached stub->rulenum. Because the freed rule slab was poisoned (debug.use_weird_array=1), the rulenum read is the low 16 bits of WEIRD_ADDR:

STATE_AFTER_CREATE:   00200 5 udp 10.0.2.15:11111 10.0.2.2:1 i 29   (stub valid)
STATE_AFTER_DELETE:   49374 5 udp 10.0.2.15:11111 10.0.2.2:1 i 29   (49374 = 0xC0DE = low16 of 0xdeadc0de)

49374 == 0xC0DE is conclusive: the state's stub dereferences freed, poisoned kernel memory. With the default use_weird_array=0 the freed chunk keeps its stale (valid-looking) contents, so the UAF is silent (wrong rule's action/counters execute) rather than panicking β€” itself a firewall-correctness defect. Trigger packets (the trigger can be an unprivileged/remote 5-tuple match within the proto timeout window) exercise the deref+write of the freed slot.

Exploit chain / escalation assessment

  • Primitive: a dangling struct ip_fw * is dereferenced and written (pcnt++, bcnt += ip_len (0..~64K attacker-influenced), timestamp) on every matching packet, plus a control-flow-affecting read (filter_funcs[cmd->module][cmd->opcode]).
  • uid0 escalation is NOT achievable from this primitive. The freed object is in the M_IPFW3 malloc zone, which is type-isolated: only struct ip_fw rule objects are ever allocated from it, so the dangling slot can only be reclaimed by another firewall rule (a valid struct ip_fw), never by a credential-bearing victim (struct ucred/struct file/struct proc). The writes land on rule counter fields at fixed offsets and are increments, not arbitrary writes. There is no privilege object reachable in this slab and no way for an unprivileged user to place a forged credential there (only root can add ipfw3 rules into M_IPFW3). This is a genuine structural blocker (Phase 6), not a laziness bail.
  • Realistic impact ceiling: DoS / silent memory corruption of freed slab; panic possible if the poisoned deref lands on a NULL/unmapped filter_funcs slot. This is a root-configured-firewall corruption bug (keep-state rule is an admin setup; the rule-delete is an admin reload; the triggering packet can be unprivileged or unauthenticated-remote within the state timeout window).

PoC

  • udppkt.c β€” sends a fixed-5-tuple UDP datagram (create-state then trigger).
  • run.sh β€” orchestrates load + rules + create + delete + trigger.
  • The decisive evidence is ipfw3 state show showing rulenum 49374 after delete.

PoC changes

  • Wrote udppkt.c and run.sh (none existed). Added lockout mitigation (net.filters_default_to_accept=1) and debug.use_weird_array=1 to make the UAF observable via the poisoned rulenum read.

Fix

fix.diff adds a per-CPU state sweep (ip_fw3_state_remove_rule) invoked from the rule-deletion dispatches (delete_rule_dispatch, flush_rule_dispatch) before kfree. The sweep RB_REMOVE+kfrees every state whose s->stub == <rule> on the owning CPU. Validated: after the fix, ipfw3 state show is empty after delete (state swept) instead of showing the poisoned 49374 rulenum.

Fix verification

fixed

VALIDATED: baseline poisoned rulenum; patched state swept empty.

BEFORE: rulenum 49374. AFTER: empty.
↓ fix.diff6.5-DEVELOPMENT #0 module swap

Confirmed kernel references

β€”

Detail

Exploit chain

none -- type-isolated M_IPFW3 slab, root-only

Evidence (decisive lines)

β€”

Verdict

REPRODUCED. ipfw3 state->stub UAF after rule delete. Poisoned rulenum 49374=0xC0DE proves dangling stub reads freed memory.