# DF-2453 — dm_target_flakey UAF of target config in async read io path

## Verdict

**NOT REPRODUCED as a crash — LATENT UAF (code-confirmed).** The bug is
genuinely present in the code: `_flakey_read()` stores the `tfc` pointer
in `bio_caller_info1.ptr` with **no reference count**, and
`_flakey_read_iodone()` later dereferences it. A concurrent
`dm_target_flakey_destroy()` → `kfree(tfc)` while async I/O is in flight
produces a **use-after-free**. However, the race is **not triggerable as a
visible crash** from a userspace PoC on the default GENERIC kernel for two
reasons:

1. **Synchronous I/O model:** `read()` blocks until the biodone chain
   completes. The `_flakey_read_iodone` callback runs *before* `read()`
   returns, so `tfc` is always valid when accessed from a synchronous
   userspace read. The `dm_dev_remove` ioctl checks `is_open` and returns
   EBUSY while any reader has the device open — so the table can't be
   destroyed during a synchronous read.

2. **INVARIANTS poisoning:** On GENERIC (INVARIANTS ON), freed slab memory
   is poisoned with `0xdeadc0de`. Even if the race fires, the poisoned
   values at `corrupt_buf_byte` / `drop_writes` offsets cause both branches
   in `_flakey_read_iodone` to be skipped — the UAF read is **silent**.

The race *could* be triggered by kernel-initiated asynchronous I/O (e.g.,
a filesystem's buffer cache flush on the dm device) concurrent with a
table destroy, but this requires a complex multi-step setup not achievable
from a simple PoC. The fix is still warranted: the code is buggy and the
UAF is a latent defect.

**Status: `not_reproduced`** (no crash observed in 200+ race iterations).
The fix.diff applies, compiles cleanly, and the patched module functions
correctly without regression. Fix validation is `not_testable` (can't
demonstrate the fix closing a crash that doesn't occur).

## Mechanism (code-confirmed UAF)

`_flakey_read()` in `sys/dev/disk/dm/flakey/dm_target_flakey.c`:

```c
 303: nbio = push_bio(bio);
 304: nbio->bio_done = _flakey_read_iodone;
 305: nbio->bio_caller_info1.ptr = tfc;    <-- stores tfc, NO refcount
 306: nbio->bio_offset = pop_bio(nbio)->bio_offset;
 307:
 308: _submit(tfc, nbio);                   <-- submits ASYNC I/O
```

The async read path is entered when `corrupt_buf_byte || drop_writes` is
set (line 297-301 guard). `_submit` sends the bio to the underlying device
via `vn_strategy` — the I/O completes asynchronously.

`_flakey_read_iodone()` (the completion callback):

```c
 267: static void
 268: _flakey_read_iodone(struct bio *bio)
 269: {
 272:     tfc = bio->bio_caller_info1.ptr;   <-- retrieves stored pointer
 279:     if (tfc->corrupt_buf_byte && ...)  <-- UAF READ if tfc freed
 281:     else if (!tfc->drop_writes)        <-- UAF READ
 284:     biodone(obio);
 285: }
```

The `dmstrategy` path in `device-mapper.c`:
```c
 408: tbl = dm_table_get_entry(..., DM_TABLE_ACTIVE);  // shared lock
 458: table_en->target->strategy(table_en, nestbuf);   // async submit
 465: dm_table_release(..., DM_TABLE_ACTIVE);           // release shared lock
```

The shared table lock is released after the async submit. A concurrent
`dm_table_destroy` (exclusive lock) can then proceed and free `tfc`:

```c
 209: dm_target_flakey_destroy(dm_table_entry_t *table_en)
 217:     dm_pdev_decr(tfc->pdev);
 219:     kfree(tfc, M_DMFLAKEY);                       <-- frees tfc
```

If the async I/O completes after `kfree(tfc)`, `_flakey_read_iodone`
dereferences freed memory.

### Why it can't be triggered from userspace

1. `read()` is synchronous: the kernel blocks in `biowait()` until
   `biodone()` fires. `_flakey_read_iodone` runs in the I/O completion
   path, which is *before* `read()` returns. So `tfc` is valid when the
   iodone callback reads it.

2. `dm_dev_remove_ioctl` (dm_ioctl.c:354-359) checks `dmv->is_open`
   and returns EBUSY if any process has the device open. Since `read()`
   requires the device to be open, the table can't be destroyed during
   a read.

3. The PoC ran 200 iterations × 8 readers = 1600 race attempts with
   0 successful teardowns (all returned EBUSY because readers always had
   the device open).

### What WOULD trigger it

Kernel-initiated asynchronous I/O that doesn't hold the device open
(e.g., a filesystem's buffer cache flush initiated from a kernel thread)
could be in flight when a separate ioctl thread destroys the table. This
is a realistic but complex scenario requiring a filesystem mounted on the
flakey dm device.

## Exploit chain

`none` — latent UAF with no write primitive. Even if triggered, the
freed-memory reads in `_flakey_read_iodone` are benign (on GENERIC,
INVARIANTS poisoning prevents data corruption; on noinv, stale values
are benign). No escalation chain possible.

## Fix (`fix.diff`)

Add an atomic refcount to `dm_target_flakey_config_t`. The table entry
holds one reference (initial). Each in-flight async read acquires a
reference in `_flakey_read` and releases it in `_flakey_read_iodone`.
The last reference drop (either the table destroy or the last iodone)
calls `dm_pdev_decr` + `kfree`.

Key changes:
- `tfc_hold(tfc)` in `_flakey_read` before storing in bio
- `tfc_release(tfc)` in `_flakey_read_iodone` after biodone
- `tfc_release(tfc)` in `dm_target_flakey_destroy` (replaces direct free)
- `tfc->ref_cnt = 1` in `dm_target_flakey_init`

## Fix validation (Phase 8)

Since the bug was NOT reproduced as a crash, the fix is validated as
**`not_testable`**:
- `fix.diff` applies cleanly (6 hunks, all succeeded)
- Module compiles cleanly (`MODULE_BUILD_EXIT=0`, no warnings with `-Werror`)
- Patched module loads and functions correctly (200 race iterations, no
  panic, no regression)
- The fix is correct by code inspection: the refcount ensures `tfc` cannot
  be freed while any async read I/O holds a reference

## PoC

`poc.c` — races 8 reader children (continuous open/read/close on the
flakey device) against 200 iterations of create/reload/resume/teardown
to try to hit the UAF window. The PoC confirms the race is not triggerable
from userspace (0 successful teardowns in 200 iterations, all EBUSY).

Build: `cc -O2 -o poc poc.c -lprop`
Run (as root): `./run.sh`
