Uninitialized vm_zone->znalloc discloses stale kernel heap memory to unprivileged users via sysctl vm.zone (REQUESTS column)
| Field | Value |
|---|---|
| ID | DF-2829 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N |
| CWE | CWE-457 Use of Uninitialized Variable |
| File | sys/vm/vm_zone.c |
| Lines | 453 (no M_ZERO), 347-436 (znalloc never init), 848/854-858 (print) |
| Area | vm |
| Confidence | certain |
| Discovered | 2026-08-31 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | kernleak |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
zinit() allocates struct vm_zone with kmalloc(M_ZONE, M_NOWAIT) β no M_ZERO β and zinitna() initializes every stats field except znalloc, which is only ever incremented. sysctl_vm_zone() reads it and prints the sum in the world-readable vm.zone REQUESTS column, so 8 bytes of stale kernel heap at a fixed offset cross to any local user. Secondary window: LIST_INSERT_HEAD onto zlist precedes the bzero of zpcpu, letting a racing reader sum uninitialized per-CPU slots. Stock exposure: swap_zone on every kernel and the netbt pools on device-bluetooth kernels; any future runtime zinit() caller (KLD) makes it groomable.
Proof of contest
VERIFIED on the stock INVARIANTS kernel (findings/poc/DF-2829/): KLD
grooms the heap (16Γ kmalloc memset 0x41, kfree), zinit("leakmark_zone")
β unpriv su -m nobody -c sysctl vm.zone prints REQUESTS
4702111234474983745 == 0x4141414141414141 verbatim, first attempt.
Fix (znalloc=0 + bzero-before-publish + M_ZERO) validated on rebuilt
kernel #1. No userβroot route (read primitive only).
Recommended fix
Validated fix.diff in findings/poc/DF-2829/.
Timeline
- 2026-08-31 Discovered during pass-2 audit of vm_zone.c (GLM 5.3); groomed unpriv leak reproduced first attempt + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2829 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df2829_harness.c | β | 2.4 KB | view raw | |
| build.sh | β | 455 B | view raw | |
| run.sh | β | 643 B | view raw | |
| build.log | β | 5.5 KB | view raw | |
| run.log | β | 1.2 KB | view raw | |
| fix_validation.log | β | 725 B | view raw | |
| fix.diff | β | 666 B | view raw | |
| README.md | β | 2.2 KB | β raw | |
| VERDICT.md | β | 3.7 KB | β raw | |
| verdict.json | β | 3.6 KB | view raw |
DF-2829 β Uninitialized vm_zone->znalloc heap data disclosed to unprivileged users via sysctl vm.zone
What
zinit() (sys/vm/vm_zone.c:453) allocates struct vm_zone with
kmalloc(..., M_ZONE, M_NOWAIT) β no M_ZERO β and zinitna()
(sys/vm/vm_zone.c:347-436) initializes every stats field except
z->znalloc. That field is only ever incremented (vm_zone.c:773,786).
sysctl_vm_zone() reads it (vm_zone.c:848) and prints the sum in the
REQUESTS column (vm_zone.c:854-858). vm.zone is a stock, world-readable
sysctl (CTLFLAG_RD, no privilege gate) β confirmed readable as nobody.
Result: 8 bytes of stale kernel heap memory (at the fixed offset of
znalloc inside a 16640-byte block) cross to unprivileged userspace, as a
decimal long, for every zone created through zinit():
SWAPMETA (swap_pager.c:441 β every stock kernel) and the three netbt pools
(bt_proto.c:139-149 on device bluetooth kernels). A secondary window: the
zone is LIST_INSERT_HEAD'd onto zlist (vm_zone.c:359) before
bzero(z->zpcpu,...) (vm_zone.c:362), so a racing reader can also sum
uninitialized per-CPU slots during the init window.
Reproduce
Guest (stock X86_64_GENERIC INVARIANTS kernel #0, DragonFly 6.5-DEVELOPMENT):
build.shβ build the KLD harnessdf2829.ko(in-guest, needs /usr/src).run.shas root:kldload /root/df2829/df2829.ko- the harness grooms the heap: 16 Γ kmalloc(sizeof(struct vm_zone)) filled with0x41, freed; thenzinit("leakmark_zone", 64, 1, ...).- Read as an unprivileged user:
sysctl vm.zone | grep leakmark
Expected (vulnerable kernel)
leakmark_zon 000064, 00000000, 000000, 000000, 4702111234474983745
4702111234474983745 = 0x4141414141414141 β exactly the groomed marker,
proving the uninitialized heap field reached userspace. On the patched
kernel the same run prints REQUESTS 00000000.
Files
df2829_harness.cβ KLD groom + zone sourcebuild.sh/run.shbuild.log/run.logβ decisive run (root +nobodyreads)fix_validation.logβ patched-kernel rerun (marker gone)fix.diffβ git-apply-able fix (znalloc init + M_ZERO + bzero reorder)manifest.json,verdict.json
DF-2829 VERDICT β REPRODUCED (leak)
Bottom line
zinit() kmallocs struct vm_zone (16640 bytes) without M_ZERO
(sys/vm/vm_zone.c:453) and zinitna() never initializes z->znalloc
(sys/vm/vm_zone.c:347-436 β every other stats field is set; znalloc is
only ever incremented at vm_zone.c:773/786). The world-readable sysctl
vm.zone prints that field (vm_zone.c:848,854-858) to any local user
(verified: identical output as root and via unprivileged run_user).
How it was reproduced
Stock guest kernel (#0, X86_64_GENERIC, INVARIANTS):
- KLD harness groomed the heap: 16 allocations of
sizeof(struct vm_zone)memset to0x41, freed (same size class aszinit's kmalloc). zinit("leakmark_zone", 64, 1, ZONE_DESTROYABLE)β zone struct reused a marker block (zone=0xfffff80118628000, kprintf in run.log).- Unprivileged
sysctl vm.zoneprinted:
leakmark_zon 000064, 00000000, 000000, 000000, 4702111234474983745
4702111234474983745 == 0x4141414141414141 β the groomed heap marker,
verbatim, in the REQUESTS column. USED/FREE/LIMIT are 0, so REQUESTS is
pure uninitialized field content. Marker crossed to userspace on the
first attempt (attempts=1) and identically for root and nobody.
Reachability on a stock system (no KLD)
swap_zone is created with zinit() on every boot (sys/vm/swap_pager.c:441),
and the three netbt pools on device bluetooth kernels
(sys/netbt/bt_proto.c:139-149). On the fresh stock guest the SWAPMETA
REQUESTS column read 00000001 β i.e. the stale qword happened to be 0 on
that boot (early-boot heap largely fresh pages), which is why the harness
groom is used for a deterministic demonstration. The disclosure window is
structural: whatever occupies that 16 KB heap block before zinit()
(boot-time kernel allocations, or β for any future runtime zinit() caller,
e.g. a KLD β groomable content) is printed to unprivileged userspace.
Secondary window (same finding/fix): LIST_INSERT_HEAD(&zlist, z) at
vm_zone.c:359 happens before bzero(z->zpcpu) at vm_zone.c:362, so a
racing sysctl vm.zone reader can sum uninitialized per-CPU
zfreecnt/znalloc slots in the init window (same unprivileged reader surface
as DF-0951).
Exploit chain
Not a corruption chain β a read primitive: unprivileged user reads
sysctl vm.zone; REQUESTS column of the first row discloses 8 bytes of
stale kernel heap per zinit()-created zone (decimal-encoded). Value is
bounded by heap residue at zone-creation time; on default kernels zone
creation is boot-time only, so the realistic yield is early-boot heap
residue (may contain kernel pointers β KASLR-relevant on hardened systems;
guest has no KASLR). Impact ceiling: limited kernel heap info leak
(CWE-457). No write primitive, no escalation path.
Fix validation
Authored fix.diff (znalloc init in zinitna + M_NOWAIT|M_ZERO in zinit +
bzero-before-insert reorder). Applied to the guest's /usr/src copy,
make nativekernel KERNCONF=X86_64_GENERIC (vm_zone.o rebuilt 20:55,
kernel.stripped relinked), make installkernel, reboot into kernel #1
(DragonFly 6.5-DEVELOPMENT #1: Tue Sep 1 20:57:06 UTC 2026).
Re-ran the exact PoC on the patched kernel (same groom, same module):
leakmark_zon: ... REQUESTS 00000000 β the 0x4141414141414141 marker is
GONE (fix_validation.log). Baseline reproduced / patched not reproduced β
fix_status=fixed.
Kernel references
- sys/vm/vm_zone.c:453 (kmalloc without M_ZERO)
- sys/vm/vm_zone.c:347-363 (zinitna init block β no znalloc; bzero after insert)
- sys/vm/vm_zone.c:773,786 (only-ever-increment)
- sys/vm/vm_zone.c:848,854-858 (unprivileged read + print)
- sys/vm/swap_pager.c:441, sys/netbt/bt_proto.c:139-149 (stock zinit callers)
Fix verification
fixedApplied fix.diff to guest /usr/src/sys/vm/vm_zone.c, make nativekernel KERNCONF=X86_64_GENERIC (vm_zone.o rebuilt, kernel relinked), installkernel + reboot into #1. Identical PoC rerun (same groom/module): leakmark REQUESTS=00000000 - marker leak eliminated; no regressions in other rows.
findings/poc/DF-2829/fix_validation.log (patched run), fix.diff, compare run.log (baseline 4702111234474983745)
Confirmed kernel references
Detail
Exploit chain
unpriv user -> sysctl vm.zone -> REQUESTS column of zinit()-created zones = 8 bytes of stale kernel heap (decimal) per zone, incl. the transient pre-bzero zpcpu window; read primitive only, no escalation
Evidence (decisive lines)
run.log: 'leakmark_zon 000064, 00000000, 000000, 000000, 4702111234474983745' (=0x4141414141414141) from 'su -m nobody -c sysctl vm.zone'; fix_validation.log: same run on kernel #1 gives REQUESTS 00000000; dmesg line 'zone=0xfffff80118628000 sizeof(struct vm_zone)=16640'
PoC changes
self-authored harness (no seed); groom = 16x kmalloc(sizeof(struct vm_zone)) memset 0x41 then kfree, immediately followed by zinit so the freed block is reused; verified nobody can read vm.zone via 'su -m nobody -c sysctl vm.zone'
Verified recommended fix
Initialize z->znalloc=0 in zinitna's init block, allocate the zone with M_ZERO in zinit(), and bzero(z->zpcpu) before LIST_INSERT_HEAD onto zlist (see fix.diff)
Verdict
zinit() kmallocs struct vm_zone without M_ZERO (vm_zone.c:453) and zinitna() never initializes z->znalloc (vm_zone.c:347-436; the field is only incremented at :773/:786). The world-readable sysctl vm.zone prints it (:848,:854-858) - verified readable as nobody. On the stock INVARIANTS guest, a heap-groomed KLD harness produced REQUESTS=4702111234474983745 (0x4141414141414141) for a zero-activity zone: the groomed 8-byte heap marker crossed verbatim to unprivileged userspace on the first attempt. Stock exposure: SWAPMETA (swap_pager.c:441, every kernel) and the netbt pools (bt_proto.c:139-149, device bluetooth); on a fresh boot the stale qword read 0 (early-boot fresh pages), so the realistic yield is boot-time heap residue; the structural uninitialized read is default-config present. Fixed by znalloc init + M_ZERO + bzero-before-zlist-insert: patched kernel #1 prints REQUESTS 00000000 for the identical run.
No comments yet.