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

ipfw3 skipto stale next_rule cache: rule deletion leaves a dangling cached target; packet matching executes the freed rule (deny-all blackhole, wild filter_funcs dispatch, deterministic panic)

Summary

check_skipto() jumps through (*f)->next_rule, a per-rule cache filled by lookup_next_rule(). ip_fw3_delete_rule() frees a rule with no walk of the remaining chain to clear cached next_rule pointers - its own comment admits 'the caller is in charge of clearing rule pointers' and no caller does. After deleting a skipto target, the next packet re-enters the rule walk at freed memory: f->set/f->cmd_len/f->cmd are read from the freed M_IPFW3 chunk and filter_funcs[cmd->module][cmd->opcode](...) dispatches through freed (refillable, rule-body-controlled) bytes; terminal actions do f->pcnt++/f->bcnt+= - UAF write into the freed chunk. DEMONSTRATED (pack incl. libkvm krules walker imaging rule100->next_rule == &rule200 live): deny-all traffic blackhole (console 'ouch!' flood, ssh death) when the chunk is reused, and a deterministic Fatal trap 9 GPF at ip_fw3_chk+0x1a4 with debug.use_weird_array=1. Firewall rule churn (add skipto rule + delete its target - routine for dynamic blacklisting daemons) arms a per-CPU dangling pointer; every subsequent packet on the data path (unpriv traffic) executes freed memory. The freed chunk is refillable with attacker-chosen rule instruction words (same-zone M_IPFW3 reallocation) - controlled-content indirect-call setup; setup is root-gated (corruption/availability impact, not unpriv->root by itself). Fix validated on a rebuilt kernel (clear caches at deletion, FreeBSD flush_rule_pointers strategy): post-delete ping passes, no flood, no panic.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3439 Β· 12 files
FileTypeDescriptionSize
ipfw3x.c β€” 6.7 KB view raw
krules.c β€” 2.3 KB view raw
kfdump.c β€” 1.5 KB view raw
run.sh β€” 143 B view raw
run.log β€” 3.4 KB view raw
panic.txt β€” 3.5 KB view raw
panic_runA3.raw β€” 2.0 KB ↓ download
blackhole_console.txt β€” 618 B view raw
fix.diff β€” 880 B view raw
VERDICT.md β€” 5.9 KB ↓ raw
verdict.json β€” 5.0 KB view raw
manifest.json β€” 1.2 KB view raw
VERDICT.md
↓ download raw

DF-3439 β€” VERDICT

Status: reproduced (kernel use-after-free: deny-all blackhole + deterministic panic). Fix validated on a rebuilt kernel (single-hunk fix, bad behavior gone).

The bug

check_skipto() (sys/net/ipfw3_basic/ip_fw3_basic.c:175-185) jumps through a cached rule pointer:

if ((*f)->next_rule == NULL)
        lookup_next_rule(*f);          /* caches me->next_rule = target */
*f = (*f)->next_rule;                  /* jump */
*cmd_ctl = IP_FW_CTL_AGAIN;            /* re-enter the walk at the target */

lookup_next_rule() (ip_fw3_basic.c:104-124, twin at ip_fw3.c:288-307) scans me->next for the first rule with rulenum >= cmd->arg1 and stores the pointer in me->next_rule. The cache is never invalidated: ip_fw3_delete_rule() (sys/net/ipfw3/ip_fw3.c:757-768) unlinks and kfree()s a rule β€” its own comment says "The caller is in charge of clearing rule pointers to avoid dangling pointers" β€” but no caller (delete_rule_dispatch ip_fw3.c:824-841, ip_fw3_ctl_delete_rule ip_fw3.c:844-863) walks the chain to clear next_rule caches. (flush_rule_dispatch ip_fw3.c:771-791 frees all rules, so caches die with their owners β€” only single-rule deletion leaves dangling caches.)

Next packet through the skipto rule: *f = <freed chunk> β†’ goto again β†’ ctx->sets & (1 << f->set), f->cmd_len, f->cmd are read from freed memory and filter_funcs[cmd->module][cmd->opcode](...) (ip_fw3.c:506) dispatches through module/opcode bytes of freed (reusable, groomable) heap β€” the DF-0473 unvalidated-dispatch family reached from a dangling rule pointer. On a terminal action, f->pcnt++ / f->bcnt += (ip_fw3.c:575-577) writes into the freed chunk.

Reproduction (root; firewall rule churn is a normal root-daemon activity;

the trigger packet side needs no privilege)

Setup per run: sysctl net.filters_default_to_accept=1 before module load (so the default rule is ACCEPT and ssh survives), kldload ipfw3 ipfw3_basic, then via ipfw3x (raw setsockopt(IPPROTO_IP, IP_FW_X) client, included):

  1. add 200 protodeny 1 β€” rule "proto 1 -> deny icmp" (4 words)
  2. add 100 skipto 200 β€” action rule whose first packet caches rule100->next_rule = &rule200 (verified live via krules, a libkvm /dev/kmem walker: num=100 ... next_rule=0x...4f1837a0 == num=200 rule address)
  3. ping -c4 127.0.0.1 β†’ 100% loss (skipto β†’ live rule 200 β†’ deny), no panic
  4. del 200 β†’ rule 200 freed on every CPU; rule 100's cache dangles

Observed on the stock INVARIANTS kernel (#0):

  • Behavioral UAF: ICMP stays 100%-denied (sendto: Permission denied) after the only deny rule was deleted from a default-ACCEPT chain; the freed chunk is still executed through the stale cache. Once ordinary kernel activity reuses the chunk, every packet through the cache hits garbage and the console floods +++ ipfw: ouch!, skip past end of rules, denying packet (ip_fw3.c:570) β€” a total traffic blackhole that killed ssh (blackhole_console.txt).
  • Deterministic panic: with sysctl debug.use_weird_array=1 (freed-chunk poison 0xdeadc0de, kern_slaballoc.c:1560-1571) before the delete, the first packet after the delete dies with

Fatal trap 9: general protection fault while in kernel mode instruction pointer = 0x8:0xffffffff826001a4 Stopped at ip_fw3_chk+0x1a4: ret

(panic.txt): the walk dispatched through poisoned module/opcode bytes (filter_funcs[0xad][0xc0de]-style OOB function-pointer read, indirect call to garbage, frame smashed, GPF at the ret).

Primitive characterization

  • read: f->set, f->cmd_len, f->cmd[i] from the freed M_IPFW3 chunk
  • call: filter_funcs[module][opcode] with both indices from freed bytes β€” the chunk is refillable by a new rule (rule bodies are attacker-supplied instruction words), i.e. a controlled-content indirect-call setup for whoever can both delete the target and allocate a same-zone replacement (root on the box; the packet trigger itself is unprivileged)
  • write: f->pcnt++, f->bcnt += ip_len into the freed chunk on terminal actions (memory corruption of a foreign allocation once reused)

Escalation ceiling: setup requires firewall-admin privilege (root), so this is not an unpriv→root chain on its own; severity is from memory corruption + packet-path availability impact (deny-all blackhole) reachable through routine firewall rule churn.

Fix validation

fix.diff (authored after verification; generated with diff -u, applies with patch -p1): in delete_rule_dispatch(), before freeing, walk the per-CPU chain and NULL every ->next_rule that targets the deleted rule β€” the same strategy FreeBSD's ipfw uses (flush_rule_pointers).

  • vm.sh reset with-src; apply in guest /usr/src; make -j6 nativekernel (+install) β†’ kernel #1; identical PoC phases:
  • pre-delete identical (deny works, cache populated: next_rule == &rule200)
  • after del 200: dump shows rule100.next_rule = 0x0 (cleared; a CPU that already saw traffic re-cached onto the live default rule)
  • ping after delete: 0% loss (fresh lookup β†’ default accept)
  • no ouch flood, no panic, guest up.
  • fix_status: fixed (baseline reproduced on #0, patched #1 clean).

Files

  • ipfw3x.c β€” IP_FW_X rule client (add protodeny/skipto/allow, del, flush, show)
  • krules.c β€” libkvm walker dumping per-CPU fw3_ctx->rules incl. next_rule
  • kfdump.c β€” libkvm dump of filter_funcs[0][*] (registration sanity)
  • run.sh β€” phase-split reproduction script
  • run.log, panic.txt, blackhole_console.txt β€” captured evidence
  • fix.diff β€” the validated one-hunk fix

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

Identical PoC phases on the fixed kernel: pre-delete behavior unchanged (deny works, cache populated), after delete the cache is cleared (krules shows next_rule=0x0 or re-cached onto the live default rule), post-delete ping passes (0% loss, fresh lookup to default accept), no ouch flood, no panic, guest stays up.

findings/poc/DF-3439/VERDICT.md (Fix validation section); run.log tail
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Sep 12 18:50:09 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root adds skipto rule + target rule (routine firewall config); unprivileged packet populates the cache; root deletes the target rule (routine churn); next packet: UAF read of freed rule header + instructions -> filter_funcs[module][opcode] indirect call with freed-chunk indices (chunk refillable with attacker-chosen rule instruction words) and pcnt++/bcnt+= UAF writes on terminal actions. Setup needs firewall-admin; trigger is any packet; demonstrated impact: deny-all availability loss + deterministic panic; controlled-call escalation would additionally require same-zone refill choreography (not pursued; setup is already root-gated).

Evidence (decisive lines)

['findings/poc/DF-3439/panic.txt β€” Fatal trap 9 at ip_fw3_chk+0x1a4 after del+ping with use_weird_array=1', 'findings/poc/DF-3439/run.log β€” phase-by-phase: live deny, cache dump (next_rule==&rule200), post-delete deny-all, ouch-flood', "findings/poc/DF-3439/blackhole_console.txt β€” 'ouch!, skip past end of rules, denying packet' flood after freed-chunk reuse", 'findings/poc/DF-3439/fix.diff + VERDICT.md β€” fix validation: kernel #1 clears caches, post-delete ping 0% loss, no panic']

PoC changes

Original audit sketch had no runnable PoC. Wrote ipfw3x (raw IP_FW_X setsockopt client; the shipped /sbin/ipfw3 userland has no skipto parser), krules (libkvm /dev/kmem rule-chain walker to image the cache before/after delete), and a poison run using debug.use_weird_array. Two early PoC encodings were wrong (ipfw_insn is 2 words wide, LEN_OF_IPFWINSN==2; struct ipfw_ioc_rule pads to 48 so instruction arrays must be byte-packed at offset 36) β€” a misencoded 2nd instruction itself demonstrated the known DF-0473 unvalidated dispatch (call NULL, Fatal trap 12 RIP=0) before the encoding was fixed and the UAF runs captured.

Verified recommended fix

In delete_rule_dispatch(), walk the chain and NULL every ->next_rule equal to the rule about to be freed (see fix.diff); mirror in any future rule-move/flush paths.

Verdict

check_skipto() jumps through a per-rule cached next_rule pointer that is never invalidated on rule deletion (ip_fw3_delete_rule frees the target; no caller clears caches). Reproduced three ways on the stock INVARIANTS guest: (1) ICMP stays 100% denied after the only deny rule is deleted from a default-accept chain (stale freed rule still executing); (2) once the freed M_IPFW3 chunk is reused by ordinary kernel activity, every packet through the stale cache hits garbage -> 'ipfw: ouch!' console flood and a total ssh/traffic blackhole; (3) with debug.use_weird_array=1 (freed-chunk poison), the first packet after the delete panics: Fatal trap 9 GPF at ip_fw3_chk+0x1a4 (wild filter_funcs[module][opcode] indirect call from poisoned freed bytes). Live-memory dumps (libkvm /dev/kmem) show rule100->next_rule == &rule200 before the delete and still set after the free. Fix (clear targeting next_rule caches in delete_rule_dispatch) validated on a rebuilt kernel: cache cleared, post-delete ping passes via fresh lookup, no flood, no panic.