# DF-2436 — VERDICT

## Verdict: REPRODUCED (panic) + FIX VALIDATED (fixed)

**Bug:** Use of uninitialized heap memory (CWE-908) in `dm_target_crypt_destroy()`
on partial-init failure of `dm_target_crypt_init()`.

**Status:** REPRODUCED as a kernel panic on the unpatched `#0` GENERIC kernel
(INVARIANTS ON). Fix authored (`M_ZERO` on the `priv` allocation) and VALIDATED
by building a single-fix `dm_target_crypt.ko` module, installing it, and re-running
the SAME PoC — the panic is gone; the init-error path returns `ENOTSUP` cleanly
and the guest stays up.

**Impact:** `panic` (local DoS / hardening gap). **NOT `uid0`** — see the
privilege analysis below (root/operator-only trigger is a valid hard blocker).

---

## Mechanism (trigger → primitive → effect), path:line at each hop

`dm_target_crypt_init()` in `sys/dev/disk/dm/crypt/dm_target_crypt.c`:

1. **Line 489** — `priv = kmalloc(sizeof(dm_target_crypt_config_t), M_DMCRYPT, M_WAITOK);`
   allocates the crypt config **WITHOUT `M_ZERO`**. The struct contains raw heap
   residue in: `status_str`, `ivgen`, `ivgen_priv`, `crypto_session`,
   `read_mpipe`, `write_mpipe` (struct fields at `dm_target_crypt.c:80-99`).

2. **Line 513** — `dm_table_init_target(table_en, priv);` (which sets
   `table_en->target_config = cfg` at `dm_table.c:266-268`) **publishes the
   garbage-filled `priv`** to the table entry.

3. **Lines 521 / 533 / 543 / 552 / 564** — five `goto notsup` error paths that
   are reachable **AFTER** `priv` is published (513) but **BEFORE** the fields
   are initialized:
   - `priv->ivgen` is set at 547
   - `priv->crypto_session` is set at 549
   - `priv->status_str` is set at 577
   - `dmtc_init_mpipe(priv)` (initializes `read_mpipe`/`write_mpipe`) is at 580

4. **Lines 584-587 (the `notsup` block)** only frees the local `status_str` and
   `return ENOTSUP;` — it does **NOT** zero `priv`'s fields, does **NOT** NULL
   `table_en->target_config`, and does **NOT** free `priv`.

5. The caller `dm_table_load_ioctl()` at `sys/dev/disk/dm/dm_ioctl.c:783-785`:
   ```c
   if ((ret = dm_table_init(target, table_en, str)) != 0) {
       dm_table_release(&dmv->table_head, DM_TABLE_INACTIVE);
       dm_table_destroy(&dmv->table_head, DM_TABLE_INACTIVE);   // <-- destroy
   ```
   `dm_table_destroy` (`dm_table.c:147-152`) iterates the table entries and calls
   `table_en->target->destroy(table_en)`.

6. **`dm_target_crypt_destroy()` at `dm_target_crypt.c:607`** reads the
   uninitialized fields:
   - **Line 620** `dmtc_destroy_mpipe(priv);` → `mpipe_done(&priv->read_mpipe)`
     (`dm_target_crypt.c:205`) → **`kern_mpipe.c:133` `lwkt_gettoken(&mpipe->token)`**
     on the **uninitialized garbage `lwkt_token`** → assertion
     `count & TOK_COUNTMASK` fails → **panic at `lwkt_token.c:458`**.
   - (If that had survived) line 628 `strlen(priv->status_str)` on a garbage
     pointer, line 633 `priv->ivgen->dtor` on a garbage function pointer, line 637
     `cryptoapi_cipher_freesession(priv->crypto_session)` on a garbage pointer.

### Trigger chosen

Invalid `iv_mode` → the `goto notsup` at **line 533**. Params:
`aes-xts-bogusiv <64-hex-key> 0 /dev/md0 0`:
- `crypto_alg="aes"`, `crypto_mode="xts"` → `dmtc_find_crypto_cipher("aes","xts",256)`
  returns non-NULL (`dm_target_crypt.c:425-426`) ✓
- `dm_pdev_insert("/dev/md0")` succeeds (md0 present) ✓
- `hex2key()` succeeds (valid hex) ✓ — so we pass the publish at 513
- `iv_mode="bogusiv"` is not in `ivgens[]` (`dm_target_crypt.c:150-156`, valid:
  `essiv`/`plain`/`plain64`) → loop falls through → `goto notsup` at 533

At line 533: `ivgen`, `ivgen_priv`, `crypto_session`, `status_str`,
`read_mpipe`, `write_mpipe` are ALL still raw heap residue. Destroy reads them → panic.

### Panic signature (from `dfbsd-qemu/boot.log`, full text in `panic.txt`)
```
dm_target_crypt: iv_mode='bogusiv' unsupported
dm_target_crypt: ENOTSUP
panic: assertion "count & TOK_COUNTMASK" failed in _lwkt_reltokref at /usr/src/sys/kern/lwkt_token.c:458
lwkt_relalltokens() at lwkt_relalltokens+0x80
lwkt_gettoken() at lwkt_gettoken+0x299
mpipe_done() at mpipe_done+0x37                       <-- uninitialized mpipe->token
dm_target_crypt_destroy() at dm_target_crypt_destroy+0x29
Debugger("panic")
Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip)
db>
```
The trace nails the bug to `dm_target_crypt_destroy → mpipe_done → lwkt_gettoken`
on the uninitialized `lwkt_token` inside `priv->read_mpipe`.

---

## Privilege analysis — why this is NOT `uid0` (valid hard blocker)

| Gate | Status | Citation |
|------|--------|----------|
| `/dev/mapper/control` perms | `0640 root:operator` | `device-mapper.c:181`; `ls -l` in `env.txt` |
| `kldload dm` | root only | standard DragonFly kld load privilege |
| unprivileged `maxx` (uid 1001) | **not** in `wheel`/`operator` | `id` in `env.txt` |

The whole dm ioctl surface (create/reload/table) is reachable **only** from
root or the `operator` group. Verified: an unprivileged `maxx` cannot open the
control dev (`Permission denied`). There is **no unprivileged path** to this
bug. Per the bright-line rule, root→kernel is game-over by definition, so a
`uid0` escalation claim is **invalid** here — this is a **root/operator local
DoS (panic) and a hardening gap** (defense-in-depth), not an unpriv→root
escalation. This is a VALID hard blocker for the `uid0` chain; the bug's honest
impact is `panic`.

(The uninitialized fields would, on a non-INVARIANTS build with slab grooming,
constitute a kernel function-pointer-hijack primitive — e.g. forging
`priv->ivgen` to point at a fake `iv_generator` whose `dtor` is an
attacker-chosen pointer — and SMEP/SMAP are OFF on this guest, so such a
pointer could jump to userspace shellcode. But that primitive is reachable only
from root/operator, so it does not cross a privilege boundary.)

---

## Exploit chain

`none` (memory-corruption class, but root/operator-only trigger = valid hard
blocker for `uid0`). The primitive is characterized above (uninitialized heap
read/use of 6 fields, culminating in a function-pointer-shaped field
`priv->ivgen->dtor`); the realistic impact ceiling is a **root/operator local
DoS (panic)** and, with slab grooming on a noinv build, a kernel code-exec
primitive from an already-privileged credential. No unprivileged escalation is
possible because the ioctl surface is gated at `0640 root:operator`.

---

## Fix

`fix.diff` — a one-line, root-cause fix: add `M_ZERO` to the `priv` allocation
at `dm_target_crypt.c:489`:

```diff
-	priv = kmalloc(sizeof(dm_target_crypt_config_t), M_DMCRYPT, M_WAITOK);
+	priv = kmalloc(sizeof(dm_target_crypt_config_t), M_DMCRYPT,
+	    M_WAITOK | M_ZERO);
```

With `M_ZERO`, all fields start NULL/0, so `dm_target_crypt_destroy()` becomes a
safe no-op on any partial-init error path:
- `mpipe_done()` on a zero'd `malloc_pipe` is explicitly safe — the comment at
  `kern_mpipe.c:117-121` guarantees it ("This routine can also safely be called
  on an uninitialized mpipe structure if it was zero'd ..."). `free_count ==
  total_count == 0` passes the KKASSERT; `thread==NULL`/`array==NULL` skip the
  cleanup loops.
- `priv->status_str == NULL` → the `if (priv->status_str)` guard at 628 skips.
- `priv->ivgen == NULL` → the `if ((priv->ivgen) && ...)` guard at 633 skips.
- `priv->crypto_session == NULL` → `cryptoapi_cipher_freesession(NULL)` returns
  early (`cryptoapi.c:1093-1094`).

This is minimal and targeted at the root cause; it is preferable to the
alternatives (moving the publish to after all init, or adding NULL-inits in the
notsup block) because it covers ALL error paths uniformly with a single change.

---

## Fix validation (Phase 8)

**Before (unpatched `#0` kernel, original `dm_target_crypt.ko`):**
Same PoC → panic `assertion "count & TOK_COUNTMASK" failed ... mpipe_done ...
dm_target_crypt_destroy`, guest **down**, ssh dies. (`run.log` + `panic.txt`.)

**After (single-fix `dm_target_crypt.ko` module rebuilt from patched source,
kernel still `#0`):**
Same PoC → `reload returned rv=45 (Operation not supported)`, `RUN_EXIT=0`,
guest **stays up**. Confirmed deterministic over 3 runs (`fix_run.log`).
dmesg shows the clean `iv_mode='bogusiv' unsupported` + `ENOTSUP` sequence with
**no panic** (`dmesg.txt`).

The fix module is a standalone `dm_target_crypt.ko` built from the patched
`/usr/src/sys/dev/disk/dm/crypt/dm_target_crypt.c` via `make` in that directory
(single `.c` file, ~10 s build). The base kernel was not rebuilt because
`dm_target_crypt` is a loadable module (not compiled into `X86_64_GENERIC`);
replacing `/boot/kernel/dm_target_crypt.ko` and reloading is sufficient and
equivalent. sha256 of the module changed
(`bfadc5...` → `766a71...`), confirming the patched code is loaded.

`fix_status: fixed`.

---

## PoC changes

Authored from scratch (the PoC directory did not exist). `dm_crypt_uninit.c`
is a libprop `NETBSD_DM_IOCTL` PoC modeled on the DF-2435 sibling: it
`create`s a dm device, `reload`s a `crypt` table with params
`aes-xts-bogusiv <key> 0 /dev/md0 0` to drive `dm_target_crypt_init` past the
`priv` publish (line 513) into the invalid-`iv_mode` `goto notsup` (line 533),
which leaves `priv` published with uninitialized fields; the caller's error
path then invokes `dm_target_crypt_destroy`, which reads them and panics.
