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

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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2829 Β· 10 files
FileTypeDescriptionSize
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):

  1. build.sh β€” build the KLD harness df2829.ko (in-guest, needs /usr/src).
  2. run.sh as root: kldload /root/df2829/df2829.ko - the harness grooms the heap: 16 Γ— kmalloc(sizeof(struct vm_zone)) filled with 0x41, freed; then zinit("leakmark_zone", 64, 1, ...).
  3. 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 source
  • build.sh / run.sh
  • build.log / run.log β€” decisive run (root + nobody reads)
  • fix_validation.log β€” patched-kernel rerun (marker gone)
  • fix.diff β€” git-apply-able fix (znalloc init + M_ZERO + bzero reorder)
  • manifest.json, verdict.json
VERDICT.md
↓ download raw

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

  1. KLD harness groomed the heap: 16 allocations of sizeof(struct vm_zone) memset to 0x41, freed (same size class as zinit's kmalloc).
  2. zinit("leakmark_zone", 64, 1, ZONE_DESTROYABLE) β€” zone struct reused a marker block (zone=0xfffff80118628000, kprintf in run.log).
  3. Unprivileged sysctl vm.zone printed:

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied 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)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 1 20:57:06 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.