# DF-0362 — VERDICT

## Status
**REPRODUCED** (panic / kernel memory corruption). Fix **VALIDATED**.

## One-line summary
`pfr_fix_anchor()` strips leading `/` from `pfrt_anchor` with a counting
loop that has no bound against `siz=MAXPATHLEN`. The byte immediately
after `pfrt_anchor[1024]` in `struct pfr_table` is `pfrt_name[0]`, also
attacker-controlled. A `pfrt_anchor` full of `/` plus `pfrt_name[0]='/'`
makes the loop read one byte past the array, `off` reaches 1025, and
`bcopy(path, anchor, siz - off)` computes `1024 - 1025` as a `size_t`,
wrapping to `~SIZE_MAX`. The bcopy (`memmove`) page-faults and the
kernel panics. Confirmed by reproduction and patched away by the
authored fix.

## Mechanism (confirmed path:line)
1. **Reach.** `DIOCRGETTABLES` ioctl on `/dev/pf` (`pf_ioctl.c:2430`).
   `pfrio_esize == sizeof(struct pfr_table)` gate at `pf_ioctl.c:2433`,
   then `pfr_get_tables(&io->pfrio_table, ...)` at `pf_ioctl.c:2437`.
2. **Sink.** `pfr_get_tables()` calls `pfr_fix_anchor(filter->pfrt_anchor)`
   at `pf_table.c:1280` with no prior validation of the anchor.
3. **Bug.** Inside `pfr_fix_anchor` (`pf_table.c:1739-1762`):
   ```c
   path = anchor; off = 1;
   while (*++path == '/')   // line 1751 — no bound vs siz
       off++;
   bcopy(path, anchor, siz - off);    // line 1753 — wraps when off>siz
   ```
   With `pfrt_anchor[0..1023]='/'` and `pfrt_name[0]='/'`, the loop
   walks `pfrt_anchor[1..1023]` (all `/`) then reads `pfrt_name[0]`
   (the byte immediately past the array — `pfvar.h:1037-1038` lays
   `pfrt_anchor[1024]` directly in front of `pfrt_name[32]`). `off`
   reaches 1025.
4. **Wrap.** `siz(1024) - off(1025)`: `off` (int) is promoted to
   `size_t` (unsigned 64-bit) before the subtraction, so the result
   wraps to `0xFFFFFFFFFFFFFFFF`. `bcopy` is `memmove` on x86_64
   (`sys/platform/pc64/x86_64/support.S`), implemented as `rep movsb`
   which page-faults on the first unmapped byte past the slab.

## Reproduction evidence (decisive)
Unpatched `pf.ko` loaded on the `with-src` baseline kernel
(`DragonFly 6.5-DEVELOPMENT #0`, `X86_64_GENERIC`, INVARIANTS ON).
PoC sets `pfrt_anchor`=1024×'/', `pfrt_name`={'/',0,...}, issues
`DIOCRGETTABLES` as root:

```
panic: vm_fault: fault on stack guard, addr: 0xfffff8011795a000
cpuid = 1
Trace beginning at frame 0xfffff801183431e8
vm_fault() at vm_fault+0x12eb 0xffffffff8099ef9b
trap_pfault() at trap_pfault+0x9a 0xffffffff80bd52ca
trap() at trap+0x17c 0xffffffff80bd5bcc
calltrap() at calltrap+0x9 0xffffffff80b991fa
--- trap 000000000000000c, rip = ffffffff80bcab4f, rsp = fffff801183435f0 ---
memmove() at memmove+0x24f 0xffffffff80bcab4f
pfr_get_tables() at pfr_get_tables+0x31 0xffffffff8262a681
```

This is precisely the primitive the finding describes — the bcopy in
`pfr_fix_anchor` (inlined into / called from `pfr_get_tables+0x31`) is
the crashing instruction. A crash dump was captured. Reproduced 3/3.

**Negative control.** Same PoC with `pfrio_esize=0` returns `ENODEV`
from `pf_ioctl.c:2433` *before* `pfr_get_tables`/`pfr_fix_anchor` is
reached — guest stays up. The panic is unambiguously caused by reaching
`pfr_fix_anchor` with the malformed anchor/name pair, i.e. the
DF-0362 primitive, not anything incidental.

## Reachability / threat model (verified on guest)
The audit prompt's premise "PF is compiled into GENERIC" is **wrong**.
On DragonFlyBSD master DEV:

- PF is **NOT** in `X86_64_GENERIC` (`sys/config/X86_64_GENERIC` has no
  `device pf`). It is built only as a KLD module (`sys/net/pf/Makefile`,
  `KMOD=pf`).
- `/dev/pf` does **not** exist on a default boot. It is created only
  after `kldload pf` (root-only) via
  `pf_load() → make_dev(&pf_ops, 0, UID_ROOT, GID_WHEEL, 0600, PF_NAME)`
  (`pf_ioctl.c:3360`) — i.e. **mode 0600 root:wheel**.
- `pfsync` does not call any `pfr_*` table routine (`if_pfsync.c` has
  zero references to `pfr_table`/`pfr_fix_anchor`/`pfr_validate_table`),
  so there is **no network-reachable path**.

Therefore the bug is a **root→kernel hardening gap**, not an
unprivileged→root escalation. Per the audit bright-line rule, this is a
**valid hard blocker** for `uid=0`: the corruption primitive is real,
but the privilege boundary an unprivileged attacker would have to cross
does not exist — opening `/dev/pf` already requires root.

Realistic impact ceiling:
- **Kernel panic (DoS)** from any context holding a readable `/dev/pf`
  fd. Most important real-world case: a **jailed root** on hosts that
  expose `/dev/pf` into the jail for firewall management (common
  practice) — jail→host kernel DoS, defeating jail isolation.
- **Latent memory-corruption primitive** if a future change moves PF
  into the kernel proper or loosens `/dev/pf` permissions. The
  `bcopy` overwrites a small region (next page boundary, ~few KB)
  with attacker-controlled bytes (`pfrt_name`) before faulting; with
  heap grooming this is plausibly an arbitrary-write primitive — but
  only relevant once an unprivileged path exists, which it does not
  today.

## Exploit chain
**Blocked by a valid hard blocker**: the only reachable path to
`pfr_fix_anchor` is an ioctl on `/dev/pf`, which is created mode 0600
root:wheel (`pf_ioctl.c:3360`) and exists only after a root-only
`kldload pf`. There is no unprivileged path: PF is not in the default
kernel, `pfsync` does not invoke any `pfr_*` routine, and no
world-readable PF device node exists. The bug is a **root→kernel
memory-corruption / DoS gap**, not a privilege-boundary cross — opening
`/dev/pf` already concedes root. No `uid=0` chain is developable
because there is no lower privilege to escalate from. `exploit.c`/`chain.c`
not applicable (no corruption class that crosses a privilege boundary).

## PoC changes from the scaffolded original
1. Use the guest's installed `<net/pf/pfvar.h>` (via `<net/if.h>` for
   `IFNAMSIZ`) so `struct pfioc_table`/`pfr_table`/`DIOCRGETTABLES` are
   byte-accurate. The scaffolded PoC had `pfrio_buffer` as `int`
   (should be `void *`) and used ioctl number `66` (= `DIOCRCLRADDRS`)
   instead of `63` (= `DIOCRGETTABLES`).
2. Set `pfrio_esize = sizeof(struct pfr_table)` — required to pass the
   gate at `pf_ioctl.c:2433` before `pfr_get_tables` is reached.
3. Added reachability/caveat documentation to the PoC source.

## Recommended fix (authored in `fix.diff`)
Two-layer defence at `sys/net/pf/pf_table.c:pfr_fix_anchor`:
1. **NUL-termination check at entry**: `if (anchor[siz - 1] != '\0')
   return (-1);` — guarantees the loop's `*++path` reads cannot run
   past the array (the NUL terminator is the loop's natural exit).
2. **In-loop guard**: `while (off < siz && *++path == '/')` plus
   `if (off >= siz) return (-1);` — defense-in-depth in case the
   NUL check is ever loosened.

This **supersedes** the finding markdown's proposed fix (which had the
same two ideas but a syntax error — an extra `int i;` line — and was
not validated against the actual headers). The authored diff is
`git apply`-clean (`git apply --check` passes), compiles into pf.ko
without warnings, and is verified by disassembly to produce the new
`cmpb $0x0,0x3ff(%rdi); jne ...` prologue.

## Fix validation (Phase 8)
- **Build:** `cd /usr/src/sys/net/pf && make KERNCONF=X86_64_GENERIC`
  succeeds (rc=0); produces patched `pf.ko` (350368 B). Module-only
  build is sufficient because PF is a KLD module and the kernel proper
  is unchanged by this fix (avoiding the `kernel.stripped` loader-format
  issue seen with full `nativekernel`).
- **Install:** copy patched `pf.ko` over `/boot/kernel/pf.ko`,
  `kldload pf`. The running kernel stays at the `with-src` #0 baseline.
- **AFTER (patched pf.ko):** 3/3 PoC runs return `rc=-1` cleanly.
  Zero `boot.log` delta. Guest stays up.
- **BEFORE (control, same boot):** source reverted with `patch -R`,
  unpatched `pf.ko` rebuilt, `kldunload`+`kldload`, same PoC →
  `Fatal trap 12`, kernel reboots. (Full signature with `memmove+0x24f`
  / `pfr_get_tables+0x31` captured earlier in session — see `panic.txt`.)
- **`fix_status` = `fixed`** (clean before/after on the same kernel).

## Confidence
**certain** — bug confirmed by trap-12 panic in `memmove`/`pfr_get_tables`,
root-caused line-by-line to `pfr_fix_anchor:1751-1753`, fix compiled and
validated by before/after on the same boot.
