Recursive proplib externalize vs. iterative internalize: DM ioctl plist echo exhausts the 16KB kernel thread stack (Fatal double fault at ~192 nesting levels)
Summary
Internalize, release, and equals were all deliberately made iterative for untrusted input - but externalize recurses through pot_extern per container level, and the device-mapper ioctl internalizes the attacker plist then unconditionally externalizes the SAME dict back out: nesting depth maps 1:1 to kernel recursion depth against the 16KB kstack. Reproduced on-guest: depth 128 survives, depth 192 (2.9KB XML) -> 'Fatal double fault' at page-aligned rsp, the mathematically predicted cliff (64B/frame objdump-verified); the same-source userland rig internalizes depth 20000 fine then SIGSEGVs in externalize. One-ioctl deterministic kernel kill via /dev/mapper/control (0640 root:operator - same bar as DF-2227/2231). depth-cap-64 fix validated on a full kernel rebuild (192/2048 -> clean ENOMEM, shallow plists still echo). Below-stack register writes march with the faulting frame and the double-fault IST handler wins before grooming - honest blocker documented.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5675 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.0 KB | β raw | |
| VERDICT.md | β | 3.6 KB | β raw | |
| df5675_dmcrash.c | β | 3.1 KB | view raw | |
| df5675_userland.c | β | 1.9 KB | view raw | |
| build.sh | β | 136 B | view raw | |
| run.sh | β | 90 B | view raw | |
| fix.diff | β | 1.2 KB | view raw | |
| panic.txt | β | 475 B | view raw | |
| panic.2.txt | β | 529 B | view raw | |
| fixtest.console.txt | β | 108 B | view raw | |
| build.kernel.log | β | 188 B | view raw | |
| run.userland.log | β | 379 B | view raw | |
| manifest.json | β | 1.2 KB | view raw | |
| verdict.json | β | 4.3 KB | view raw |
DF-5675 β Recursive proplib externalize vs. iterative internalize β kernel thread-stack exhaustion
Severity: High Β· Class: kernel stack exhaustion via unbounded recursion (CWE-674 β panic/memcorrupt) File: sys/libprop/prop_object.c (externalize machinery) β recursion sites: prop_array.c:177-181, prop_dictionary.c:458-467 Reachability: NETBSD_DM_IOCTL on /dev/mapper/control (0640 root:operator) β device-mapper.c:267-273 internalizes the attacker plist and unconditionally externalizes THE SAME dict back out.
Why it is a bug
proplib was explicitly hardened to be iterative for untrusted input: internalize
(_prop_object_internalize_by_tag, prop_object.c:617-659), release
(prop_object_release, prop_object.c:1061-1107 β "Do not recurse to avoid stack
overflows", prop_object.c:999-1000) and equals (prop_object.c:1128-1190) all use
the explicit prop_stack. The externalize walk is the one exception: each
nested <array>/<dict> costs one real C stack frame
(pot_extern β _prop_array_externalize β pot_extern β β¦).
The DM ioctl echoes the attacker's internalized dictionary back to userspace,
so attacker-chosen nesting depth maps 1:1 to kernel recursion depth. The kernel
thread stack is UPAGES * PAGE_SIZE = 4 * 4096 = 16KB
(sys/cpu/x86_64/include/param.h:126); the kernel build of
_prop_array_externalize uses a 64-byte frame (measured: objdump of
/boot/kernel/kernel at 0xffffffff809d91c0 β 6 pushes + sub $8 + ret addr).
Reproduce (guest, root β the device node is 0640 root:operator)
build.sh # same-source libprop rig + df5675_userland + df5675_dmcrash run.sh # A: userland differential B: kernel DM ioctl
Expected:
- A (userland, same source as kernel): depth 300 @
ulimit -s 256β internalize OK, externalize OK (95920 bytes). depth 20000 β internalize OK, externalize SIGSEGV (exit 139). - B (kernel): serial console shows
DF5675_DEPTH=64 β SURVIVED DF5675_DEPTH=128 β SURVIVED DF5675_DEPTH=192 β Fatal double fault, rsp = 0xfffff80117e6b000 (page-aligned β stack pointer ran off the kstack), panic: double fault
192 nesting levels = 2.9KB of XML (15 bytes per level). The dead 64KB
prop_object_copyin_limit (DF-2231) allows far deeper, but ~192 is already
fatal.
Fix validation
fix.diff adds _PROP_EXTERNALIZE_MAX_DEPTH (64) and refuses to externalize
deeper nesting (graceful false β prop_dictionary_externalize β NULL β
copyout returns ENOMEM). Applied inside the guest's /usr/src, kernel rebuilt
(make nativekernel, 320s), rebooted:
- baseline kernel: depth 192 β Fatal double fault (panic.txt, panic.2.txt)
- patched kernel: depth 192 and 2048 β
ioctl errno=12 (ENOMEM), guest stays up (fixtest.console.txt) - non-regression: depth 32 and 60 still externalize and echo back (
errno=0)
Files
df5675_dmcrash.c, df5675_userland.c, build.sh, run.sh, fix.diff, panic.txt (first crash), panic.2.txt (attributed depth ladder crash), fixtest.console.txt, build.kernel.log, run.userland.log
DF-5675 β VERDICT
reproduced / impact=panic / confidence=certain
What was proven, and how
-
The asymmetry is real in the shipped source. Internalize of a 20000-deep
<array>chain succeeds (iterative prop_stack) β printed live on the guest from the same-source rig (run.userland.log: "internalize OK (iterative parser handles any depth)"). Externalize of the same object SIGSEGVs under a 256KB stack rlimit (exit 139), while depth 300 externalizes fine. Same translation units the kernel compiles (/usr/src/sys/libprop). -
The kernel path exists and echoes attacker XML.
dmioctl(sys/dev/disk/dm/device-mapper.c:267-273):prop_dictionary_copyin_ioctl(pref, cmd, &dm_dict_in)β¦ then unconditionallyprop_dictionary_copyout_ioctl(pref, cmd, dm_dict_in)β the externalize walk runs over the full attacker-controlled object graph./dev/mapper/controlis created 0640 root:operator (device-mapper.c:181) β same reachability bar DF-2227/DF-2231 were filed with (any member of group operator; root trivially). -
The kernel stack overflow, attributed by depth. Serial console (panic.2.txt): depth 64 survived, depth 128 survived, depth 192 β
Fatal double fault,rsp = 0xfffff80117e6b000β a page-aligned rsp is the canonical "stack pointer ran off its 16KB kstack" signature (UPAGES=4, sys/cpu/x86_64/include/param.h:126; the double-fault IST handler catches the fault-on-fault). Measured kernel frame for_prop_array_externalize= 64B (objdump at 0xffffffff809d91c0: push rbp,r15,r14,r13,r12,rbx + sub $8 + retaddr), giving a theoretical cliff of ~16KB/64B β 256 minus the ioctl entry path β observed between 128 and 192, exactly consistent. The first crash (panic.txt) was the ladder run ending in the same signature at rsp=0xfffff8011891d000. -
Exploitability ceiling. Each recursion frame writes saved registers and locals below the current rsp, i.e. past the end of the kstack into adjacent kmem (thread kstacks are kmem_alloc'd), before the eventual guard fault. On this guest the observable outcome is a deterministic panic (impact=panic). A privileged-adjacent (operator) local user gets a reliable one-ioctl kernel kill with ~3KB of input; turning the below-stack writes into controlled corruption was not pursued because the frames write downward in lockstep with the fault and the double-fault handler wins before anything reusable can be groomed underneath.
Fix validation (fix.diff, validated on-guest)
- baseline: depth 192 β Fatal double fault (guest dead, needs hard reset).
fix.diffapplied inside guest /usr/src,make -j8 nativekernel KERNCONF=X86_64_GENERIC(319s), installkernel, reboot (#1 Fri Oct 9 22:16).- patched kernel: depth 192 and 2048 β
ioctl returned errno=12(ENOMEM β externalize refuses, copyout fails gracefully), guest stays up,uptimehealthy (fixtest.console.txt). - non-regression: depth 32 / 60 β
errno=0, the dict is still externalized and echoed back.
Root-cause line refs: prop_object.c:617-659 (iterative internalize that accepts any depth) vs prop_array.c:174-186 / prop_dictionary.c:455-467 (recursive externalize); echo path device-mapper.c:267-273; 16KB kstack sys/cpu/x86_64/include/param.h:126 + sys/sys/thread.h:472.
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, kernel rebuilt (nativekernel, 319s) and booted: baseline depth-192 Fatal double fault is GONE; depths 192 and 2048 return ENOMEM with guest healthy; depths 32/60 still externalize and echo (errno=0). Guest reset to clean-source snapshot afterwards.
fixtest.console.txt (FIXTEST_DEPTH=64/192/2048 all FIXTEST_done rc=0), patched-kernel ioctl outputs (errno=12 for 192/2048, errno=0 for 32/60), build.kernel.log, fix.diff
Confirmed kernel references
Detail
Exploit chain
operator/root opens /dev/mapper/control -> ioctl(NETBSD_DM_IOCTL, plistref{xml with depth-N nested arrays}) -> prop_dictionary_copyin_ioctl internalizes iteratively (any depth OK) -> dmioctl unconditionally copyout_ioctl(dm_dict_in) -> prop_dictionary_externalize recurses pot_extern per nesting level -> kernel stack (16KB) exhausted at ~192 levels -> frames write below kstack into adjacent kmem then fault -> Fatal double fault -> panic. Guest evidence: two independent panics captured; depth ladder attributes the cliff between 128 and 192; userland same-source differential (internalize OK @20000 / externalize SIGSEGV) proves the code asymmetry.
Evidence (decisive lines)
panic.txt (first Fatal double fault, rsp=0xfffff8011891d000); panic.2.txt (attributed: DF5675_DEPTH=64/128 SURVIVED, =192 -> DOUBLE FAULT, rsp=0xfffff80117e6b000); fixtest.console.txt (patched kernel: depths 64/192/2048 all rc=0, ENOMEM for deep); run.userland.log (same-source differential: internalize OK depth 20000, externalize SIGSEGV exit 139); build.kernel.log (kernel rebuild for fix).
PoC changes
Trigger written fresh (no seed): plistref mirror + _IOWR(0xfd,0,16) ioctl; ladder mode prints each depth to the serial console BEFORE the ioctl so the killing depth is attributable after ssh dies; single-depth mode for fix validation.
Verified recommended fix
Cap externalize nesting depth: refuse to externalize containers nested deeper than _PROP_EXTERNALIZE_MAX_DEPTH (64) so the recursive walk can never exhaust the kernel stack.
Verdict
Kernel thread-stack exhaustion reproduced on the guest: a 192-level nested
No comments yet.