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

Unbounded kmalloc(len+1, M_PROP_DATA, M_WAIT) from the attacker-controlled <data size="N"> plist attribute in _prop_data_internalize: deterministic kernel panic (size beyond kernel_map KVA) and non-interruptible system-wide vm_wait wedge (size > RAM); strtoul ERANGE guard compiled out in-kernel

Summary

The <data size=N> path takes the blob length from the XML attribute via strtoul with no bound, and the ERANGE guard is #ifndef _KERNEL'd out: a 98-byte plist with size=2^63 panics the stock guest (M_WAITOK lacks M_NULLOK so kmem_slab_alloc panics when vm_map_findspace fails) and size=8GiB wedges the entire system (the M_WAITOK page loop retries vm_wait(0) forever, ssh killed, pagedaemon continuous-paging warnings). Same inputs against the same source in userland fail cleanly - the kernel-only M_WAITOK failure semantics are the multiplier. The len=SIZE_MAX wrap ('size=-1' bypasses even userland ERANGE since negation sets no errno) reaches kmalloc(0)==ZERO_LENGTH_PTR, contained today only by incidental decoder targsize checks. Operator-group local via /dev/mapper/control. doc-length-clamp fix validated on a full kernel rebuild (both kills -> clean EIO, controls parse, non-regressed).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5680 Β· 18 files
FileTypeDescriptionSize
README.md β€” 4.9 KB ↓ raw
VERDICT.md β€” 7.4 KB ↓ raw
df5680_userland.c β€” 4.5 KB view raw
df5680_dmcrash.c β€” 3.2 KB view raw
df5680_mod.c β€” 2.3 KB view raw
build.sh β€” 128 B view raw
run.sh β€” 233 B view raw
build.log β€” 9 B view raw
build.kernel.log β€” 5.7 MB ↓ download
run.userland.log β€” 533 B view raw
panic.txt β€” 2.4 KB view raw
hang.console.txt β€” 254 B view raw
fixtest.console.txt β€” 731 B view raw
fixtest.module.txt β€” 1.1 KB view raw
env.txt β€” 213 B view raw
fix.diff β€” 662 B view raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 7.0 KB view raw

DF-5680 β€” <data size="N"> plist attribute drives an unbounded kmalloc(M_WAITOK) in _prop_data_internalize (kernel panic / system wedge)

File: sys/libprop/prop_data.c (pass 2, GLM 5.3) Verdict: reproduced (stock-kernel panic + system-wide wedge; both eliminated by fix.diff) Impact: kernel panic (availability), memory-drain DoS β€” via root/operator-gated kernel plist entry points (DM ioctl, udev UDEVPROP, quotactl-prop, tbridge)

Root cause (path:line)

_prop_data_internalize() (sys/libprop/prop_data.c:543) accepts an attacker-controlled size attribute on <data> elements:

<plist version="1.0"><dict><key>a</key><data size="N">QQ==</data></dict></plist>
  • len = strtoul(ctx->poic_tagattrval, &cp, 0) β€” prop_data.c:572
  • the ERANGE overflow guard is compiled out of the kernel (#ifndef _KERNEL, prop_data.c:573-576 β€” β€œXXX can't check for ERANGE in the kernel”), so in-kernel strtoul saturation is silently accepted (size="999999999999999999999999999999" β†’ len = SIZE_MAX); a plain size="-1" bypasses the guard even in userland (negation is in-range modulo 2^64 β†’ no ERANGE, verified: run.userland.log neg1 errno=0)
  • buf = _PROP_MALLOC(len + 1, M_PROP_DATA) β€” prop_data.c:588 β€” which is kmalloc(len + 1, M_PROP_DATA, M_WAITOK) (prop_object_impl.h:260) with no upper bound of any kind on the attacker string
  • DF kmalloc semantics for such sizes (sys/kern/kern_slaballoc.c):
  • len+1 == 0 (len = SIZE_MAX) β†’ kmalloc(0) returns the special non-NULL ZERO_LENGTH_PTR (:888-891) β€” today still contained by the decoder's targsize==0 bounds checks + the alen != len check (prop_data.c:597), i.e. only by luck
  • size β‰₯ kernel_map KVA span (e.g. 2^63) β†’ kmem_slab_alloc fails vm_map_findspace and panics β€œkmem_slab_alloc(): kernel_map ran out of space!” (:1721-1724) β€” M_WAITOK (0x0002) does not include M_NULLOK (0x0400), so NULL is not an option
  • size > physical RAM (e.g. 8 GiB on a 4 GiB guest) β†’ the M_WAITOK page loop retries forever in vm_wait(0) (:1778-1804): the ioctl never returns, the thread is not signal-interruptible, and the drain takes the whole guest down (pagedaemon: WARNING! Continuous paging)

Kernel reachability (all root/operator gated on stock configs): /dev/mapper/control 0640 root:operator β†’ NETBSD_DM_IOCTL β†’ prop_dictionary_copyin_ioctl (sys/dev/disk/dm/device-mapper.c:267); /dev/udev 0600 β†’ UDEVPROP (sys/kern/kern_udev.c:892); quotactl-prop (sys/kern/vfs_quota.c:346); tbridge (sys/dev/misc/tbridge/tbridge.c:258).

Pack contents

file what
df5680_userland.c userland differential harness (6 scenarios)
df5680_dmcrash.c kernel trigger via DM ioctl (panic/hang/custom size)
df5680_mod.c KLD probe calling prop_dictionary_internalize in-kernel (fix validation, unambiguous vs DM's own EIO)
build.sh / run.sh exact runnable commands
build.log userland rig build (guest)
run.userland.log userland differential battery
panic.txt serial-console panic from the stock kernel (size="2^63") β€” stack: _prop_data_internalize β†’ _kmalloc β†’ kmem_slab_alloc
hang.console.txt serial console of the 8 GiB wedge (pagedaemon: WARNING! Continuous paging for 1 minutes, ssh dead)
fixtest.console.txt patched kernel: panic + hang inputs both return cleanly (ioctl EIO), guest alive
fixtest.module.txt patched kernel KLD probe: control/plain β†’ dict OK size=5 (non-regression); huge/hang β†’ NULL
build.kernel.log fix kernel rebuild (make -j6 nativekernel, installkernel)
env.txt guest uname / physmem / toolchain
fix.diff git-apply-able unified diff (authored against read-only sys/; applied only inside guest /usr/src for validation)

Reproduce

# on the audit guest (root), stock kernel:
cd /tmp/df5680 && sh build.sh && sh run.sh          # userland differential
kldload dm
./df5680_dmcrash panic        # size=2^63  -> guest panics (see panic.txt)
# after vm.sh reset with-src + re-rig:
./df5680_dmcrash hang         # size=8GiB  -> guest wedges (hang.console.txt)

Expected (stock): panic with the exact stack in panic.txt; system-wide memory drain for the hang variant. Userland same-source rig: every scenario exits cleanly (malloc NULL β†’ internalize NULL).

Fix

fix.diff bounds len by the number of characters remaining in the XML document (len > strlen(ctx->poic_cp) β†’ reject): a base64 blob can never decode to more bytes than its own encoding occupies, so this rejects 2^63 / 8 GiB / SIZE_MAX / strtoul-overflow values while accepting every legitimate plist (validated in-kernel and in userland).

Validated by full kernel rebuild + reboot on the guest: baseline panic and wedge inputs are gone (clean EIO rejection, guest healthy), legit size="5" and attribute-free data plists still internalize (kernel probe + userland rig).

VERDICT.md
↓ download raw

DF-5680 β€” VERDICT

Status: reproduced (stock kernel), fixed (fix.diff validated by full kernel rebuild + reboot). Impact: kernel panic (deterministic) + system-wide memory-drain wedge (8 GiB variant), availability only. Root/operator entry bar (all kernel plist copyin paths are 0600/0640 on stock configs). No memory-corruption primitive found: every write in the decoder is bounded (see negative results), so no escalation chain was pursued β€” the bug class is unbounded-alloc/DoS, not corruption.

How it reproduces

  1. Stock kernel, panic tier. ./df5680_dmcrash panic sends the 98-byte plist <plist version="1.0"><dict><key>a</key><data size="9223372036854775808">QQ==</data></dict></plist> through NETBSD_DM_IOCTL on /dev/mapper/control (0640 root:operator). dmioctl (device-mapper.c:267) β†’ prop_dictionary_copyin_ioctl β†’ _prop_object_copyin (prop_kern.c:398-411) β†’ prop_dictionary_internalize β†’ dict body positions the ctx on the value tag (prop_dictionary.c:1347) β†’ _prop_data_internalize: * tagattr size matched (prop_data.c:565) * strtoul("9223372036854775808") = 0x8000000000000000, whole attribute consumed (prop_data.c:577) * buf = kmalloc(0x8000000000000001, M_PROP_DATA, M_WAITOK) (prop_data.c:588) * _kmalloc large path β†’ kmem_slab_alloc(0x8000000000001000, ...) (kern_slaballoc.c:942-946) β†’ vm_map_findspace fails β†’ panic "kmem_slab_alloc(): kernel_map ran out of space!" (kern_slaballoc.c:1721-1724; M_WAITOK=0x0002 does not include M_NULLOK=0x0400).

Observed (panic.txt): panic: kmem_slab_alloc(): kernel_map ran out of space! Trace ... kmem_slab_alloc() at kmem_slab_alloc+0x42b _kmalloc() at _kmalloc+0x5be _prop_data_internalize() at _prop_data_internalize+0xa1 <-- prop_data.c:588 _prop_object_internalize_by_tag() ... _prop_generic_internalize() ... Guest down immediately after the ioctl banner; ssh dead.

  1. Stock kernel, wedge tier. ./df5680_dmcrash hang with size="8589934592" (8 GiB on the 4 GiB guest): KVA is available, so vm_map_findspace succeeds, vm_map_insert reserves 8 GiB, and the page loop allocates every retrievable page; once memory is exhausted vm_page_alloc returns NULL and the M_WAITOK retry path calls vm_wait(0) forever (kern_slaballoc.c:1792-1800). The ioctl thread never returns and is not signal-interruptible; the drain collapses the guest: ssh session died mid-script, vm.sh status β‡’ down, and the serial console shows pagedaemon: WARNING! Continuous paging for 1 minutes (hang.console.txt). Nothing can satisfy an 8 GiB wired allocation on a 4 GiB box; the state is terminal without a reset.

  2. Userland differential (same source). With _PROP_MALLOC == malloc the identical inputs are cleanly rejected (run.userland.log): * huge β†’ errno=12 (ENOMEM), internalize NULL * hang β†’ internalize NULL * overflow (30 nines) β†’ errno=34 (ERANGE) rejected by the userland-only guard prop_data.c:574 β€” the exact check the kernel compiles out * neg1 (size="-1") β†’ errno=0: strtoul returns ULONG_MAX without ERANGE (negation is in-range modulo 2^64), so even userland accepts the parse; the SIZE_MAX wrap (len+1 == 0, in-kernel kmalloc(0) β†’ non-NULL ZERO_LENGTH_PTR, kern_slaballoc.c:888-891) is today contained by the decoder's tarindex >= targsize checks (prop_data.c:418/428/442/456 with targsize==0) and the alen != len check (prop_data.c:597). Contained by luck, not by validation β€” this is the same missing-bound root cause.

Fix validation (mandatory: memory-impact finding reproduced)

  • fix.diff (authored against the read-only sys/ tree; applied only inside the guest's /usr/src copy) inserts the tight bound after len is determined, before the allocation (prop_data.c:583): c if (len > strlen(ctx->poic_cp)) return (true); A decoded blob can never exceed the number of characters remaining in the XML document, so every legitimate plist passes; 2^63 / 8 GiB / SIZE_MAX / strtoul-overflow values are rejected before any allocation.
  • Guest: make -j6 nativekernel KERNCONF=X86_64_GENERIC && make installkernel, reboot into #1: Fri Oct 9 23:45:32 UTC 2026 (build.kernel.log).
  • Baseline vs patched (fixtest.console.txt, fixtest.module.txt):
input stock kernel patched kernel
size="9223372036854775808" (2^63) panic (kmem_slab_alloc) internalize β†’ NULL, ioctl returns EIO, guest healthy
size="8589934592" (8 GiB) system-wide wedge, pagedaemon thrash, ssh dead internalize β†’ NULL, guest healthy
size="5" + "SGVsbG8=" (legit) dict OK dict OK, data size=5 (in-kernel KLD probe + userland rig)
<data>SGVsbG8=</data> (no attr) dict OK dict OK, data size=5

Note: in the DM-ioctl rig the legit control also surfaced errno=5 β€” traced to dm_check_version returning EIO for version-less dicts (dm_ioctl.c:1082-1089), not to the fix; the KLD probe (df5680_mod.c) calling prop_dictionary_internalize directly removes that ambiguity and shows control -> dict OK, data size=5 on the patched kernel. * Guest reset to the clean-source snapshot afterwards (vm.sh reset with-src).

Why no uid0 chain

The defect is an unbounded allocation size, not a write primitive. The decoder was audited state-by-state (all four states check tarindex/tarindex+1 against targsize before every write, prop_data.c:415-467) and the alen != len cross-check (prop_data.c:597) makes size-attribute spoofing unable to produce an object whose pd_size exceeds its buffer. The len+1 wrap yields ZERO_LENGTH_PTR with targsize == 0, which rejects on the first base64 character. No corruption surface β†’ impact ceiling is panic/wedge (root/operator-gated), consistent with Medium severity.

Negative results (classes considered and killed in this pass)

  • base64 decoder OOB write: all states bounds-checked vs targsize=len+1 (prop_data.c:417-459); pass-1/pass-2 decode is deterministic over the same buffer β€” no drift.
  • alen/len spoofing via size attr: caught by alen != len (prop_data.c:597).
  • _PROP_ASSERT(*cp == '\"') (prop_data.c:579, KKASSERT in-kernel): the cp-consumed check at :577 plus find_tag's guaranteed closing quote make it unviolable (the OOB quote scan itself is DF-2227, prop_object.c:484).
  • <data/> empty element β†’ internalizer returns true with NULL child: cleanly rejected by array/dict continue handlers (prop_array.c:806-807, prop_dictionary.c:1283+).
  • equals paths: type guards + size-0 early-return (prop_data.c:187-195, 359-382); memcmp(NULL, v, 0) benign.
  • lifecycle: consistent M_PROP_DATA alloc/free pairing (prop_data.c:231/93), NOCOPY objects never freed (prop_data.c:92), single-release error paths, pot_free always DONE (no emergency-free contract violation).
  • externalize buffer accounting: 256-byte growth realloc with capacity/len size_t (prop_object.c:243-253); base64 encoder tail handling correct for sizes 1-2 (prop_data.c:146-170).
  • refcount overflow/underflow: uint32 po_refcnt via atomics (prop_object.c:992); no prop_data-specific imbalance.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied inside guest /usr/src (never to the read-only sys/ tree), kernel rebuilt (make -j6 nativekernel KERNCONF=X86_64_GENERIC, build.kernel.log) and booted (#1 Fri Oct 9 23:45:32): the size=2^63 input that panicked the stock kernel now internalizes to NULL and the ioctl returns EIO with the guest healthy; the size=8GiB input that wedged the stock kernel (pagedaemon continuous paging, ssh dead) now returns cleanly; non-regression proven in-kernel (KLD probe: control/plain data plists still internalize, data size=5) and in userland against the patched source (roundtrip/sizeattr OK). Guest reset to clean-source snapshot afterwards.

fixtest.console.txt (FIXTEST panic/hang/control on patched kernel), fixtest.module.txt (DF5680MOD: control -> dict OK data size=5; huge/hang -> NULL), build.kernel.log (fix kernel build), fix.diff
↓ fix.diffper-fix-DF-5680

Confirmed kernel references

Detail

Exploit chain

operator/root opens /dev/mapper/control -> ioctl(NETBSD_DM_IOCTL, plistref{aQQ==}) -> prop_dictionary_copyin_ioctl (device-mapper.c:267) -> _prop_object_copyin (prop_kern.c:398-411) -> prop_dictionary_internalize -> dict body positions ctx on the value tag (prop_dictionary.c:1347) -> _prop_data_internalize reads size attr (prop_data.c:565), strtoul -> len (prop_data.c:572; ERANGE check compiled out in-kernel :573-576) -> kmalloc(len+1, M_PROP_DATA, M_WAITOK) (prop_data.c:588) -> [N=2^63] kmem_slab_alloc vm_map_findspace fails -> panic 'kernel_map ran out of space!' (kern_slaballoc.c:1723); [N=8GiB>RAM] vm_page_alloc NULL forever -> vm_wait(0) retry loop (kern_slaballoc.c:1797) -> unkillable thread + total memory drain -> guest-wide wedge (pagedaemon continuous paging). Same XML via /dev/udev UDEVPROP (kern_udev.c:892, 0600), quotactl-prop (vfs_quota.c:346) or tbridge on configs exposing them.

Evidence (decisive lines)

panic.txt (serial console: panic + stack _prop_data_internalize+0xa1 -> _kmalloc -> kmem_slab_alloc); hang.console.txt (pagedaemon: WARNING! Continuous paging for 1 minutes, guest unresponsive); run.userland.log (same-source userland differential: huge=ENOMEM-clean, hang=clean, overflow=ERANGE-only-in-userland, neg1=errno-0 acceptance, roundtrip/sizeattr sanity); fixtest.console.txt (patched kernel: both kill inputs return EIO, guest alive); fixtest.module.txt (KLD probe on patched kernel: control/plain -> dict OK data size=5, huge/hang -> NULL β€” non-regression proof, isolating DM's own dm_check_version EIO); build.kernel.log (nativekernel rebuild of fix); fix.diff (git-apply-clean).

PoC changes

No seed pack existed (IDs reserved fresh); trigger written to the established rig pattern: plistref mirror + _IOWR(0xfd,0,plistref) DM ioctl (as DF-5675), userland same-source differential (as DF-5675/DF-5678), KLD probe module (as DF-5678) added during fix validation to disambiguate DM's dm_check_version EIO from internalize failure.

Verified recommended fix

Reject implausible blob sizes before allocating: after computing len in _prop_data_internalize, return early if len exceeds the number of characters remaining in the XML document (len > strlen(ctx->poic_cp)) β€” base64 can never decode to more bytes than its own encoding, so every legitimate plist passes while 2^63/8GiB/SIZE_MAX/strtoul-overflow values are refused before kmalloc.

Verdict

Reproduced on the stock guest kernel: the attacker-controlled plist attribute flows through strtoul (prop_data.c:572, with the ERANGE guard compiled out in-kernel at :573-576) straight into kmalloc(len+1, M_PROP_DATA, M_WAITOK) (prop_data.c:588) with no upper bound. size=2^63 (98-byte plist, one DM ioctl on /dev/mapper/control 0640 root:operator) panics with 'kmem_slab_alloc(): kernel_map ran out of space!' and the exact stack _prop_data_internalize -> _kmalloc -> kmem_slab_alloc (panic.txt), because M_WAITOK lacks M_NULLOK (kern_slaballoc.c:1721-1724); size=8GiB on the 4GiB guest wedges the whole system in the non-interruptible vm_wait(0) retry loop (kern_slaballoc.c:1792-1800) with 'pagedaemon: WARNING! Continuous paging' on the console and ssh dead (hang.console.txt). The same inputs against the same libprop source in userland fail cleanly (malloc NULL -> internalize NULL; run.userland.log), and size="-1"/30-digit-overflow document the missing-ERANGE/negative acceptance gap (errno=0 pass-through in userland for -1). No memory-corruption primitive: every decoder write is bounds-checked (prop_data.c:415-467) and alen!=len (prop_data.c:597) blocks size spoofing, so impact ceiling is panic/wedge at a root/operator entry bar -> Medium. fix.diff (bound len by the remaining XML length) validated by full kernel rebuild + reboot: both kill inputs now internalize to NULL with the guest healthy, and legit size="5" / attribute-free data plists still parse (in-kernel KLD probe + userland rig).