DragonFlyBSD Kernel Audit
← triage · dashboard
DF-2446

dm_message_ioctl frees and dereferences an uninitialized stack msg pointer

Summary

dm_message_ioctl declares char *msg with no initializer and calls prop_dictionary_get_cstring without checking return. proplib leaves *cpp unwritten when key missing or wrong-typed. If attacker omits message key msg stays random stack garbage passed to table_en->target->message(table_en msg) and then unconditionally kfree(msg M_TEMP) - kernel arbitrary-free/NULL-adjacent-deref of attacker-influenced pointer. Also sector uninitialized when DM_MESSAGE_SECTOR absent used in sector==0 extent-match branch. Unguarded kfree executes on every path after successful dm_dev_lookup so any existing dm device with active table triggers it. Attacker: operator group with /dev/mapper/control. Reliable kernel panic with heap grooming controlled double-free/arbitrary free.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2446 · 14 files
FileTypeDescriptionSize
dm_uninit_msg.c trigger-source create dm device + message ioctl omitting 'message' key -> kfree of uninitialized stack ptr 4.6 KB view raw
build.sh build-script cc -o dm_uninit_msg dm_uninit_msg.c -lprop 176 B view raw
run.sh run-script kldload dm; ./dm_uninit_msg 274 B view raw
run.log run-log baseline (unpatched) run + panic signature 1.6 KB view raw
fix_run.log run-log patched dm.ko run -> clean EINVAL, guest up 1.3 KB view raw
panic.txt panic-signature fatal trap 12 page fault at _kfree+0x45, fault addr 0x2e2e7a4a7054 425 B view raw
boot_unpatched.log serial-log full serial console of the panicking run 13.8 KB view raw
boot_patched.log serial-log serial console of the patched run (no panic) 122 B view raw
fix.diff suggested-fix init msg=NULL, check prop_dictionary_get_cstring return, guard kfree 812 B view raw
fix_build.log build-log full nativekernel build log (proves fix compiles into kernel + dm.ko) 5.6 MB ↓ download
dm_module_build.log build-log standalone dm module rebuild log (dm.ko used for validation) 220 B view raw
env.txt environment uname, cc version, kern.version, dm.ko sha256 547 B view raw
VERDICT.md verdict full narrative: mechanism, primitive, privilege analysis, fix validation 8.4 KB ↓ raw
README.md readme human-readable summary + reproduce instructions 2.3 KB ↓ raw
README.md readme human-readable summary + reproduce instructions
↓ download raw

DF-2446 — dm_message_ioctl uninitialized msg free/deref

Summary

dm_message_ioctl() (sys/dev/disk/dm/dm_ioctl.c) declares char *msg without an initializer and calls prop_dictionary_get_cstring(...,&msg) without checking the return. When the "message" key is missing from the ioctl dictionary, proplib leaves *cpp unwritten, so msg holds stack residue. The function then calls kfree(msg, M_TEMP) on that residue → kernel page-fault / panic (or, with stack grooming, an arbitrary-free/UAF primitive).

Privilege

Root/operator-only. /dev/mapper/control is 0640 root:operator (device-mapper.c:181), the dm module is demand-loaded via root-only kldload, and there is no setuid helper or devfs relaxation. Verified: unprivileged maxx gets Permission denied. This is a root→kernel robustness/hardening gap (local DoS + potential heap corruption), not an unprivileged→root escalation.

Reproduce

./build.sh && ./run.sh      # run.sh does: kldload dm; ./dm_uninit_msg
  • Build: cc -o dm_uninit_msg dm_uninit_msg.c -lprop
  • Run as root (must be root or operator-group to open the control dev).
  • Expected on the BUGGY (unpatched) kernel: kernel panic — Fatal trap 12: page fault while in kernel mode, Stopped at _kfree+0x45: movl 0x54(%rax),%r13d, guest wedged in DDB.
  • Expected on the FIXED kernel: the message ioctl returns EINVAL (22), the PoC prints message ioctl returned rv=22, exits 0, and the guest stays up.

How the PoC works

  1. Opens /dev/mapper/control.
  2. Sends NETBSD_DM_IOCTL with command="create", name="df2446dev" — creates a dm device so dm_dev_lookup() succeeds inside dm_message_ioctl.
  3. Sends NETBSD_DM_IOCTL with command="message", name="df2446dev", and omits the "message" key. prop_dictionary_get_cstring returns false without writing &msg, so msg stays uninitialized; the unconditional kfree(msg, M_TEMP) frees stack garbage.

Fix

See fix.diff: initialize char *msg = NULL;, check the prop_dictionary_get_cstring return (return EINVAL on failure after unbusying the device), and guard the cleanup kfree with if (msg != NULL). Validated by rebuilding the dm module and re-running the same PoC — panic → clean EINVAL.

VERDICT.md verdict full narrative: mechanism, primitive, privilege analysis, fix validation
↓ download raw

DF-2446 — dm_message_ioctl uninitialized msg free/deref

Verdict

REPRODUCED (panic / local DoS) + FIX VALIDATED. The bug is real and deterministically crashes the kernel. The escalation to uid=0 is blocked by a valid hard blocker: the vulnerable ioctl path is reachable only from an already-root context (kldload + a 0640 root:operator device node), so there is no privilege boundary to cross — this is a root→kernel robustness/hardening gap, not an unprivileged→root escalation. The authored fix.diff is built, installed as dm.ko, and confirmed to close the bug (panic → clean EINVAL).

Mechanism (trigger → primitive → effect)

dm_message_ioctl() in sys/dev/disk/dm/dm_ioctl.c has an uninitialized auto pointer that is freed unconditionally:

 997: int
 998: dm_message_ioctl(prop_dictionary_t dm_dict)
 999: {
1000:     ...
1006:     char *msg;                                    <-- UNINITIALIZED
1007:     int ret, found = 0;
       ...
1022:     if ((dmv = dm_dev_lookup(name, uuid, minor)) == NULL) {
1023:         dm_remove_flag(dm_dict, &flags, DM_EXISTS_FLAG);
1024:         return ENOENT;                             <-- must pass: device must exist
1025:     }
1026:
1027:     /* Get message string */
1028:     prop_dictionary_get_cstring(dm_dict, DM_MESSAGE_STR, &msg);
                                                         <-- RETURN VALUE NOT CHECKED
       ...
1058:     kfree(msg, M_TEMP);                            <-- frees stack garbage
1059:     dm_dev_unbusy(dmv);

prop_dictionary_get_cstring (sys/libprop/prop_dictionary_util.c:185) is documented and implemented to leave *cpp unwritten when the key is missing or the value is not a string:

185: prop_dictionary_get_cstring(prop_dictionary_t dict, const char *key, char **cpp)
192:     if (prop_object_type(str) != PROP_TYPE_STRING)
193:         return (false);                 /* does NOT write *cpp */

So if the ioctl dictionary carries command="message" plus a valid device name (so dm_dev_lookup succeeds) but omits the "message" key, msg keeps whatever stack residue the frame holds, and kfree(msg, M_TEMP) is called on that residue.

Confirmed effect (unpatched #0 kernel)

Fatal user address access from kernel mode from dm_uninit_msg at ffffffff80657ec5
Fatal trap 12: page fault while in kernel mode
fault virtual address     = 0x2e2e7a4a7054
fault code                = supervisor read data, page not present
instruction pointer       = 0x8:0xffffffff80657ec5
current process           = 987
Stopped at      _kfree+0x45:    movl    0x54(%rax),%r13d
db>

msg happened to hold 0x2e2e7a4a7000 (stack residue; the 0x2e bytes are ASCII . from prior proplib XML buffers). _kfree dereferences the chunk header at rax+0x54 to read slab metadata → page fault on an unmapped address → fatal trap 12 → kernel panic, guest wedged in DDB.

Primitive characterization

  • Class: free of an uninitialized (stack-residue) pointer.
  • Attacker control of the freed address: indirect. The pointer is stack residue, not directly settable via the ioctl arguments, but it can be influenced by prior stack frames in the same syscall path (proplib externalize/internalize buffers, prior ioctl dispatch frames). With stack grooming (a controlled prior call sequence) the residue could be steered toward a valid slab address, turning this into an arbitrary-free / UAF.
  • Observed outcome: deterministic kernel panic (DoS). On this guest the residue was unmapped, so it manifested as a page fault rather than silent heap corruption.

Why no uid=0 chain (valid hard blocker)

Per the Phase-6 hard-blocker rules, the escalation chain is blocked because the vulnerable write is reachable only from an already-root context:

  1. Module load: the dm driver is a KLD module (DECLARE_MODULE(dm, …) in device-mapper.c:97); it is not built into the GENERIC kernel and is not auto-loaded. Reaching the ioctl requires kldload dm, which is a root-only operation (priv_check on PRIV_KLD_LOAD).
  2. Device node permission: the control device is created as make_dev(&dmctl_ops, 0, UID_ROOT, GID_OPERATOR, 0640, "mapper/control") (device-mapper.c:181) — i.e. crw-r----- root operator.
  3. Unprivileged user cannot open it: verified on the guest — maxx (uid 1001, gid 1001, not in operator or wheel) gets open /dev/mapper/control: Permission denied. There is no devfs rule in /etc/devfs.conf or /etc/defaults/devfs.conf that relaxes this, and dmsetup/lvm are not setuid (-r-xr-xr-x root wheel).

Root→kernel is game-over by definition (root can already set uid=0), so there is no privilege boundary for this bug to cross. Realistic impact ceiling: a root operator (or any operator-group member) can deterministically panic/crash the kernel (local DoS), and — with stack grooming — potentially corrupt the kernel heap. This is a defense-in-depth / robustness fix worth making, not a privilege-escalation finding.

PoC

dm_uninit_msg.c — uses libprop to issue two NETBSD_DM_IOCTL ioctls: 1. command="create", name="df2446dev" → creates a dm device so dm_dev_lookup succeeds. 2. command="message", name="df2446dev", "message" key omitted → triggers the uninitialized kfree.

Build: cc -o dm_uninit_msg dm_uninit_msg.c -lprop Run (as root): kldload dm && ./dm_uninit_msg

Fix (fix.diff)

Minimal, targeted at the root cause:

  1. char *msg = NULL; — deterministic initialization.
  2. Check the prop_dictionary_get_cstring return; on failure (key missing / wrong type) dm_dev_unbusy(dmv); return EINVAL; — never reach kfree with an uninitialized pointer.
  3. Defensive if (msg != NULL) kfree(msg, M_TEMP); at the cleanup site (now always-true, but guards future regressions).

git apply --check passes against the read-only sys/ tree. Applied to in-guest /usr/src, the dm module was rebuilt and installed as /boot/kernel/dm.ko (sha256 358587fc…); the same PoC that panicked the unpatched kernel now returns EINVAL (22) and the guest stays up.

Fix validation (Phase 8)

step result
baseline #0 kernel panic at _kfree+0x45, fault 0x2e2e7a4a7054, guest DDB
apply fix.diff to /usr/src 3 hunks applied cleanly
rebuild dm module make in sys/dev/disk/dmdm.ko OK, no errors
install dm.ko /boot/kernel/dm.ko replaced (kernel image left at #0)
re-run PoC (×2) rv=22 (EINVAL), kernel survives, guest up, no panic

(kernel image left at the working #0 baseline because dm is a purely loadable module — the fix lives entirely in dm.ko, not in the kernel proper. The first nativekernel rebuild also succeeded (NK_DONE rc=0) and produced an equivalent patched dm.ko; the standalone module build was used for the final validation to avoid an unnecessary kernel-image swap.)

Files

file purpose
dm_uninit_msg.c trigger PoC (create device + message ioctl w/o message key)
build.sh cc -o dm_uninit_msg dm_uninit_msg.c -lprop
run.sh kldload dm && ./dm_uninit_msg
run.log baseline (unpatched) run + panic signature
fix_run.log patched dm.ko run → clean EINVAL, guest up
panic.txt fatal-trap 12 signature from boot.log
boot_unpatched.log full serial log of the panicking run
boot_patched.log serial log of the patched run (no panic)
fix.diff git-apply-able fix (init msg, check return, guard kfree)
fix_build.log full nativekernel build log (proves fix compiles)
dm_module_build.log standalone dm module rebuild log
env.txt guest uname / cc / kern.version / dm.ko hash
manifest.json machine-readable artifact catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: PoC on unpatched dm.ko baseline panics deterministically (Stopped at _kfree+0x45, guest DDB). After fix.diff + rebuilt dm.ko, SAME PoC returns rv=22 EINVAL, exits 0, guest up, no panic over two runs. Uninitialized-pointer kfree gone; bug closed.

baseline (dm.ko b9084b66): Fatal trap 12 page fault, Stopped at _kfree+0x45: movl 0x54(%rax),%r13d, guest DDB, vm down. patched (dm.ko 358587fc): message ioctl returned rv=22 (EINVAL), RUN_EXIT=0, vm up, boot.log clean.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: kernel image left at #0; fix lives in rebuilt dm.ko installed to /boot/kernel/dm.ko (sha256 358587fc...); nativekernel rebuild also succeeded (NK_DONE rc=0).

Confirmed kernel references

Detail

Exploit chain

BLOCKED by a valid Phase-6 hard blocker: root-only reachability. dm is a demand-loaded KLD (not in GENERIC; needs kldload dm = PRIV_KLD_LOAD root-only); control device is make_dev(UID_ROOT, GID_OPERATOR, 0640) — maxx gets open EACCES; no setuid dmsetup/lvm. Root->kernel is game-over by definition so no privilege boundary to cross. No uid=0 chain possible. No exploit.c written because escalation is structurally impossible.

Evidence (decisive lines)

baseline (unpatched #0): Fatal trap 12 page fault, fault va=0x2e2e7a4a7054, current process=987, Stopped at _kfree+0x45: movl 0x54(%rax),%r13d, db>. patched dm.ko: message ioctl returned rv=22 (Invalid argument), RUN_EXIT=0, guest up, no panic in boot.log.

PoC changes

Authored PoC from scratch (dir empty). dm_uninit_msg.c uses libprop: (1) command=create name=df2446dev so dm_dev_lookup succeeds, then (2) command=message with 'message' key omitted so prop_dictionary_get_cstring leaves msg uninitialized and kfree(msg) frees stack garbage. fix.diff initializes msg=NULL, checks get_cstring return, guards kfree with msg!=NULL.

Verified recommended fix

In sys/dev/disk/dm/dm_ioctl.c dm_message_ioctl: (1) initialize char *msg = NULL; (2) check prop_dictionary_get_cstring return and return EINVAL on failure before kfree; (3) defensively guard if (msg != NULL) kfree(msg, M_TEMP). Full git-apply-able diff in findings/poc/DF-2446/fix.diff (git apply --check passes).

Verdict

REPRODUCED. dm_message_ioctl (sys/dev/disk/dm/dm_ioctl.c:998) declares char *msg; uninitialized, calls prop_dictionary_get_cstring(dm_dict, DM_MESSAGE_STR, &msg) at line 1028 without checking the return, then calls kfree(msg, M_TEMP) at line 1058. proplib leaves *cpp UNWRITTEN when the key is missing or wrong-typed, so msg keeps stack residue. Confirmed on the unpatched #0 kernel: a NETBSD_DM_IOCTL with command="message" and the "message" key omitted panics deterministically — Fatal trap 12: page fault, fault virtual address = 0x2e2e7a4a7054 (stack-garbage msg), Stopped at _kfree+0x45: movl 0x54(%rax),%r13d, guest wedged in DDB. Free-of-uninitialized-pointer primitive: deterministic local DoS, and a heap-corruption/UAF primitive if stack residue is groomed onto a valid slab address.