# DF-0472 — Verdict

**Verdict: REPRODUCED (info leak + OOB-call panic). FIX VALIDATED.**

## Summary

`ip_fw3_ctl_add_rule` (`sys/net/ipfw3/ip_fw3.c:951`) accepts a firewall rule
from a raw socket (`IP_FW_X` / `IP_FW_ADD`) and validates only the *total*
`sopt_valsize` (`[sizeof(ioc_rule)-sizeof(ipfw_insn) .. 1020]`), never
`ioc_rule->cmd_len` / `act_ofs`. It `krealloc()`s the buffer to 1020 bytes
without `M_ZERO`, then `add_rule_dispatch` (`:655`) does
`bcopy(ioc_rule->cmd, rule->cmd, cmd_len*4)`. With `cmd_len=255` but only one
8-byte instruction actually supplied, that `bcopy` reads 1020 bytes from offset
36 of a 1020-byte (kmalloc-1024) buffer: ~976 bytes of uninitialized krealloc
tail **plus ~32 bytes over-read into the neighbouring slab object**. The garbage
becomes the rule and is:

1. **leaked back to userland** by `ip_fw3_ctl_get_rules` (`:1026`,
   `bcopy(rule->cmd, ioc->cmd, ioc->cmd_len*4)` via `IP_FW_GET`) — CWE-125/200;
2. **used as `filter_funcs[module][opcode]` indices** by `ip_fw3_chk` (`:506`)
   when the firewall evaluates a packet — CWE-787 OOB indirect call.

## Mechanism (every hop cited)

- **Trigger (root):** `socket(AF_INET, SOCK_RAW, IPPROTO_RAW)` →
  `setsockopt(IPPROTO_IP, IP_FW_X=49, [x_hdr.opcode=IP_FW_ADD=50][ioc_rule], 52)`.
  `raw_ip.c:385` `rip_ctloutput` → `ip_fw3_sockopt` (`ip_fw3_glue.c:51`) →
  `ip_fw3_ctl_x` (`ip_fw3.c:1038`) strips the 4-byte `x_header` → `ip_fw3_ctl`
  → `ip_fw3_ctl_sockopt` (`:1138`) case `IP_FW_ADD` → `ip_fw3_ctl_add_rule`
  (`:951`).
- **Missing validation (`:956-965`):** only `size ∈ [40,1020]` is checked; no
  check that `cmd_len` is consistent with `size`. `krealloc(sopt_val, 1020,
  M_TEMP, M_WAITOK)` (`:962`) grows the 52-byte buffer to 1020 bytes; the tail
  `[48,1020)` is uninitialized heap.
- **Over-read (`:655`):** `rule->cmd_len = ioc_rule->cmd_len` (=255);
  `bcopy(ioc_rule->cmd, rule->cmd, 255*4=1020)` reads `[36,1056)` of the
  1020-byte buffer → `[1024,1056)` is past the slab object (neighbour heap).
- **Info leak (`:1026`):** `IP_FW_GET` → `ip_fw3_ctl_get_rules` copies
  `rule->cmd` (1020 bytes) back to userland.
- **OOB call (`:506`):** when the firewall is enabled and a packet is evaluated,
  `ip_fw3_chk` iterates the rule's cmds and calls
  `(filter_funcs[cmd->module][cmd->opcode])(...)`. `filter_funcs` is
  `[10][100]`; attacker `module=0x80, opcode=0x80` indexes entry 12928, far past
  the 1000-entry array.

## Evidence

### Info leak (deterministic, varies run-to-run)

`./leak` on the unpatched `#0` kernel returns the garbage rule with non-zero
leaked bytes whose count/offset vary across fresh runs (12 / 5 / 28 non-zero
bytes), proving genuine uninitialized heap rather than deterministic output.
Observed leaked content includes **ASCII path strings** from kernel namecache /
vnode-path buffers and a **kernel virtual address** `0xfffff8008db3b000`
(bytes `00 b0 b3 8d 00 f8 ff ff`, little-endian). Full hex in `leak_sample.txt`.

### OOB-call panic (trap 9)

`./panic` installs a rule with `cmd[0].module=0x80 opcode=0x80`, enables the
firewall, and sends one UDP packet. The guest panics (full signature in
`panic.txt`):

```
Fatal trap 9: general protection fault while in kernel mode
instruction pointer = 0x8:0xffffffff826001a4   (ipfw3.ko+0x1a4)
current process = Idle
Stopped at  ip_fw3_chk+0x1a4:  ret
```

The faulting RIP is inside `ipfw3.ko` (loaded at `0xffffffff82600000`); ddb
symbolises it as `ip_fw3_chk+0x1a4`, i.e. the `filter_funcs[module][opcode]`
indirect call site (`:506`). The wild call ran a few instructions off the
corrupted pointer/stack and faulted on `ret` — proof the attacker-controlled
`module`/`opcode` reached the indirect call.

## Impact (honest)

- **Root-only trigger.** No privilege boundary is crossed from an unprivileged
  user: the ctl path needs a raw socket (root) and the module must be loaded.
  Relevance: compromised root process, setuid ipfw3 front-end, jail escape.
- **Info leak:** discloses ~1 KB of neighbouring kernel heap per call, including
  kernel pointers (`0xfffff800........`). On a KASLR-enabled build this defeats
  KASLR; here KASLR is off so the ceiling is heap-content disclosure.
- **OOB call:** a kernel memory-corruption primitive (attacker-influenced
  indirect call). On this guest SMEP/SMAP are OFF, so a heap-grooming chain that
  lands a chosen value at `filter_funcs[0x80][0x80]` could redirect execution to
  userspace shellcode (`commit_creds(prepare_kernel_cred(0))`) for root→kernel
  code execution; demonstrated here at the panic (DoS) level. Because the trigger
  is already root, the LPE chain is moot — the value is the memory-corruption
  primitive itself (CWE-787) plus the info leak.

## The fix (`fix.diff`)

One hunk in `ip_fw3_ctl_add_rule`, inserted after `ioc_rule = sopt->sopt_val;`
and before `ip_fw3_add_rule(ioc_rule);`:

```c
if (ioc_rule->cmd_len > IPFW_RULE_SIZE_MAX -
        ((sizeof(*ioc_rule) - sizeof(ipfw_insn)) / sizeof(uint32_t)) ||
    ioc_rule->act_ofs >= ioc_rule->cmd_len ||
    size < IOC_RULESIZE(ioc_rule)) {
    return EINVAL;
}
```

This rejects a rule when:
- `cmd_len` exceeds the words that fit in the 255-word buffer after the 10-word
  header (`cmd_len <= 245`) — closes the slab over-read;
- `act_ofs >= cmd_len` — closes the `ACTION_PTR` OOB;
- the supplied `size` is smaller than `IOC_RULESIZE(ioc_rule)` (= 40 +
  `cmd_len*4`) — closes the uninitialized-krealloc-tail read.

A legitimate rule (`cmd_len=2`, full data, `act_ofs=0`) is still accepted
(verified: `rc=0`).

## Fix validation (Phase 8)

Because `ipfw3` is a loadable module, the fix was validated by rebuilding only
`ipfw3.ko` (`make KERNBUILDDIR=.../X86_64_GENERIC` in `sys/net/ipfw3`,
`-Werror`, rc=0) and hot-swapping it (`kldunload`/`cp`/`kldload`), no kernel
rebuild/reboot required.

| test                 | unpatched `#0` module                    | patched module                          |
|----------------------|------------------------------------------|-----------------------------------------|
| `./leak` ADD         | `rc=0`, rule installed                   | `rc=-1 errno=22 (EINVAL)`               |
| `./leak` leaked bytes| 12–41 non-zero (paths + `0xfffff800…` ptr)| n/a (ADD rejected, nothing to read back)|
| `./panic`            | `trap 9` panic at `ip_fw3_chk+0x1a4`     | ADD rejected (`EINVAL`), guest stays up |
| legitimate `cmd_len=2` rule | accepted `rc=0`                  | accepted `rc=0` (no regression)         |

Clean before/after: the bad behaviour is present on the unpatched `#0` module
and **gone** on the single-fix module. `fix_status = fixed`.

## Re-verification (2026-07-16, #0 with-src)

Re-confirmed on a fresh `with-src` snapshot (#0 unpatched kernel + unpatched
`ipfw3.ko`), then re-validated the fix by rebuilding only `ipfw3.ko` and
hot-swapping.

| test                            | unpatched `#0` module                | patched module                              |
|---------------------------------|--------------------------------------|---------------------------------------------|
| `./leak` ADD (cmd_len=255)      | `rc=0` (rule installed)              | `rc=-1 errno=22 (EINVAL)`                   |
| `./leak` leaked bytes           | **42 non-zero** incl. `/root/.ssh/authorized/root` + ptrs `0xffffffff81115a60`/`0x...5a20` | n/a (ADD rejected, nothing to read back) |
| legitimate rule (`cmd_len=1`)   | accepted `rc=0`                      | accepted `rc=0` (no regression)             |
| `ipfw3.ko` build                | n/a                                  | `-Werror`, `rc=0`                           |

Clean before/after holds on the current guest: bad behaviour present on the
unpatched module, **gone** on the single-fix module, legit rules unaffected.
`fix_status = fixed`. (Full transcripts: `run.log`, `run.2.log`, `run.3.log`,
`leak_sample.txt`, `fix_build.log`, `fix_run.log`, `env.txt`.)
