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

Uninitialized kernel stack leaked to userspace via fairq_getqstats copyout of struct fairq_classstats

Field Value
ID DF-0592
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N
CWE CWE-200 Exposure of Sensitive Information to an Unauthorized Actor
File sys/net/altq/altq_fairq.c
Lines 282, 306, 312, 974-1001
Area net/altq (FAIRQ scheduler)
Confidence certain
Discovered 2026-07-02
Reported pending

Summary

fairq_getqstats declares struct fairq_classstats stats on the kernel stack without zeroing (line 282). The helper get_class_stats (altq_fairq.c:974-1001) only populates a subset of the struct fields: compiler-inserted padding bytes (between qlimit and xmit_cnt, and between qtype and red[0]), plus the entire red[3] array when qtype != Q_RIO (i.e. for the default Q_DROPTAIL configuration), remain uninitialized. copyout(&stats, ubuf, sizeof(stats)) at line 312 then leaks up to ~176 bytes of stale kernel stack to userspace per DIOCGETQSTATS call.

Root cause

The struct layout on amd64 (computed from sys/net/altq/altq_fairq.h:68-79 and sys/net/altq/altq_red.h:50-57):

struct fairq_classstats {
    uint32_t         class_handle;   // off  0, size 4
    u_int            qlength;        // off  4, size 4
    u_int            qlimit;         // off  8, size 4
    /* 4 bytes compiler-inserted padding here (align pktcntr.uint64) */
    struct pktcntr   xmit_cnt;       // off 16, size 16
    struct pktcntr   drop_cnt;       // off 32, size 16
    int              qtype;          // off 48, size 4
    /* 4 bytes compiler-inserted padding here (align redstats) */
    struct redstats  red[3];         // off 56, size 168 (3 Γ— ~56)
};                                   // total sizeof = 224

At sys/net/altq/altq_fairq.c:282, struct fairq_classstats stats; is declared on the kernel stack with no memset/initializer.

get_class_stats (altq_fairq.c:974-1001) writes: - sp->class_handle (978) - sp->qlimit (979) - sp->xmit_cnt (980) - sp->drop_cnt (981) - sp->qtype (982) - sp->qlength (983, then accumulated :988) - conditionally sp->red[0] via red_getstats (995) only if Q_RED - conditionally sp->red[0..2] via rio_getstats (999) only if Q_RIO

It never touches: - (a) 4 bytes of padding at struct offset 12-15 (between qlimit and xmit_cnt); - (b) 4 bytes of padding at struct offset 52-55 (between qtype and red[0]); - (c) when qtype == Q_DROPTAIL (the default), the entire red[3] array (168 bytes at offset 56-223); - (d) when qtype == Q_RED, red[1] and red[2] (112 bytes), plus 4 bytes of internal padding inside red[0] between q_avg and xmit_cnt (red_getstats at sys/net/altq/altq_red.c:252-260 does not fill that padding).

copyout((caddr_t)&stats, ubuf, sizeof(stats)) at altq_fairq.c:312 copies the full 224 bytes unconditionally, leaking all uninitialized regions.

The same pattern exists in sys/net/altq/altq_priq.c:priq_getqstats and sys/net/altq/altq_hfsc.c:hfsc_getqstats (identical stack-declared, partially-populated *_classstats struct).

Threat model & preconditions

  • Attacker position: privileged local user. /dev/pf is make_dev'd at sys/net/pf/pf_ioctl.c:3360 with mode 0600, UID root, GID wheel β€” so this requires either genuine root or a process that has been granted /dev/pf access (e.g. a jail with /dev/pf delegated).
  • Privileges gained or impact: disclosure of up to ~176 bytes of stale kernel stack per DIOCGETQSTATS call when the class uses Q_DROPTAIL (the default). The leaked bytes may include kernel text/data pointers from prior call frames (useful for KASLR bypass), credential structure pointers, or other sensitive locals. No code execution.
  • Required config or capabilities: root or /dev/pf access. A fairq discipline with a class using Q_DROPTAIL (the default) configured.
  • Reachability: open("/dev/pf") β†’ DIOCBEGINALTQS β†’ DIOCADDALTQ (scheduler=ALTQT_FAIRQ) β†’ DIOCADDALTQ (class with qname="def", default Q_DROPTAIL β†’ no FARF_RED/FARF_RIO) β†’ DIOCCOMMITALTQS β†’ DIOCGETQSTATS with pq.buf=<heap buffer>, pq.nbytes=sizeof(struct fairq_classstats).

Proof of concept

PoC source: findings/poc/DF-0592/fairq_leak.c (sketch β€” full driver to be materialized by the per-PoC verifier using <net/pf/pfvar.h> pf ioctls).

Build & run

cc -O2 -o fairq_leak fairq_leak.c
sudo ./fairq_leak em0       # must run as root or with /dev/pf access

Expected output

Hex dump of the returned struct fairq_classstats showing non-zero bytes at struct offsets 12-15, 52-55, and 56-223 (the uninitialized regions). Look for values in the kernel text/data range (e.g. 0xffffffff8xxxxxxx on amd64) β€” those are stale kernel pointers recovered from the stack.

class_handle = 0x...
qlimit       = ...
qtype        = 0  (Q_DROPTAIL)
stack leak:
  off 12-15 : 0x <possibly kernel pointer fragment>
  off 52-55 : 0x <possibly kernel pointer fragment>
  off 56-223: 168 bytes, of which:
    <hex dump showing stale kernel stack>

Impact

  • Blast radius: any DragonFly system where a privileged process can open /dev/pf and configure a FAIRQ class. Realistic in VPN concentrators, routers, and jails with delegated /dev/pf. The same defect affects priq and hfsc disciplines (identical code pattern).
  • Severity rationale: Low. Deterministic and repeatable leak, but the attacker is already privileged (/dev/pf mode 0600 root:wheel). Primary impact is KASLR bypass and credential-pointer disclosure for an already-privileged process in a constrained environment. No code execution.
  • Reliability: 100% β€” straight-line copyout of partially-initialized struct, no race.

Zero the stats struct before populating. Apply at the top of fairq_getqstats:

--- a/sys/net/altq/altq_fairq.c
+++ b/sys/net/altq/altq_fairq.c
@@ -279,6 +279,7 @@ fairq_getqstats(struct pf_altq *a, void *ubuf, int *nbytes)
    struct fairq_classstats stats;
    struct ifaltq *ifq;
    int error = 0;

+   memset(&stats, 0, sizeof(stats));
    if (*nbytes < sizeof(stats))
        return (EINVAL);

(Or initialize at declaration: struct fairq_classstats stats = {0};.)

The same fix should be applied to sys/net/altq/altq_priq.c:priq_getqstats and sys/net/altq/altq_hfsc.c:hfsc_getqstats, which have the identical pattern (stack-declared, partially-populated *_classstats struct).

References

  • The classic pattern: kernel code that copyouts a stack-declared struct partially filled by a helper. The historical fix in many BSD subsystems is memset/= {0} at declaration.
  • sys/net/pf/pf_ioctl.c:3360 β€” /dev/pf make_dev with 0600 root:wheel (the privilege gate that bounds the impact here).

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0592 Β· 17 files
FileTypeDescriptionSize
fairq_leak.c trigger-source Full pf-altq driver: DIOCXBEGIN/DIOCADDALTQ x2/DIOCXCOMMIT/DIOCGETALTQS/DIOCGETALTQ/DIOCGETQSTATS, hex-dumps uninitialized regions of struct fairq_classstats 7.9 KB view raw
build.sh build-script cc -O2 -o fairq_leak fairq_leak.c 117 B view raw
run.sh run-script kldload pf.ko (system setup) + ./fairq_leak vtnet0 406 B view raw
README.md readme Build/run/expected output, mechanism summary, file list 4.3 KB ↓ raw
VERDICT.md verdict Full mechanism walkthrough with path:line, exploit-chain discussion, PoC-change log, fix-validation table 7.1 KB ↓ raw
fix.diff suggested-fix memset(&stats, 0, sizeof(stats)) in fairq_getqstats + priq_getqstats + hfsc_getqstats 835 B view raw
run.log run-log Decisive unpatched run: 124/176 non-zero bytes, 27 ptr windows 1.1 KB view raw
run.2.log run-log Leak stress-test run 2: 133/176 1.1 KB view raw
run.3.log run-log Leak stress-test run 3: 133/176 1.1 KB view raw
baseline_unpatched.log run-log Phase 8 baseline reproduction on #0 (138/176) 976 B view raw
fix_run.log run-log Phase 8 patched #1 run: 0/176 (leak GONE) 855 B view raw
fix_build.log build-log Single-fix nativekernel build, rc=0 5.6 MB ↓ download
build.log build-log PoC compiler output, BUILD_EXIT=0 13 B view raw
leak_sample.txt leak-sample 4-run byte-level variance sample of leaked stack 3.3 KB view raw
env.txt environment uname, cc version, kldstat 558 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 Build/run/expected output, mechanism summary, file list
↓ download raw

DF-0592 β€” PoC: uninitialized kernel stack leak via fairq_getqstats copyout

Privileged local info-leak PoC. fairq_getqstats (sys/net/altq/altq_fairq.c:278) declares struct fairq_classstats stats; on the stack without zeroing (line 282). The helper get_class_stats (altq_fairq.c:974-1001) only populates a subset of the struct fields β€” compiler-inserted padding bytes plus the entire red[3] array (when the class uses Q_DROPTAIL, the default) remain uninitialized. copyout((caddr_t)&stats, ubuf, sizeof(stats)) at altq_fairq.c:312 copies the full 224-byte struct unconditionally, leaking up to 176 bytes of stale kernel stack per DIOCGETQSTATS call.

The same defect exists in altq_priq.c:priq_getqstats and altq_hfsc.c:hfsc_getqstats (identical stack-declared, partially-populated *_classstats struct); fix.diff closes all three.

Files

  • fairq_leak.c β€” full driver: DIOCXBEGIN/DIOCADDALTQ x2 (discipline + Q_DROPTAIL class) / DIOCXCOMMIT / DIOCGETALTQS / DIOCGETALTQ / DIOCGETQSTATS, then hex-dumps the uninitialized regions of the returned struct.
  • build.sh β€” cc -O2 -o fairq_leak fairq_leak.c
  • run.sh β€” kldload pf.ko (system setup) + ./fairq_leak vtnet0
  • run.log β€” decisive unpatched run (LEAK CONFIRMED)
  • run.2.log,run.3.log β€” additional leak runs (variance)
  • baseline_unpatched.log β€” Phase 8 baseline reproduction on #0
  • fix_run.log β€” Phase 8 patched-kernel run (leak GONE)
  • fix_build.log β€” single-fix kernel build log (35k lines, rc=0)
  • leak_sample.txt β€” 4-run variance sample
  • env.txt β€” guest uname / cc / kldstat
  • fix.diff β€” git-apply-able fix for fairq + priq + hfsc
  • VERDICT.md β€” full narrative
  • manifest.json β€” catalog

Build & run

./build.sh                       # cc -O2 -o fairq_leak fairq_leak.c
sudo ./run.sh                    # kldload pf.ko ; ./fairq_leak vtnet0

(Or directly: cc -O2 -o fairq_leak fairq_leak.c && sudo ./fairq_leak vtnet0.)

Expected output (unpatched kernel)

Hex dump of the returned struct fairq_classstats showing non-zero bytes at struct offsets 12-15, 52-55, and 56-223 (the uninitialized regions). Many of those bytes look like kernel text/data pointers (0xffffffff8xxxxxxx on amd64) β€” stale stack frames recovered from prior kernel call paths. On the default GENERIC with-src kernel a typical leak is 124-138 non-zero bytes out of 176, with 27-29 kernel-pointer-looking 8-byte windows per call.

=== uninitialized-region analysis ===
  region [ 12.. 15] (  4 bytes) padding(qlimit->xmit_cnt) : 4 non-zero bytes
  region [ 52.. 55] (  4 bytes) padding(qtype->red[0])    : 4 non-zero bytes
  region [ 56..223] (168 bytes) red[3] (Q_DROPTAIL: ...)  : 130 non-zero bytes

TOTAL non-zero bytes in uninitialized regions: 138 / 176
kernel-pointer-looking 8-byte windows in red[] region: 29
RESULT: LEAK CONFIRMED β€” 138 bytes of uninitialized kernel stack returned to userspace.

Expected output (patched kernel β€” fix.diff applied)

  region [ 12.. 15] (  4 bytes) padding(qlimit->xmit_cnt) : 0 non-zero bytes
  region [ 52.. 55] (  4 bytes) padding(qtype->red[0])    : 0 non-zero bytes
  region [ 56..223] (168 bytes) red[3] (Q_DROPTAIL: ...)  : 0 non-zero bytes

TOTAL non-zero bytes in uninitialized regions: 0 / 176
RESULT: no leak β€” all uninitialized regions are zero.

Notes

  • /dev/pf is 0600 root:wheel (sys/net/pf/pf_ioctl.c:3360). The PoC must run as root (or with /dev/pf delegated, e.g. a jail with /dev/pf). This is a Low info-leak finding (CVSS PR:H/C:L); the realistic attacker is already privileged. No privilege escalation; the leak is the whole impact.
  • kldload pf.ko is system setup, not part of any escalation chain β€” it makes the pf subsystem available. The kernel-side bug (fairq_getqstats) ships in the base GENERIC kernel (compiled in via options ALTQ_FAIRQ); pf.ko is the standard userland-facing module.
  • The same defect exists in altq_priq.c:priq_getqstats and altq_hfsc.c:hfsc_getqstats. fix.diff patches all three with the identical memset(&stats, 0, sizeof(stats)) immediately after the declaration in each *_getqstats function.
VERDICT.md verdict Full mechanism walkthrough with path:line, exploit-chain discussion, PoC-change log, fix-validation table
↓ download raw

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

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline 138/176 non-zero, 29 kernel-ptr windows; patched 0/176 all zero x3.

BEFORE #0: 138/176 non-zero, 29 kernel-ptr windows. AFTER #1: 0/176 non-zero x3 runs.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Mon Jul 13 03:32:30 UTC 2026 (sha ca2b5d2d...)

Confirmed kernel references

Detail

Exploit chain

none -- pure info-leak (CWE-200). No write primitive. Ceiling: 176 bytes stale kernel stack per call.

Evidence (decisive lines)

baseline #0: 138/176 non-zero bytes, 29 kernel-ptr windows. patched #1: 0/176 non-zero, all zero x3 runs.

PoC changes

Wrote fairq_leak.c from scratch (folder had only README.md sketch). Uses actual DragonFly pf ioctl interface (DIOCXBEGIN+PF_RULESET_ALTQ). Added build.sh, run.sh, VERDICT.md, manifest.json, leak_sample.txt, fix.diff (3 hunks: fairq+priq+hfsc).

Verified recommended fix

Add memset(&stats, 0, sizeof(stats)) after local declarations in fairq_getqstats (:285), priq_getqstats (:223), hfsc_getqstats (:299). SUPERSAES finding markdown (extends to all three identical bugs). Full git-apply-able diff in findings/poc/DF-0592/fix.diff.

Verdict

REPRODUCED. fairq_getqstats declares struct fairq_classstats stats on kernel stack without zeroing. Helper only writes partial fields (Q_DROPTAIL leaves 176 bytes uninitialized). copyout leaks full 224-byte struct. Confirmed: 138/176 non-zero bytes with 29 kernel-pointer-looking windows, byte-for-byte variance across runs.