# DF-0592 — VERDICT

## Verdict

**REPRODUCED.** Uninitialized kernel stack bytes are leaked to userspace
through the FAIRQ altq `DIOCGETQSTATS` ioctl path.  The same defect also
exists in the priq and hfsc disciplines (identical code pattern); `fix.diff`
closes all three.

## Mechanism (trigger → primitive → effect)

1. **Trigger.** A privileged local user (root or any process holding
   `/dev/pf`, which is `0600 root:wheel` per `sys/net/pf/pf_ioctl.c:3360`)
   configures a FAIRQ discipline on an interface with a `Q_DROPTAIL` class
   (the default — no `FARF_RED`/`FARF_RIO` flags), then issues
   `DIOCGETQSTATS` (`sys/net/pf/pfvar.h:1707`).  The ioctl dispatches
   through `pfioctl` (`sys/net/pf/pf_ioctl.c:2097`) → `altq_getqstats`
   (`sys/net/altq/altq_subr.c:677`) → `fairq_getqstats`
   (`sys/net/altq/altq_fairq.c:278`).

2. **Primitive.** In `fairq_getqstats` the kernel declares
   `struct fairq_classstats stats;` on the stack **without zeroing**
   (`altq_fairq.c:282`).  The helper `get_class_stats`
   (`altq_fairq.c:974-1001`) writes only:
   - `class_handle`, `qlimit`, `xmit_cnt`, `drop_cnt`, `qtype`, `qlength`
     (lines 978-988) — covering offsets 0-11, 16-47, 48-51 of the 224-byte
     struct.
   - Conditionally `sp->red[0]` (`Q_RED`) or `sp->red[0..2]` (`Q_RIO`).

   It **never touches**:
   - (a) 4 bytes of padding at offset 12-15 (between `qlimit` and
     `xmit_cnt`, needed to align `pktcntr` to 8 bytes);
   - (b) 4 bytes of padding at offset 52-55 (between `qtype` and
     `red[0]`, needed to align `struct redstats` to 8 bytes);
   - (c) when `qtype == Q_DROPTAIL` (the default — `cl_qtype` is set to
     `Q_DROPTAIL=0x03` in `fairq_class_create` at `altq_fairq.c:436`
     unless `FARF_RED`/`FARF_RIO` are set), the entire `red[3]` array
     (168 bytes at offset 56-223).

   Total leak surface per call: **176 bytes** (out of 224).

3. **Effect.** `copyout((caddr_t)&stats, ubuf, sizeof(stats))` at
   `altq_fairq.c:312` copies the full 224-byte struct to userspace
   unconditionally, leaking all 176 uninitialized bytes.

## Reproduction evidence (unpatched `#0` baseline)

On the default GENERIC `with-src` kernel (`6.5-DEVELOPMENT #0`) as root
with `pf.ko` loaded, four independent runs of `./fairq_leak vtnet0`
returned:

```
RUN 1: 138/176 non-zero bytes,  29 kernel-pointer-looking 8-byte windows
RUN 2: 124/176 non-zero bytes,  27 kernel-pointer-looking 8-byte windows
RUN 3: 126/176 non-zero bytes,  27 kernel-pointer-looking 8-byte windows
RUN 4: 133/176 non-zero bytes,  28 kernel-pointer-looking 8-byte windows
```

Sample bytes (RUN 1, region 56-223):
`fe@57 9a@58 89@59 f8@61 ff@62 ff@63 b0@64 c8@65 67@66 80@67 ff@68 ff@69 ...`

The byte-for-byte variance across runs at offsets 80-103 proves these are
genuine stale stack frames (not deterministic struct contents); the
re-occurring `0xffff...`, `0xffffffff80...`, `0xffffffff81...` windows are
kernel text/data pointers from prior call frames — directly useful as
KASLR-defeat input (KASLR is OFF on this guest, but the leak holds on
hardened kernels too).

## Exploit chain

**None.** This is a pure info-leak (CWE-200).  No write primitive, no
corruption, no escalation chain.  Realistic impact ceiling: up to 176
bytes of stale kernel stack disclosed per call, repeatable, containing
kernel text/data pointers usable for KASLR bypass.  The attacker is
already privileged (`/dev/pf` `0600 root:wheel`), so the *direct*
operational impact is bounded; the leak is most valuable as input to a
separate primitive in a chained exploit.

## PoC changes from the filing-time sketch

The PoC source `fairq_leak.c` did not exist in the folder at run time —
the README described it as a "sketch" to be materialized by the verifier.
I wrote a complete driver from scratch that:

- Uses the **actual** DragonFly pf ioctl interface — `DIOCXBEGIN` +
  `PF_RULESET_ALTQ` to obtain the altq-transaction ticket (NOT
  `DIOCBEGINALTQS` — that constant is defined in `pfvar.h` but is **not**
  wired to a case in `pf_ioctl.c`; altq begin/commit are only reachable
  via the batched `DIOCXBEGIN`/`DIOCXCOMMIT` path with `rs_num =
  PF_RULESET_ALTQ = PF_RULESET_MAX = 5`).
- Adds the FAIRQ **discipline** first (`qname=""`, `scheduler=ALTQT_FAIRQ`,
  `ifbandwidth=10Mbps`) on the chosen interface, then a **class** named
  "def" on the same interface (the kernel auto-discovers the parent
  discipline by ifname match at `pf_ioctl.c:2038-2044`, and auto-allocates
  a non-zero `qid` via `pf_qname2qid`).
- Walks the active list with `DIOCGETALTQS`/`DIOCGETALTQ` to find the
  queue entry (the one with `qname[0] != 0`).
- Issues `DIOCGETQSTATS` with `pq.buf` poisoned with `0xAA` first, so
  even a partial-overlap (e.g. if a future kernel truncates the copyout)
  would be visible.
- Analyzes the three known-uninitialized regions (offsets 12-15, 52-55,
  56-223) and reports non-zero byte counts plus a kernel-pointer scan.
- Corrected the qtype labels: `Q_RED=0x01`, `Q_RIO=0x02`,
  `Q_DROPTAIL=0x03` (from `sys/net/altq/altq_classq.h:48-50`, **not** the
  `0/1/2` the original sketch guessed).

No fix to the underlying claim was needed — the finding's mechanism
description is accurate end-to-end.

## Fix validation (Phase 8)

Authored `fix.diff`: a single `memset(&stats, 0, sizeof(stats))` line
added immediately after the local-variable declarations in each of
`fairq_getqstats`, `priq_getqstats`, and `hfsc_getqstats`.  Minimal and
targeted at the root cause.

| Step | Result |
|------|--------|
| `vm.sh reset with-src` → confirm `#0` boots | OK |
| Baseline re-reproduction on `#0` | **LEAK CONFIRMED — 138/176 non-zero bytes** |
| Apply `fix.diff` (`patch -p1 --forward`) | All 3 hunks applied cleanly (altq_fairq.c:283, altq_priq.c:222, altq_hfsc.c:298) |
| `make -j6 nativekernel KERNCONF=X86_64_GENERIC` | rc=0 (35k-line build log saved) |
| `cp kernel.stripped /boot/kernel/kernel` + reboot | `#1` boots, hash `ca2b5d2d...` differs from baseline `5dc83dac...` |
| Re-run PoC on `#1` (×3) | **0/176 non-zero bytes — leak GONE** |
| `vm.sh reset with-src` | OK |

`fix.diff` **supersedes** the finding markdown's proposal (which only
fixed `fairq_getqstats`): I extended the identical one-line `memset` to
`priq_getqstats` and `hfsc_getqstats` because they have the same
line-for-line bug pattern (stack-declared, partially-populated
`*_classstats` struct).  The finding itself flagged these as needing the
same fix, so this is a straightforward superset.

## Honesty notes / caveats

- The bug is **deterministic and 100% reproducible** on every call.  The
  variance in non-zero *count* (124-138 of 176) is because the leaked
  stack bytes themselves vary, not because the leak sometimes fires.
- `pf.ko` must be loaded (`kldload pf.ko`) for `/dev/pf` to exist.  That
  is **normal system setup** (the standard shipped pf module), not a
  privilege-escalation step; the finding already requires root to open
  `/dev/pf` (`0600 root:wheel`), so there is no privilege boundary being
  crossed by `kldload`.
- `/dev/pf` exists only when `pf.ko` is loaded — fresh boot does not have
  it.  The bug is still real on any DragonFly deployment that actually
  uses ALTQ/pf (routers, VPN concentrators, jails with delegated
  `/dev/pf`).
