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/pfismake_dev'd at sys/net/pf/pf_ioctl.c:3360 with mode0600, UID root, GID wheel β so this requires either genuine root or a process that has been granted/dev/pfaccess (e.g. a jail with/dev/pfdelegated). - Privileges gained or impact: disclosure of up to ~176 bytes of stale
kernel stack per
DIOCGETQSTATScall when the class usesQ_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/pfaccess. A fairq discipline with a class usingQ_DROPTAIL(the default) configured. - Reachability:
open("/dev/pf")βDIOCBEGINALTQSβDIOCADDALTQ(scheduler=ALTQT_FAIRQ) βDIOCADDALTQ(class withqname="def", defaultQ_DROPTAILβ noFARF_RED/FARF_RIO) βDIOCCOMMITALTQSβDIOCGETQSTATSwithpq.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/pfand configure a FAIRQ class. Realistic in VPN concentrators, routers, and jails with delegated/dev/pf. The same defect affectspriqandhfscdisciplines (identical code pattern). - Severity rationale: Low. Deterministic and repeatable leak, but the
attacker is already privileged (
/dev/pfmode 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.
Recommended fix
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 ismemset/= {0}at declaration. sys/net/pf/pf_ioctl.c:3360β/dev/pfmake_dev with0600root: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)
PoC verification
Evidence pack
findings/poc/DF-0592 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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/DIOCADDALTQx2 (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.crun.shβkldload pf.ko(system setup) +./fairq_leak vtnet0run.logβ decisive unpatched run (LEAK CONFIRMED)run.2.log,run.3.logβ additional leak runs (variance)baseline_unpatched.logβ Phase 8 baseline reproduction on#0fix_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 sampleenv.txtβ guest uname / cc / kldstatfix.diffβ git-apply-able fix for fairq + priq + hfscVERDICT.mdβ full narrativemanifest.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/pfis0600 root:wheel(sys/net/pf/pf_ioctl.c:3360). The PoC must run as root (or with/dev/pfdelegated, e.g. a jail with/dev/pf). This is a Low info-leak finding (CVSSPR:H/C:L); the realistic attacker is already privileged. No privilege escalation; the leak is the whole impact.kldload pf.kois 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 viaoptions ALTQ_FAIRQ);pf.kois the standard userland-facing module.- The same defect exists in
altq_priq.c:priq_getqstatsandaltq_hfsc.c:hfsc_getqstats.fix.diffpatches all three with the identicalmemset(&stats, 0, sizeof(stats))immediately after the declaration in each*_getqstatsfunction.
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)
-
Trigger. A privileged local user (root or any process holding
/dev/pf, which is0600 root:wheelpersys/net/pf/pf_ioctl.c:3360) configures a FAIRQ discipline on an interface with aQ_DROPTAILclass (the default β noFARF_RED/FARF_RIOflags), then issuesDIOCGETQSTATS(sys/net/pf/pfvar.h:1707). The ioctl dispatches throughpfioctl(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). -
Primitive. In
fairq_getqstatsthe kernel declaresstruct fairq_classstats stats;on the stack without zeroing (altq_fairq.c:282). The helperget_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. - Conditionallysp->red[0](Q_RED) orsp->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).
- Effect.
copyout((caddr_t)&stats, ubuf, sizeof(stats))ataltq_fairq.c:312copies 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_ALTQto obtain the altq-transaction ticket (NOTDIOCBEGINALTQSβ that constant is defined inpfvar.hbut is not wired to a case inpf_ioctl.c; altq begin/commit are only reachable via the batchedDIOCXBEGIN/DIOCXCOMMITpath withrs_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 atpf_ioctl.c:2038-2044, and auto-allocates a non-zeroqidviapf_qname2qid). - Walks the active list with
DIOCGETALTQS/DIOCGETALTQto find the queue entry (the one withqname[0] != 0). - Issues
DIOCGETQSTATSwithpq.bufpoisoned with0xAAfirst, 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(fromsys/net/altq/altq_classq.h:48-50, not the0/1/2the 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.komust be loaded (kldload pf.ko) for/dev/pfto 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 bykldload./dev/pfexists only whenpf.kois 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
fixedVALIDATED: 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.
Confirmed kernel references
- sys/net/altq/altq_fairq.c:278
- sys/net/altq/altq_fairq.c:282
- sys/net/altq/altq_fairq.c:306
- sys/net/altq/altq_fairq.c:312
- sys/net/altq/altq_fairq.c:436
- sys/net/altq/altq_fairq.c:974
- sys/net/altq/altq_fairq.c:982
- sys/net/altq/altq_fairq.c:993
- sys/net/altq/altq_classq.h:48
- sys/net/altq/altq_classq.h:50
- sys/net/altq/altq_subr.c:677
- sys/net/altq/altq_priq.c:217
- sys/net/altq/altq_hfsc.c:293
- sys/net/pf/pf_ioctl.c:2031
- sys/net/pf/pf_ioctl.c:2097
- sys/net/pf/pf_ioctl.c:3360
- sys/net/pf/pfvar.h:1707
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.
No comments yet.