Latent UAF: fairq_class_destroy does not clear dangling pif_default pointer (currently unreachable via pf ioctls)
| Field | Value |
|---|---|
| ID | DF-0593 |
| Status | new |
| Severity | Info |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-416 Use After Free |
| File | sys/net/altq/altq_fairq.c |
| Lines | 428, 488-526, 570-578 |
| Area | net/altq (FAIRQ scheduler) |
| Confidence | speculative |
| Discovered | 2026-07-02 |
| Reported | pending |
Summary
fairq_class_destroy resets pif_classes[pri], pif_poll_cache, and
pif_maxpri when destroying a class, but does not clear
pif->pif_default if the destroyed class was the default. After destruction,
pif_default is a dangling pointer to freed heap. The next fairq_enqueue
that falls through to the default class (line 571) would pass the non-NULL
dangling-pointer check and write to freed memory. This is currently
unreachable from userspace ioctls (all pf callers gate on
qname[0] == 0 before calling altq_remove, so per-class destroy is never
dispatched), but the defect is real in the code and the function is public
API (altq_var.h:105); it is a latent regression / hardening hazard if a
per-class removal path is added in the future (e.g. wiring up the reserved
DIOCCHANGEALTQ placeholder).
Root cause
fairq_class_destroy (sys/net/altq/altq_fairq.c:488-529) clears:
499: pif->pif_classes[cl->cl_pri] = NULL;
500: if (pif->pif_poll_cache == cl)
501: pif->pif_poll_cache = NULL;
but there is no equivalent if (pif->pif_default == cl)
pif->pif_default = NULL;. pif_default is only ever assigned in
fairq_class_create at line 428 (if (flags & FARF_DEFAULTCLASS)
pif->pif_default = cl;).
After kfree(cl, M_ALTQ) at line 526, if cl was the default class,
pif->pif_default is a dangling pointer to freed heap memory
(sizeof(struct fairq_class) โ 200 bytes on amd64).
The next fairq_enqueue (altq_fairq.c:570-577) executes:
570: if (cl == NULL) {
571: cl = pif->pif_default;
572: if (cl == NULL) { ... }
573: }
...
578: cl->cl_flags |= FARF_HAS_PACKETS; /* WRITE to freed memory */
...
581: fairq_addq(cl, m, hash); /* DEREF cl->cl_buckets etc. */
โ the dangling pointer is non-NULL so the NULL-check passes, then
cl->cl_flags |= FARF_HAS_PACKETS; writes to freed memory, and
fairq_addq(cl, m, hash) dereferences cl->cl_head (altq_fairq.c:728),
cl->cl_buckets (:731, :734), cl->cl_nbucket_mask (:733) โ all freed-heap
reads, plus _addq writes to the freed bucket queue. This is a
controllable UAF: the freed ~200-byte fairq_class can be reallocated by
spraying same-sized allocations, turning the flag-write and bucket-deref
into arbitrary-field corruption.
The sibling function sys/net/altq/altq_priq.c:priq_class_destroy
(:402-436) has the identical omission for priq_if->pif_default.
Threat model & preconditions
- Attacker position: currently no userspace trigger path exists.
fairq_remove_queue(:259) โfairq_remove_queue_locked(:248) โfairq_class_destroyis the only per-class destroy entry point. Butaltq_remove(sys/net/altq/altq_subr.c:566-571) dispatches toaltq_remove_queueonly whena->qname[0] != 0, and all threepfcallers (pf_begin_altqsys/net/pf/pf_ioctl.c:590,pf_rollback_altq:615,pf_commit_altq:663) explicitly checkaltq->qname[0] == 0before callingaltq_remove, soaltq_removeis only ever called with discipline-type altqs. There is noDIOCREMOVEALTQioctl (DIOCCHANGEALTQreturnsENODEVat pf_ioctl.c:2094). Therefore, individual class destruction while the discipline remains live does not occur via the standard interface. - Privileges gained or impact: currently zero (no trigger). If a
per-class removal path is wired up later (e.g. implementing
DIOCCHANGEALTQorDIOCREMOVEALTQ, or an in-kernel module callingfairq_remove_queue/altq_remove_queuedirectly), the exploit becomes: 1. Configure a fairq discipline with a default class (FARF_DEFAULTCLASS). 2. Remove only the default class via the new path. 3. Groom the kernel heap: spray ~200-byte allocations (same slab asstruct fairq_class) to reclaim the freed default class's memory with a controlled object. 4. Send a UDP packet via the interface that doesn't match any classifier (sofairq_enqueuefalls through topif_default). 5.fairq_enqueuewritesFARF_HAS_PACKETSto the reclaimed object'scl_flagsfield offset, andfairq_addqreadscl_head/cl_bucketsfrom the reclaimed object โ if the reclaimed object is a fakefairq_classpointing to attacker-controlled memory, this yields arbitrary kernel read/write. - Required config or capabilities: if a trigger were added, root + ALTQ config (which already requires root to set up).
- Reachability: currently not reachable. This finding documents the latent code defect so it is not re-introduced as a live bug by a future per-class-removal patch.
Proof of concept
No PoC can be built today โ the trigger path is unreachable from userspace.
The findings/poc/DF-0593/ directory contains only a README.md
documenting the latent defect and the conditions under which it would
become exploitable.
Impact
- Blast radius: currently zero (unreachable).
- Severity rationale: Info โ hardening opportunity / defense-in-depth.
The code defect is certain (verified by reading
fairq_class_destroyvsfairq_class_create); the exploitability is purely speculative (depends on a future patch wiring up per-class removal). The CVSS vector above reflects the hypothetical impact if a trigger were added. - Reliability: not currently triggerable.
Recommended fix
Add pif_default reset in fairq_class_destroy, mirroring the existing
pif_poll_cache reset:
--- a/sys/net/altq/altq_fairq.c
+++ b/sys/net/altq/altq_fairq.c
@@ -498,6 +498,8 @@ fairq_class_destroy(struct fairq_class *cl)
pif = cl->cl_pif;
pif->pif_classes[cl->cl_pri] = NULL;
if (pif->pif_poll_cache == cl)
pif->pif_poll_cache = NULL;
+ if (pif->pif_default == cl)
+ pif->pif_default = NULL;
if (pif->pif_maxpri == cl->cl_pri) {
The same fix should be applied to sys/net/altq/altq_priq.c:priq_class_destroy
(after line 413).
References
sys/net/altq/altq_priq.c:priq_class_destroyโ the sibling function with the identical defect.sys/net/altq/altq_subr.c:566-571(altq_removedispatch) and the threepfioctl callers (pf_ioctl.c:590, 615, 663) that gate onqname[0] == 0, making per-class destroy currently unreachable.
Timeline
- 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
- 2026-07-02 Reported to DragonFlyBSD security contact (pending) as a hardening/latent-defect item.