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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5675 Β· 14 files
FileTypeDescriptionSize
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

VERDICT.md
↓ download raw

DF-5675 β€” VERDICT

reproduced / impact=panic / confidence=certain

What was proven, and how

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

  2. 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 unconditionally prop_dictionary_copyout_ioctl(pref, cmd, dm_dict_in) β€” the externalize walk runs over the full attacker-controlled object graph. /dev/mapper/control is 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).

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

  4. 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.diff applied 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, uptime healthy (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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-5675

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 plist (2.9KB XML) sent through NETBSD_DM_IOCTL on /dev/mapper/control (0640 root:operator) kills the kernel with 'Fatal double fault' at a page-aligned rsp (0xfffff80117e6b000) β€” the recursion in _prop_array_externalize/_prop_dictionary_externalize (64B/frame, objdump-verified in /boot/kernel/kernel) runs the 16KB kstack off its end, while the iterative internalize path accepts any depth. Depths 64/128 survive, 192+ panics; depth 2048 also panics. fix.diff (externalize depth cap 64) validated by full kernel rebuild+reboot on the guest: depths 192 and 2048 now return clean ENOMEM with the guest healthy, depths 32/60 still externalize and echo back (non-regressed).