# DF-4907 — dm_target_delay async-drain use-after-free (verified)

## Finding
`_destroy()` in `sys/dev/disk/dm/delay/dm_target_delay.c:341` drains the delay
queue with `_submit_queue(di, 1)`, which **asynchronously** submits every queued
bio to the underlying pdev via `vn_strategy()` (`_submit`, :267) and returns
immediately. Nothing in the target (or in the dm core teardown that follows)
waits for those bios to complete, yet their completion path dereferences memory
owned by the dm device that is about to be freed:

* the bios are nestbufs created by `dmstrategy()`
  (`sys/dev/disk/dm/device-mapper.c:425`) whose
  `bio_caller_info2.ptr = &dmv->stats` was captured at strategy time;
* when the pdev driver finally completes them,
  `nestiobuf_iodone()` → `nestiobuf_done(..., stats)`
  (`sys/kern/vfs_bio.c:4505`) → `devstat_end_transaction()`
  (`sys/kern/subr_devstat.c:179`) **writes** `last_comp_time`,
  `busy_count--`, `bytes_read += n`, `num_reads++` through that stale pointer;
* by then `dm_table_destroy` → `dm_target_delay_destroy` has returned and
  `dm_dev_destroy` → `dm_dev_free` (`sys/dev/disk/dm/dm_dev.c:384`) has
  **kfree()d the dm_dev** that embeds that devstat.

For the linear target the same window exists but is only as wide as one disk
latency; the delay target makes it *hostile-friendly*: it (a) holds bios long
past strategy() return and (b) dumps the whole queue at destroy time, so the
freed-memory window can be widened to **seconds** by stacking delay targets
(the PoC stacks `d2(delay) ← d1(delay, removed) ← d0(delay) ← vn0`, giving a
~1 s gap between `dm_dev_free(dmv1)` and the completion of d1's orphaned bios).

## Reproduction (baseline, stock module) — REPRODUCED 2/2 from fresh boots
Guest: DragonFly 6.5-DEVELOPMENT (#0 Jul 2 2026) X86_64_GENERIC, QEMU/KVM.
Per-iteration (see df4907.c for the full timeline):
1. `d0 = delay(/dev/vn0, 1000ms)`, `d1 = delay(/dev/mapper/d0, 1000ms)`,
   `d2 = delay(/dev/mapper/d1, 1000ms)`.
2. 48 reader threads `pread()` /dev/mapper/d2 → bios queue in d2 (1 s).
3. t0+1.0 they hop into **d1's** queue (nestbufs with `stats=&dmv1->stats`).
4. t0+1.2 `dmsetup suspend d2; reload d2 (table on vn0); resume d2` —
   resume destroys d2's OLD table → delay `_destroy` → `dm_pdev_decr` →
   `vn_close(d1)` → `dmclose(d1)` → `d1.is_open = 0` (release from pdev-hold;
   d2's own fds stay held by the blocked readers, so d2 cannot be removed but
   d1 can).
5. `dmsetup remove d1` → `_submit_queue(di,1)` submits the queued bios into
   **d0's** 1 s queue and returns; teardown frees dmv1 (~µs later).
6. Spray `dmsetup create f0..f7` → the freed dm_dev chunk is immediately
   reallocated as live device **f0**.
7. t0+2.2 d0's timer fires, the orphaned bios complete on vn0, and
   `devstat_end_transaction` writes into the **live f0 device's** devstat.

Observed on the serial console (identical both runs):
```
devstat_end_transaction: HELP!! busy_count for f00 is < 0 (-1)!
panic: bio_track_rel: bad count: 0xfffff8008da31610
Trace beginning at frame ...
biodone() at biodone+0x17b
biodone() at biodone+0x17b
vnstrategy() at vnstrategy+0x125
dev_dstrategy() at dev_dstrategy+0xf5
diskstrategy() at diskstrategy+0x3b
dev_dstrategy_chain() at dev_dstrategy_chain+0x8b
Debugger("panic")
```
`HELP!! busy_count for f00` is the smoking gun: the late completion
decremented the devstat of **f0 — a different, live device** that happened to
realloc the freed chunk — i.e. a demonstrated cross-object write-after-free
into a reallocated kernel heap object, followed by secondary corruption and a
`bio_track_rel` panic in biodone.

Files: panic.txt (run 1), panic.2.baseline.txt (run 2, fresh boot),
run.log (ssh capture; connection died when the box panicked).

## Fix validation — FIXED
`fix.diff` changes the `submit_all` path of `_submit_queue` to fail the queued
bios (`b_error = ENXIO; b_resid = b_bcount; biodone(&bp->b_bio1)`) instead of
asynchronously submitting them past the target's lifetime. Built
`sys/dev/disk/dm/delay/dm_target_delay.c` as a module in the guest
(`make obj; make` in /usr/src/sys/dev/disk/dm/delay), loaded the patched
`dm_target_delay_fixed.ko`, and re-ran the exact PoC:
* 2 iterations x 48 readers: `RC=0 HELP_before=0 HELP_after=0`, guest up.
* 5 iterations x 96 readers (stress): clean, guest up, `dmesg` free of HELP.
Baseline stock module: panic on both runs. Patched: no panic, no HELP, no
leak of the corruption signature — bad behavior gone. (fix_build.log /
fix_run.log hold the untrimmed logs.)

Note on semantics: erroring queued I/O during target teardown also affects the
table-reload case (resume destroying an old table with queued bios); those
reads now see ENXIO instead of being submitted. That matches the
"device/table disappearing under I/O" contract userspace already has to
tolerate and is the safe default until the dm core gains per-bio lifetime
refcounting (the deeper fix, out of scope for this file).

## Threat model
Reaching this requires the device-mapper control device (/dev/mapper/control,
root-only), i.e. local root (or a jail/namespace given it). It is therefore
not an unpriv→root escalator by itself, but it turns an ordinary
create/destroy admin sequence into deterministic kernel heap corruption with
an attacker-chosen reallocation victim and repeated semi-controlled writes
(`bytes_read += b_bcount`, `num_reads++`, timestamps) — usable for
kernel-state corruption / DoS in the same class as the other verified dm
teardown bugs this cycle (DF-4900 family).

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