# DF-2580 — ipfw3 table id bounds-check missing (controlled kernel heap OOB R/W)

## Verdict
**REPRODUCED** on the unpatched `6.5-DEVELOPMENT #0` kernel; **FIX VALIDATED** on a
single-fix `#1` kernel + rebuilt `ipfw3_basic.ko`. The OOB write primitive is real
and root-reachable; the escalation chain to `uid=0` is **blocked by a valid hard
blocker** (the bug path is reachable only through a raw IP socket, which requires
`SYSCAP_NONET_RAW` = root; an unprivileged user cannot enter it).

## The bug (confirmed line-by-line)
Every ipfw3 table operation derives its per-CPU slot from `ioc_table->id` (a signed
`int` taken directly from the `setsockopt` payload) and indexes into
`ctx->table_ctx[IPFW_TABLES_MAX=32]` **with no bounds check**:

```c
/* sys/net/ipfw3_basic/ip_fw3_table.c */
void table_create_dispatch(netmsg_t nmsg) {
    ...
    ioc_table = tbmsg->ioc_table;
    int id = ioc_table->id;          /* line 92: attacker-controlled signed int */
    table_ctx = ctx->table_ctx;
    table_ctx += id;                  /* line 95: OOB if id<0 || id>=32 */
    table_ctx->type = ioc_table->type;        /* line 96: OOB write */
    table_ctx->count = 0;                      /* line 97: OOB write */
    strlcpy(table_ctx->name, ...);             /* line 98: OOB write, 32 bytes */
    if (table_ctx->type == 1) { rn_inithead(&table_ctx->mask,...); rn_inithead(&table_ctx->node,...); }  /* OOB kernel-ptr installs */
    ...
}
```

`struct ipfw3_table_context` is 56 bytes on amd64 (`node* + mask* + name[32] + count + type`),
so `id=N` targets byte offset `N*56` from the array base. The identical
`table_ctx += id` pattern (with no check) appears in **all eight** dispatch/sync
functions: `table_create_dispatch` (c:92), `table_delete_dispatch` (c:127),
`table_append_dispatch` (c:149), `table_remove_dispatch` (c:205),
`table_flush_dispatch` (c:244), `table_rename_dispatch` (c:265),
`ip_fw3_ctl_table_show` (c:379, reads `*id`), `ip_fw3_ctl_table_test` (c:433).

The entry path is `setsockopt(raw_ip_sock, IPPROTO_IP, IP_FW_X=49, payload)`:
`rip_ctloutput` (raw_ip.c:385) → `ip_fw3_sockopt` → `ip_fw3_ctl_x` (ip_fw3.c:1039,
strips a 4-byte `ip_fw_x_header{uint16 opcode,uint16 pad}` and sets
`sopt_name=opcode`) → `ip_fw3_ctl` (ip_fw3.c:1054, switches on opcode, e.g. 73=
`IP_FW_TABLE_CREATE`) → `ip_fw3_ctl_table_sockopt` (ip_fw3_table.c:524) →
`ip_fw3_ctl_table_create` → netmsg → `table_create_dispatch`.

## Reproduction (unpatched #0 kernel)
Run as root after `kldload ipfw3 && kldload ipfw3_basic` (with
`net.filters_default_to_accept=1` so the firewall doesn't cut ssh):

```
# /root/poc 73 100000 1          # CREATE id=100000 type=1 -> offset 5.6MB
Fatal trap 12: page fault while in kernel mode
cpuid = 1; lapic id = 1
fault virtual address = 0xfffff80118b7c0b0
Stopped at      table_create_dispatch+0x45:     movl    $0,0x30(%rbx)
db>
```
`table_create_dispatch+0x45` is the `table_ctx->count = 0;` store (offset 0x30=48
in the 56-byte struct = the `count` field). With id=100000 the computed address is
5.6 MB past the array base → unmapped → page fault. **Bug confirmed.**

Smaller OOB offsets do **not** crash — they silently corrupt adjacent slab memory:
```
# /root/poc 73 64 1     # offset 3584 -> setsockopt returns 0 (silent OOB write)
# /root/poc 73 -2 1     # offset -112 -> setsockopt returns 0 (silent OOB write, negative idx)
```
This is the "controlled heap OOB write" primitive: 4 bytes type (attacker int) +
4 bytes count (forced 0) + up to 31 attacker bytes in `name` via `strlcpy`, at an
attacker-chosen offset `id*56`; with `type` 1/2 it additionally installs two kernel
pointers via `rn_inithead`. The `ip_fw3_table_fini_dispatch` even `kfree`s the
installed `node`/`mask` on unload, so the delete/flush opcodes also give a
controlled-`kfree` of an attacker-influenced pointer.

## Privilege analysis — why escalation to uid=0 is blocked
The only kernel path to `ip_fw3_ctl_table_sockopt` is `rip_ctloutput`, which is the
`pr_ctloutput` of the raw-IP protocol family (in_proto.c: every `rip_ctloutput`
entry is `pr_type = SOCK_RAW` + `rip_usrreqs`). Creating such a socket runs
`rip_attach` (raw_ip.c:460) which gates on:

```c
error = caps_priv_check(ai->p_ucred, SYSCAP_NONET_RAW | __SYSCAP_NULLCRED);  /* raw_ip.c:473 */
```

i.e. **root** (or a jail with `allow_raw_sockets`, which still grants it only to
root *inside* the jail). Confirmed on the guest:
```
[maxx@dfbsd ~]$ /tmp/poc 73 5 1
[-] socket(AF_INET,SOCK_RAW,IPPROTO_RAW): Operation not permitted
[-] raw socket requires root (SYSCAP_NONET_RAW). errno=1   (EPERM)
```
An unprivileged user therefore **cannot** enter the bug path; there is no
privilege boundary to cross. Per the Phase-6 bright-line rule, "root-only
reachability" is a **valid hard blocker**: root→kernel is game-over by definition
(root can already `kldload`, write `/dev/mem`, etc.). The honest impact is a
**panic / kernel-heap-corruption DoS triggered by a privileged firewall admin**
plus a real OOB-write primitive that is unreachable from an unprivileged context.
No `kldload`-of-attacker-module, setuid helper, or non-default kernel was used
to "reach" the bug; the only kldload is the ipfw3/ipfw3_basic modules themselves,
which is the legitimate module providing the vulnerable surface.

## The fix
A single central bounds check in `ip_fw3_ctl_table_sockopt` (ip_fw3_table.c:524),
gating every opcode that indexes by id (`LIST` iterates all slots and is exempt):

```c
if (sopt->sopt_name != IP_FW_TABLE_LIST &&
    sopt->sopt_valsize >= sizeof(int)) {
    struct ipfw_ioc_table *ioc = (struct ipfw_ioc_table *)sopt->sopt_val;
    if (ioc->id < 0 || ioc->id >= IPFW_TABLES_MAX)
        return (EINVAL);
}
```
The id sits at offset 0 of `sopt_val` for every id-consuming opcode (it is the
first field of `struct ipfw_ioc_table`, and `SHOW` reads it as a bare `int *`),
so one check covers all eight. `git apply`-able diff: `fix.diff`.

## Fix validation (built single-fix #1 kernel + rebuilt module)
- **Before** (unpatched #0 kernel + #0 module): `id=100000` → panic
  `table_create_dispatch+0x45`; `id=64`/`id=-2` → silent OOB write (returns 0).
- **After** (#1 kernel `Sat Aug  8 08:28:55 UTC 2026` + rebuilt `ipfw3_basic.ko`
  sha256 `206a75c0…`): `id=100000`/`64`/`-2` → `EINVAL` (errno 22), no panic,
  guest stays up; valid `id=5` → still accepted (returns 0, no false reject).
- Disassembly of the rebuilt `ip_fw3_ctl_table_sockopt` confirms the check:
  `cmpl $0x1f,(%rax); jbe <switch>; mov $0x16,%eax; retq` (= `if (id<=31) switch; else return EINVAL`).

**fix_status = fixed** (clean before/after, deterministic across runs).

## Files
- `poc.c` — minimal trigger (raw socket + `setsockopt(IP_FW_X)` with attacker id).
- `build.sh` / `run.sh` — exact build & run.
- `run.log` — baseline reproduction (panic signature + silent-corruption variants + privilege gate).
- `fix_run.log` — patched-kernel re-run (EINVAL for OOB ids, valid id still works).
- `fix_build.log` — full `make -j6 nativekernel` output (NK_DONE rc=0).
- `panic.txt` — `Fatal trap 12 … table_create_dispatch+0x45`.
- `env.txt` — guest uname / cc / kld / sysctl state.
- `fix.diff` — the standalone git-apply-able fix.
- `manifest.json` — artifact catalog.
