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)
PoC verification
Evidence pack
findings/poc/DF-5680 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
ERANGEoverflow 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-kernelstrtoulsaturation is silently accepted (size="999999999999999999999999999999"βlen = SIZE_MAX); a plainsize="-1"bypasses the guard even in userland (negation is in-range modulo 2^64 β no ERANGE, verified: run.userland.logneg1errno=0) buf = _PROP_MALLOC(len + 1, M_PROP_DATA)β prop_data.c:588 β which iskmalloc(len + 1, M_PROP_DATA, M_WAITOK)(prop_object_impl.h:260) with no upper bound of any kind on the attacker string- DF
kmallocsemantics for such sizes (sys/kern/kern_slaballoc.c): len+1 == 0(len = SIZE_MAX) βkmalloc(0)returns the special non-NULLZERO_LENGTH_PTR(:888-891) β today still contained by the decoder'stargsize==0bounds checks + thealen != lencheck (prop_data.c:597), i.e. only by luck- size β₯ kernel_map KVA span (e.g. 2^63) β
kmem_slab_allocfailsvm_map_findspaceand panicsβkmem_slab_alloc(): kernel_map ran out of space!β(:1721-1724) βM_WAITOK(0x0002) does not includeM_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).
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
- Stock kernel, panic tier.
./df5680_dmcrash panicsends the 98-byte plist<plist version="1.0"><dict><key>a</key><data size="9223372036854775808">QQ==</data></dict></plist>throughNETBSD_DM_IOCTLon/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: * tagattrsizematched (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) *_kmalloclarge path βkmem_slab_alloc(0x8000000000001000, ...)(kern_slaballoc.c:942-946) βvm_map_findspacefails β panic "kmem_slab_alloc(): kernel_map ran out of space!" (kern_slaballoc.c:1721-1724;M_WAITOK=0x0002 does not includeM_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.
-
Stock kernel, wedge tier.
./df5680_dmcrash hangwithsize="8589934592"(8 GiB on the 4 GiB guest): KVA is available, sovm_map_findspacesucceeds,vm_map_insertreserves 8 GiB, and the page loop allocates every retrievable page; once memory is exhaustedvm_page_allocreturns NULL and the M_WAITOK retry path callsvm_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 showspagedaemon: 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. -
Userland differential (same source). With
_PROP_MALLOC == mallocthe 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-kernelkmalloc(0)β non-NULLZERO_LENGTH_PTR, kern_slaballoc.c:888-891) is today contained by the decoder'starindex >= targsizechecks (prop_data.c:418/428/442/456 with targsize==0) and thealen != lencheck (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 afterlenis 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/lenspoofing viasizeattr: caught byalen != 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_freealways 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
fixedfix.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
Confirmed kernel references
- sys/libprop/prop_data.c:543-588
- sys/libprop/prop_data.c:565-579
- sys/libprop/prop_data.c:384-530
- sys/libprop/prop_object_impl.h:258-260
- sys/libprop/prop_kern.c:385-425
- sys/kern/kern_slaballoc.c:863-878
- sys/kern/kern_slaballoc.c:888-891
- sys/kern/kern_slaballoc.c:942-950
- sys/kern/kern_slaballoc.c:1706-1724
- sys/kern/kern_slaballoc.c:1778-1804
- sys/dev/disk/dm/device-mapper.c:258-279
- sys/kern/kern_udev.c:888-892
- sys/kern/vfs_quota.c:346
Detail
Exploit chain
operator/root opens /dev/mapper/control -> ioctl(NETBSD_DM_IOCTL, plistref{
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).
No comments yet.