# 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:

```c
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.
