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.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0593 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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:
fairq_clear_interface(altq_fairq.c:323-335) β loops over all classes and destroys each. It is itself called only fromfairq_remove_altq(altq_fairq.c:187-199), which immediatelykfree(pif)afterwards (line 197). The danglingpif_defaultis never observed because the wholefairq_ifis freed, and the interface is detached viaaltq_pfdetach(called bypf_commit_altqatpf_ioctl.c:660beforealtq_remove) so no packet can hitfairq_enqueueduring teardown.fairq_remove_queue_locked(altq_fairq.c:248-256), reached viafairq_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
- Baseline (
#0, unpatchedwith-srcsnapshot): confirmed the finding's source trace βfairq_class_destroyandpriq_class_destroyboth lack apif_default = NULLclear. - Authored
fix.diffβ agit apply-able 2-hunk diff that addsif (pif->pif_default == cl) pif->pif_default = NULL;to bothfairq_class_destroy(mirroring the existingpif_poll_cachereset ataltq_fairq.c:500-501) andpriq_class_destroy. - Applied to in-guest
/usr/srcβpatch -p1 --forwardsucceeded, both hunks applied at the expected lines (503 and 415). - Built the single-fix kernel β
make -j6 nativekernel KERNCONF=X86_64_GENERICfrom/usr/src, completed with=== NK_DONE rc=0 ===(fix_build.log). Bothaltq_fairq.candaltq_priq.cwere recompiled with-Werror(no warnings). - Installed via
make installkerneland rebooted. - 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 indmesg(fix_run.log). - Confirmed patched source still has
pif_default = NULLataltq_fairq.c:503andaltq_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).
Fix verification
not_testablenot_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.
Confirmed kernel references
- sys/net/altq/altq_fairq.c:488
- sys/net/altq/altq_fairq.c:499
- sys/net/altq/altq_fairq.c:503
- sys/net/altq/altq_fairq.c:526
- sys/net/altq/altq_fairq.c:570
- sys/net/altq/altq_fairq.c:571
- sys/net/altq/altq_fairq.c:428
- sys/net/altq/altq_fairq.c:195
- sys/net/altq/altq_subr.c:566
- sys/net/altq/altq_subr.c:570
- sys/net/altq/altq_subr.c:641
- sys/net/pf/pf_ioctl.c:588
- sys/net/pf/pf_ioctl.c:613
- sys/net/pf/pf_ioctl.c:656
- sys/net/pf/pf_ioctl.c:2092
- sys/net/altq/altq_priq.c:402
- sys/net/altq/altq_priq.c:415
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.
No comments yet.