# DF-2449 — `dm_table_load_ioctl` uninitialized-heap `start`/`length` leak

## Verdict

**REPRODUCED (info leak of uninitialized kernel heap) + FIX VALIDATED.** The
bug is real: `dm_table_load_ioctl` allocates `dm_table_entry_t` with
`kmalloc(M_DM, M_WAITOK)` (no `M_ZERO`) and never checks the return value of
the two `prop_dictionary_get_uint64()` calls that fill `table_en->start` /
`table_en->length`. When the attacker omits the `"start"` / `"length"` keys
from the per-table-entry dictionary, proplib's `prop_dictionary_get_uint64`
returns `false` **without writing `*valp`** (early `return (false)` at
`sys/libprop/prop_dictionary_util.c:126` runs before the `*valp = ...`
assignment at line 136), so the two `uint64_t` fields retain whatever stale
slab bytes `kmalloc` returned. `dm_table_status_ioctl` later copies those
fields verbatim into the response dictionary
(`sys/dev/disk/dm/dm_ioctl.c:937-940`), leaking up to **16 bytes of
uninitialized kernel heap per table entry per status query**.

Privilege boundary: `/dev/mapper/control` is `crw-r----- root operator`
(`device-mapper.c:181`) and the `dm` module must be `kldload`-ed by root, so
this is reachable only by **root or an `operator`-group member**. There is
**no unprivileged path** (maxx uid 1001 is not in operator/wheel and gets
`EACCES` on `open("/dev/mapper/control")`). Root→kernel is game-over by
definition, so this is a **root/operator → kernel info-leak / hardening
gap**, NOT an unpriv→root escalation. There is no write primitive (the bug
surfaces stale bytes via a read-back path), so there is no escalation chain
to develop — this is the valid hard blocker for Phase 6. Realistic impact
ceiling: **16 bytes of uninitialized kernel heap leaked per query** — useful
for slab-layout inference / KASLR-defeat / information disclosure
(CWE-457 / CWE-908), not privilege escalation.

The authored `fix.diff` is built as a single-fix `dm.ko` module, installed,
and confirmed to close the bug (leak of stale bytes → clean `EINVAL`).

## Mechanism (trigger → primitive → effect)

`dm_table_load_ioctl()` in `sys/dev/disk/dm/dm_ioctl.c`:

```
 673: int
 674: dm_table_load_ioctl(prop_dictionary_t dm_dict)
 ...
 743:     if ((table_en = kmalloc(sizeof(dm_table_entry_t),
 744:                 M_DM, M_WAITOK)) == NULL) {            <-- NO M_ZERO
 ...
 750:     prop_dictionary_get_uint64(target_dict, DM_TABLE_START,
 751:                 &table_en->start);                     <-- rv NOT checked
 752:     prop_dictionary_get_uint64(target_dict, DM_TABLE_LENGTH,
 753:                 &table_en->length);                    <-- rv NOT checked
```

Proplib helper (`sys/libprop/prop_dictionary_util.c`, expanded from
`TEMPLATE(64)` macro):

```
  118: bool
  119: prop_dictionary_get_uint64 (prop_dictionary_t dict,
  120:                             const char *key,
  121:                             uint64_t *valp)
  122: {
  123:     prop_number_t num;
  124:
  125:     num = prop_dictionary_get(dict, key);
  126:     if (prop_object_type(num) != PROP_TYPE_NUMBER)
  127:         return (false);                                <-- early return,
  ...                                                        NO *valp write
  136:     *valp = (uint64_t)
  137:         prop_number_unsigned_integer_value(num);
  138:
  139:     return (true);
  140: }
```

So when `"start"` / `"length"` are absent from the dictionary,
`prop_dictionary_get_uint64` returns `false` and never touches `*valp`. The
uninitialized `table_en->start` / `table_en->length` are then leaked back to
userspace by the status ioctl:

```
  869: int
  870: dm_table_status_ioctl(prop_dictionary_t dm_dict)
 ...
  931:     TAILQ_FOREACH(table_en, tbl, next) {
 ...
  937:         prop_dictionary_set_uint64(target_dict, DM_TABLE_START,
  938:                     table_en->start);                  <-- stale bytes
  939:         prop_dictionary_set_uint64(target_dict, DM_TABLE_LENGTH,
  940:                     table_en->length);                 <-- stale bytes
```

### Observed leak signature

The DM-bucket slab is normally fresh-zero on a quiet system (trial 0 returns
0/0 because the underlying page came from the pre-zeroed ZeroPage pool — see
`sys/kern/kern_slaballoc.c:316`). To make the leak visible deterministically
we enable the INVARIANTS-gated `debug.use_malloc_pattern=1` sysctl
(`sys/kern/kern_slaballoc.c:222-229`) which fills every non-`M_ZERO`
`kmalloc` chunk with `-1`. Trial output from the unpatched baseline:

```
[*] running 5 reload cycles, omitting start/length each time...
[trial 0] df2449_0: start=0x0000000000000000 length=0x0000000000000000
[trial 1] df2449_1: start=0xffffffffffffffff length=0xffffffffffffffff
[trial 2] df2449_2: start=0xffffffffffffffff length=0xffffffffffffffff
[trial 3] df2449_3: start=0xffffffffffffffff length=0xffffffffffffffff
[trial 4] df2449_4: start=0xffffffffffffffff length=0xffffffffffffffff

[*] variance over 5 successful reloads: 4/4 differ in start, 4/4 in length
[!!!] UNINITIALIZED-HEAP LEAK CONFIRMED: at least one
      trial returned non-zero start/length despite the
      reload ioctl OMITTING both keys.
[!!!] Values VARY across trials -- definitive
      evidence of uninitialized heap residue
      (a properly-initialized field would be
      constant 0/0 across all trials).
```

Trial 0 = 0/0 (fresh slab page), trials 1-4 = `0xffffffffffffffff`
(the INVARIANTS malloc-pattern `-1` filling). The variance across trials
(0/0 vs `0xff..ff`) is itself definitive proof: a properly-initialized
field would be the same constant across every trial. On a non-debug
kernel, the same path leaks the previous slab tenant's actual data
(slba residue), not the `-1` pattern — useful for slab-layout inference.

### Trigger

A `NETBSD_DM_IOCTL` (`sys/dev/disk/dm/netbsd-dm.h:41`) carrying a libprop
dictionary with:

* `"version"` = `[4, 0, 0]` — passes `dm_check_version()`,
* `"command"` = `"create"` — makes the named dm device,
* `"command"` = `"reload"` — routes through `dm_cmd_to_fun()` in
  `device-mapper.c:286` to `dm_table_load_ioctl` (cmd_fn table line 131),
* per-table-entry dictionary with `"type"` = `"zero"`, `"params"` = `"0"`
  (so `dm_table_init` does not `return EINVAL` from `if (params == NULL)`),
  **`"start"` and `"length"` OMITTED** — the trigger,
* `"command"` = `"table"` with `DM_STATUS_TABLE_FLAG |
  DM_QUERY_INACTIVE_TABLE_FLAG` to read back the stale `start`/`length`
  values.

## Privilege analysis — why this is info-leak, not privesc

1. **Module load:** the `dm` driver is a KLD module; reaching the ioctl
   requires `kldload dm`, which is a root-only operation (`PRIV_KLD_LOAD`).
2. **Device node:** `/dev/mapper/control` is created as
   `make_dev(&dmctl_ops, 0, UID_ROOT, GID_OPERATOR, 0640, "mapper/control")`
   (`device-mapper.c:181`) — `crw-r----- root operator`. The `maxx` user
   (uid 1001, not in `operator`/`wheel`) gets `EACCES` on `open()`.
3. So the bug is reachable only by **root or an `operator`-group member**.

Combined with the fact that the primitive is a **read** of stale slab bytes
(no attacker-controlled write occurs; the bug surfaces whatever the slab
left there via a read-back ioctl), there is **no escalation chain to
develop** — this is the valid hard blocker for Phase 6. Realistic impact
ceiling: info disclosure of 16 bytes of uninitialized kernel heap.

## Exploit chain

`none` — pure uninitialized-heap info leak, no write primitive (valid hard
blocker). No escalation file (`exploit.c`) is produced because there is no
corruption to convert.

## Fix (`fix.diff`)

Minimal, root-cause-targeted, defense-in-depth:

1. **Add `M_ZERO` to the `dm_table_entry_t` `kmalloc`** — guarantees the
   entire struct starts zeroed (so any *future* field whose initialization
   is missed cannot leak stale bytes either).
2. **Check the return value of both `prop_dictionary_get_uint64` calls** —
   if either `"start"` or `"length"` is missing, `kfree` the freshly-
   allocated entry, release the table reference, unbusy the device and
   target, and return `EINVAL`. This rejects malformed input rather than
   silently defaulting.

```c
        if ((table_en = kmalloc(sizeof(dm_table_entry_t),
                    M_DM, M_WAITOK | M_ZERO)) == NULL) {        /* was M_WAITOK */
            ...
        }
        /*
         * Require start/length to be present in the per-table-entry dict.
         * prop_dictionary_get_uint64() returns false without writing *valp
         * when the key is absent, which would otherwise leave the field at
         * whatever stale slab bytes kmalloc returned (the M_DM kmalloc above
         * is not M_ZERO before this change).  M_ZERO on the kmalloc also
         * guarantees the whole struct starts zeroed for any future field
         * whose initialization is missed here.  DF-2449.
         */
        if (!prop_dictionary_get_uint64(target_dict, DM_TABLE_START,
                    &table_en->start) ||
            !prop_dictionary_get_uint64(target_dict, DM_TABLE_LENGTH,
                    &table_en->length)) {
            kfree(table_en, M_DM);
            dm_table_release(&dmv->table_head, DM_TABLE_INACTIVE);
            dm_dev_unbusy(dmv);
            dm_target_unbusy(target);
            return EINVAL;
        }
```

(There is no finding markdown for DF-2449 yet — `findings/DF-2449-*.md`
does not exist in this tree. The `fix.diff` is the authoritative verified
fix; the `recommended_fix` summary below describes it.)

## Fix validation (Phase 8)

1. **Baseline** (`with-src` snapshot, kernel `6.5-DEVELOPMENT #0`,
   unpatched `dm.ko` 0xa2000 bytes, `debug.use_malloc_pattern=1`):
   PoC reproduces — trial 0 = 0/0, trials 1-4 = `0xffffffffffffffff`,
   variance 4/4. Leak confirmed.
2. **Patched**: applied `fix.diff` to `/usr/src/sys/dev/disk/dm/dm_ioctl.c`,
   rebuilt the dm module alone (`make` in `sys/dev/disk/dm`, ~30 s,
   no full kernel rebuild needed — `fix_build.log`), installed
   `dm.ko` (now 0x6000 bytes, sha256
   `1b4397b64a0f5005064aac90853c8e684080c6b7de0c1a77fed01552f0609c0b`)
   → `/boot/kernel/dm.ko`, `kldunload dm; kldload dm`.
3. **Re-run**: same PoC — all 5 reloads now return `EINVAL (22)` ("entry
   rejected"). No inactive table is installed, so no leak path is
   reachable. Repeated 2× — deterministic. (`fix_run.log`, `fix_run.2.log`)
4. **Sanity**: a VALID reload that DOES supply `start` / `length`
   continues to succeed on the patched module (read-back returns exactly
   the supplied values 0 / 12345) — fix does not break legitimate use.
5. Verdict: **fix closes the bug** (16-byte stale-heap leak → clean
   `EINVAL`).

## PoC

`dm_uninit_startlength.c` — libprop `NETBSD_DM_IOCTL`:

1. `command="create"`, `name="df2449_<i>"` for `i` in `0..N_TRIALS-1`.
2. `command="reload"`, per-table-entry dict with `"type"="zero"`,
   `"params"="0"`, **`"start"` / `"length"` OMITTED**.
3. `command="table"` with `DM_STATUS_TABLE_FLAG |
   DM_QUERY_INACTIVE_TABLE_FLAG`, read back `start` / `length`.
4. Variance check across trials: if any two trials differ, the bytes are
   uninitialized slab residue (deterministic init would be constant).
5. On a FIXED kernel, every reload returns `EINVAL`; the PoC reports
   "FIX IS IN EFFECT".

Build: `cc -O2 -o dm_uninit_startlength dm_uninit_startlength.c -lprop`
Run (as root, after `kldload dm`): `./dm_uninit_startlength`
Recommended (to make the leak visible on a quiet slab):
`sysctl -w debug.use_malloc_pattern=1`
