# 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:

```c
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/dm` → `dm.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                          |
