# DF-3296 VERDICT — reproduced (deterministic kernel NULL-deref panic; DoS class)

## Result
`status: reproduced`, `impact: panic` (deterministic local kernel panic;
write-access to /dev/pf required, i.e. root on a stock system — /dev/pf is
`crw------- root:wheel`, pf_ioctl.c:3360).

## How it was run (baseline, stock pf.ko, guest #0 X86_64_GENERIC)
1. `kldload pf.ko`; build `trigger_changeaddr.c` (plain `cc`, structs from
   the installed `<net/pf/pfvar.h>`, sizes verified identical to kernel:
   pfioc_pooladdr=1136, pf_pooladdr=88).
2. The trigger performs, all on one `/dev/pf` fd (O_RDWR):
   DIOCBEGINADDRS -> DIOCADDADDR(10.9.9.9/32) -> DIOCXBEGIN(NAT) ->
   DIOCADDRULE(one `nat` rule, pool_ticket matched) -> DIOCXCOMMIT ->
   DIOCGETRULES (fetch ACTIVE ticket) ->
   DIOCCHANGEADDR(action=PF_CHANGE_REMOVE, nr=0, r_num=0, r_action=PF_NAT,
   af=AF_INET).
3. Kernel panicked before the ioctl returned (ssh died, vm status down):
```
Fatal user address access from kernel mode from trigger_changead at ffffffff82607d31
Fatal trap 12: page fault while in kernel mode
fault virtual address    = 0x0
fault code               = supervisor read data, page not present
Stopped at      pf_addrcpy+0x11:        movl    (%rsi),%eax
```
`%rsi` = `&pool->cur->addr.v.a.addr` with `pool->cur == NULL` — the exact
predicted sink pf_ioctl.c:2330-2332 -> pf_addrcpy (pf.c:411-426).

4. Variant 2 (same binary, `datapath enable`): removal with `af=AF_UNSPEC`
   (pf_addrcpy copies nothing -> no ioctl panic; rule left with an empty
   pool), then DIOCSTART + one UDP packet to 127.0.0.1:65001 matching the
   NAT rule:
```
Stopped at      pf_map_addr+0x76:       movzbl  0x28(%rbx),%ecx
```
   `rbx == rpool->cur == NULL` (pf.c:2693/2712).  Two independent panic
   sites from one root cause — the invariant "a pool-using rule always
   has >=1 address" (enforced at rule *creation*, pf_ioctl.c:1262-1266
   and 1500-1506) is breakable *after* commit via DIOCCHANGEADDR.

## Root cause
- pf_ioctl.c:2313-2318: PF_CHANGE_REMOVE frees the last pooladdr with no
  emptiness check.
- pf_ioctl.c:2330-2332: unconditional `pool->cur = TAILQ_FIRST(...)`
  followed by an unchecked `pool->cur->addr...` dereference.
- pf.c:2693/2712: packet path also assumes rpool->cur != NULL.

## Exploitability ceiling (why DoS, not more)
The primitive is a *read* from a fixed virtual address 0 (offsetof(struct
pf_pooladdr, addr) == 0; `v.a.addr` at offset 0 of pf_addr_wrap) into the
kernel-owned `pool->counter` — 4 bytes (AF_INET) or 16 (AF_INET6).  The
faulting address is not attacker-influenced (always NULL+0) and nothing is
copied to userland; a NULL page mapping is not attainable for unprivileged
users on DragonFly (no MAP_FIXED at 0; vm.mmap_min_addr equivalent
enforced by sys/sysref/mmap checks).  Both panic sites are identical
fixed-NULL reads.  Conclusion: unexploitable beyond
panic/denial-of-service.  No uid=0 chain is possible from this bug; pushed
to the honest ceiling (two deterministic panic variants + fix validation).

## Fix (validated)
fix.diff:
1. refuse PF_CHANGE_REMOVE of the *last* pool address (EBUSY) —
   restores the creation-time invariant for live rules;
2. defensive `if (pool->cur != NULL)` around the PF_ACPY.
Validation (same guest, module-only rebuild):
- baseline reproduced first (panic captured above, panic.txt);
- `cd /usr/src && patch -p0 < fix.diff && cd sys/net/pf && make obj && make`;
- `kldload` patched pf.ko;
- `./trigger_changeaddr` -> `ioctl returned Device busy (no panic?)` —
  identical trigger, panic GONE;
- `./fixcheck` (pool with 2 addresses): remove #1 (non-last) **succeeds**
  (fix does not over-block), remove #2 (last) -> `Device busy`, no panic;
- datapath variant post-fix: removal refused (EBUSY) so `pf_map_addr`
  never sees an empty pool.  (Caveat recorded in README: re-running with
  DIOCSTART + an unconstrained nat rule NATs the ssh flow itself —
  harness artifact, kernel stays alive.)
`fix_status: fixed`.

## Files
trigger_changeaddr.c (both variants), fixcheck.c (fix validation),
build.sh / run.sh, build.log, run.log (patched), panic.txt (both panics),
fix.diff, env.txt, manifest.json, verdict.json.

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