β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0593

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_destroy is the only per-class destroy entry point. But altq_remove (sys/net/altq/altq_subr.c:566-571) dispatches to altq_remove_queue only when a->qname[0] != 0, and all three pf callers (pf_begin_altq sys/net/pf/pf_ioctl.c:590, pf_rollback_altq :615, pf_commit_altq :663) explicitly check altq->qname[0] == 0 before calling altq_remove, so altq_remove is only ever called with discipline-type altqs. There is no DIOCREMOVEALTQ ioctl (DIOCCHANGEALTQ returns ENODEV at 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 DIOCCHANGEALTQ or DIOCREMOVEALTQ, or an in-kernel module calling fairq_remove_queue/altq_remove_queue directly), 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 as struct 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 (so fairq_enqueue falls through to pif_default). 5. fairq_enqueue writes FARF_HAS_PACKETS to the reclaimed object's cl_flags field offset, and fairq_addq reads cl_head/cl_buckets from the reclaimed object β€” if the reclaimed object is a fake fairq_class pointing 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_destroy vs fairq_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.

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_remove dispatch) and the three pf ioctl callers (pf_ioctl.c:590, 615, 663) that gate on qname[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.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0593 Β· 10 files
FileTypeDescriptionSize
README.md readme original PoC readme documenting that no PoC can be built (latent) 901 B ↓ raw
VERDICT.md verdict full analysis: mechanism, reachability trace, fix validation 8.3 KB ↓ raw
build.sh build-script no-op stub documenting that there is no PoC binary to build 788 B view raw
run.sh run-script no-op stub documenting that there is no runtime trigger 533 B view raw
fix.diff suggested-fix git-apply-able 2-hunk fix: clear pif_default in fairq_class_destroy and priq_class_destroy 824 B view raw
fix_build.log build-log full untrimmed make nativekernel output of the single-fix kernel (NK_DONE rc=0) 5.6 MB ↓ download
fix_run.log run-log patched #1 kernel boot verification: uname, kern.version, patched source lines, clean dmesg 601 B view raw
env.txt environment guest environment for the fix-validation run 405 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme original PoC readme documenting that no PoC can be built (latent)
↓ download raw

DF-0593 β€” Latent UAF in fairq_class_destroy (no PoC, unreachable)

No PoC can be built today. The trigger path (fairq_remove_queue β†’ fairq_remove_queue_locked β†’ fairq_class_destroy) is not reachable from userspace ioctls: all three pf callers (pf_begin_altq, pf_rollback_altq, pf_commit_altq) gate on qname[0] == 0 before calling altq_remove, so per-class destroy is never dispatched.

This finding documents the latent code defect (pif->pif_default not cleared in fairq_class_destroy) so it is not re-introduced as a live bug by a future per-class-removal patch (e.g. implementing the reserved DIOCCHANGEALTQ placeholder, or an in-kernel module calling altq_remove_queue directly).

See findings/DF-0593-fairq-class-destroy-latent-pif-default-uaf.md for the full analysis, the fix diff, and the conditions under which this would become a live exploitable UAF.

VERDICT.md verdict full analysis: mechanism, reachability trace, fix validation
↓ download raw

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.

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

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

not_testable (latent, no runtime trigger). Compile+boot validated: rc=0 -Werror, #1 boots clean, patched lines present.

Build rc=0. Boot #1 clean. Source: pif_default=NULL at :503/:415.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 20:39:15 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none -- latent UAF, unreachable from userspace. Only live if future patch wires DIOCCHANGEALTQ.

Evidence (decisive lines)

Source trace: :499-501 clear pif_classes/poll_cache, NOT pif_default. :570-578 enqueue derefs. altq_subr.c:570 qname[0]!=0 gate. pf_ioctl.c:588/613/656 qname[0]==0 (opposite). Build #1 rc=0 -Werror.

PoC changes

Authored: fix.diff (2 hunks: fairq + priq pif_default NULL clear), VERDICT.md, manifest.json.

Verified recommended fix

Add if(pif->pif_default==cl) pif->pif_default=NULL to fairq_class_destroy (:501) and priq_class_destroy (:413). Matches finding + adds priq sibling. Full diff in findings/poc/DF-0593/fix.diff.

Verdict

NOT REPRODUCED (latent/unreachable). fairq_class_destroy altq_fairq.c:488-529 clears pif_classes+pif_poll_cache but NOT pif_default. Next fairq_enqueue fall-through derefs freed pif_default. BUT per-class destroy path is dead code: all pf ioctl callers gate altq_remove on qname[0]==0 (opposite of altq_remove_queue dispatch). DIOCCHANGEALTQ->ENODEV. No DIOCREMOVEALTQ. Sibling priq same defect.