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->stubrestores the dangling pointer;ip_fw3.cdereferences 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.
Recommended fix
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
sys/net/ipfw3_basic/ip_fw3_state.c:325βs->stub = *f(stores rule pointer).sys/net/ipfw3_basic/ip_fw3_state.c:210,225β*f = s->stub(restores dangling pointer on match).sys/net/ipfw3_basic/ip_fw3.c:765βkfree(rule, M_IPFW3)(no state sweep).sys/net/ipfw3_basic/ip_fw3_state.c:545-580β cleanup only removes expired states, never sweeps stub references.
Timeline
- 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
- 2026-07-02 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0631 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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=1MUST be set before loading ipfw3 or the default-deny policy (withnet.link.ether.ipfw=1) locks out ssh.- The state table is per-CPU; the
udppktburst spreads across CPUs so the state-creating and state-matching packets land on the same CPU.
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)
sys/net/ipfw3_basic/ip_fw3_state.c:325βcheck_keep_statestores an unrefcounted raw pointer to the creating rule:s->stub = *f;.sys/net/ipfw3/ip_fw3.c:765βip_fw3_delete_rule(andflush_rule_dispatchat: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 vianetisr_forwardmsg_allbut never walks states.sys/net/ipfw3_basic/ip_fw3_state.c:210and:225βcheck_check_state, on a match, restores the dangling pointer*f = s->stub;and returnscmd_ctl = IP_FW_CTL_CHK_STATE.sys/net/ipfw3/ip_fw3.c:522-523β the consumer immediately dereferences the danglingf:cmd = ACTION_PTR(f); l = f->cmd_len - f->act_ofs;, and thedone:path at:575-577writesf->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_fwrule objects are ever allocated from it, so the dangling slot can only be reclaimed by another firewall rule (a validstruct 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_funcsslot. 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 showshowing rulenum49374after delete.
PoC changes
- Wrote
udppkt.candrun.sh(none existed). Added lockout mitigation (net.filters_default_to_accept=1) anddebug.use_weird_array=1to 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
fixedVALIDATED: baseline poisoned rulenum; patched state swept empty.
BEFORE: rulenum 49374. AFTER: empty.
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.
No comments yet.