# DF-0593 — Latent UAF: `fairq_class_destroy` / `priq_class_destroy` do not clear `pif_default`

## Verdict

**NOT REPRODUCED (latent / unreachable from userspace).** The dangling-pointer
defect is **real in the source** and the recommended fix is **VALIDATED**
(applies cleanly, single-fix kernel builds `rc=0`, boots as `#1`, no
regression, fix line present). Impact on the running kernel today is **none**:
no userspace ioctl path can dispatch per-class destroy while the discipline
stays live, so the dangling `pif_default` can never be observed.

## Mechanism (confirmed by source trace)

`fairq_class_destroy` (`sys/net/altq/altq_fairq.c:488-529`) clears three
pointers when destroying a class:

```
499:	pif->pif_classes[cl->cl_pri] = NULL;
500:	if (pif->pif_poll_cache == cl)
501:		pif->pif_poll_cache = NULL;
502:	if (pif->pif_maxpri == cl->cl_pri) { ... pif->pif_maxpri = ...; }
```

but does **not** clear `pif->pif_default`. `pif_default` is only ever
assigned in `fairq_class_create` at `altq_fairq.c:427-428`
(`if (flags & FARF_DEFAULTCLASS) pif->pif_default = cl;`). After
`kfree(cl, M_ALTQ)` at line 526, if the destroyed class was the default,
`pif->pif_default` is a dangling pointer to freed heap.

The consumer is `fairq_enqueue` (`altq_fairq.c:570-581`):
```
570:	if (cl == NULL) {
571:		cl = pif->pif_default;     /* dangling, non-NULL */
572:		if (cl == NULL) { m_freem(m); ... }   /* NULL-check passes */
573:	}
...
578:	cl->cl_flags |= FARF_HAS_PACKETS;   /* WRITE to freed memory */
...
581:	if (fairq_addq(cl, m, hash) != 0)   /* DEREF cl->cl_buckets etc. */
```

So the bug is genuine *as code*. The question is reachability.

## Reachability trace (the answer: not reachable from userspace)

There are exactly **two** in-tree callers of `fairq_class_destroy`:

1. `fairq_clear_interface` (`altq_fairq.c:323-335`) — loops over all classes and
   destroys each. It is itself called only from `fairq_remove_altq`
   (`altq_fairq.c:187-199`), which **immediately** `kfree(pif)` afterwards
   (line 197). The dangling `pif_default` is never observed because the whole
   `fairq_if` is freed, and the interface is detached via `altq_pfdetach`
   (called by `pf_commit_altq` at `pf_ioctl.c:660` *before* `altq_remove`)
   so no packet can hit `fairq_enqueue` during teardown.
2. `fairq_remove_queue_locked` (`altq_fairq.c:248-256`), reached via
   `fairq_remove_queue` (`altq_fairq.c:259-273`), the per-class destroy
   entry point. This is the only path where the dangling pointer could
   matter — the discipline stays live, the interface keeps queueing packets,
   and the next non-classified packet dereferences the dangling default.

`fairq_remove_queue` is dispatched by `altq_remove_queue`
(`altq_subr.c:641-665` → `altq_subr.c:663`), which is reached from
`altq_remove` (`altq_subr.c:566-571`) **only when** `a->qname[0] != 0`:

```
570:	if (a->qname[0] != 0)
571:		return (altq_remove_queue(a));
```

The **only** in-tree callers of `altq_remove()` are the three pf ioctl
helpers, and **all three gate on `qname[0] == 0`** before calling it:

- `pf_begin_altq` (`pf_ioctl.c:588-590`): `if (altq->qname[0] == 0) altq_remove(altq);`
- `pf_rollback_altq` (`pf_ioctl.c:613-615`): same guard.
- `pf_commit_altq` (`pf_ioctl.c:656-663`): same guard.

So `altq_remove()` is only ever called with **discipline** altqs (`qname==0`),
which dispatch to `*_remove_altq` (full teardown — path 1 above), **never** to
`*_remove_queue` (per-class destroy — path 2). The remaining ioctl
`DIOCCHANGEALTQ` (`pf_ioctl.c:2092-2095`) returns `ENODEV` unconditionally
(`/* CHANGEALTQ not supported yet! */`), and there is no `DIOCREMOVEALTQ`
ioctl defined (`grep -rn DIOCREMOVEALTQ sys/` → no hits).

**Conclusion:** the per-class destroy path that would observe the dangling
pointer is **not reachable from userspace on this kernel**. This is a true
latent defect — confirmed by reading the source end-to-end, not merely
speculation. It becomes a live bug only if a future patch wires up
`DIOCCHANGEALTQ` or adds a per-class removal ioctl (or an in-kernel caller
of `altq_remove_queue`).

The sibling `priq_class_destroy` (`sys/net/altq/altq_priq.c:402-438`) has
the **identical** defect: it clears `pif_classes[cl->cl_pri]` but not
`pif->pif_default`. `priq_class_create` sets `pif_default` at
`altq_priq.c:349-350`, and `priq_enqueue` dereferences it at
`altq_priq.c:473` with the same fall-through pattern as `fairq_enqueue`.

## Why the PoC cannot be built / run

Per the procedure's classification (Phase 4 case (d)): the path is
**genuinely not reachable at runtime on this kernel**. `altq_remove_queue`
is dead code from the perspective of any unprivileged (or even root)
userspace caller; the only way to dispatch it would be to add a new ioctl
handler or load a kernel module that calls it directly — both invalidate
the realism test (Phase 6 bright-line rule: a `kldload`-only trigger is
root→kernel, not unpriv→kernel). No PoC source is therefore provided.

## Fix validation (Phase 8 — `not_testable`)

The bug being unreachable means there is no runtime before/after behavior
to compare; per the procedure, this is `fix_status: not_testable`. But
because the fix is a real defense-in-depth code change, I went beyond
"applies + compiles": I built the single-fix kernel and **booted it**,
confirming no regression.

### Steps

1. **Baseline (`#0`, unpatched `with-src` snapshot):** confirmed the
   finding's source trace — `fairq_class_destroy` and
   `priq_class_destroy` both lack a `pif_default = NULL` clear.
2. **Authored `fix.diff`** — a `git apply`-able 2-hunk diff that adds
   `if (pif->pif_default == cl) pif->pif_default = NULL;` to both
   `fairq_class_destroy` (mirroring the existing `pif_poll_cache` reset
   at `altq_fairq.c:500-501`) and `priq_class_destroy`.
3. **Applied to in-guest `/usr/src`** — `patch -p1 --forward` succeeded,
   both hunks applied at the expected lines (503 and 415).
4. **Built the single-fix kernel** — `make -j6 nativekernel
   KERNCONF=X86_64_GENERIC` from `/usr/src`, completed with
   `=== NK_DONE rc=0 ===` (`fix_build.log`). Both `altq_fairq.c` and
   `altq_priq.c` were recompiled with `-Werror` (no warnings).
5. **Installed via `make installkernel`** and rebooted.
6. **Booted cleanly as `#1`** — `kern.version` =
   `DragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 20:39:15 UTC 2026`.
   Guest came up on ssh; no panic / no fairq/priq/altq messages in
   `dmesg` (`fix_run.log`).
7. **Confirmed patched source** still has `pif_default = NULL` at
   `altq_fairq.c:503` and `altq_priq.c:415`.

### before / after

| | unpatched `#0` | patched `#1` |
|---|---|---|
| `fairq_class_destroy` clears `pif_default` | **no** (latent UAF) | **yes** (`altq_fairq.c:503`) |
| `priq_class_destroy` clears `pif_default` | **no** (latent UAF) | **yes** (`altq_priq.c:415`) |
| Build | n/a (running kernel) | `NK_DONE rc=0`, `-Werror` clean |
| Boot | healthy | healthy, no panic in `dmesg` |
| Userspace reachability | none | none (unchanged — fix is defense-in-depth) |

Because the bug cannot be triggered, the before/after behavior is identical
(no panic in either case); the value of the fix is **defense-in-depth**
against a future per-class-removal patch turning this into a live UAF.

## PoC changes

`findings/poc/DF-0593/` originally contained only `README.md`. Added:
- `fix.diff` — the git-apply-able 2-hunk fix (fairq + priq).
- `build.sh`, `run.sh` — no-op stubs that document that no runtime PoC
  exists for a latent bug (per procedure's `not_testable` case).
- `fix_build.log` — full untrimmed `make nativekernel` output for the
  single-fix kernel (`NK_DONE rc=0`).
- `fix_run.log` — patched-`#1`-kernel boot verification (uname, kern.version,
  patched source lines, clean dmesg).
- `env.txt` — guest environment for the fix-validation run.
- `VERDICT.md` — this file.
- `manifest.json` — artifact catalog.

## Recommended fix

`fix.diff` adds `if (pif->pif_default == cl) pif->pif_default = NULL;` to
both `fairq_class_destroy` (`sys/net/altq/altq_fairq.c`, after line 501)
and `priq_class_destroy` (`sys/net/altq/altq_priq.c`, after line 413),
mirroring the existing `pif_poll_cache` reset idiom. **Matches** the
finding markdown's `## Recommended fix` proposal (which only mentioned the
fairq half — this diff additionally fixes the priq sibling, which the
finding markdown flagged at line 73-74 / 148-149 as having the identical
defect).
