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)
PoC verification
Evidence pack
findings/poc/DF-3439 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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):
add 200 protodeny 1β rule "proto 1 -> deny icmp" (4 words)add 100 skipto 200β action rule whose first packet cachesrule100->next_rule = &rule200(verified live viakrules, a libkvm/dev/kmemwalker:num=100 ... next_rule=0x...4f1837a0==num=200rule address)ping -c4 127.0.0.1β 100% loss (skipto β live rule 200 β deny), no panicdel 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_leninto 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 showsrule100.next_rule = 0x0(cleared; a CPU that already saw traffic re-cached onto the live default rule) pingafter 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-CPUfw3_ctx->rulesincl.next_rulekfdump.cβ libkvm dump offilter_funcs[0][*](registration sanity)run.shβ phase-split reproduction scriptrun.log,panic.txt,blackhole_console.txtβ captured evidencefix.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
fixedIdentical 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
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.
No comments yet.