# DF-0974 — xpt_action_sasync_cb UAF race on async_callback deregistration

## Verdict
**NOT REPRODUCED at runtime — race is real as a code pattern but not
reachable from any unprivileged syscall; XPT_SASYNC_CB is a kernel-internal
CCB issued only by in-tree peripheral/HBA drivers during init/attach/detach.
Even the privileged paths (kldload, hot-plug) don't naturally produce the
matching (callback, callback_arg, path) tuple needed to free an entry while
a task is pending.** Defense-in-depth fix authored + compiled.

## Mechanism (the race as described)
`xpt_action(XPT_SASYNC_CB)` at `sys/bus/cam/cam_xpt.c:3324` manipulates the
per-device `async_list` and may enqueue a taskqueue job carrying
`task->data1 = cur_entry`:

```c
/* cam_xpt.c:3376-3387 */
if ((added & (AC_FOUND_DEVICE | AC_PATH_REGISTERED)) != 0) {
    struct xpt_task *task;
    task = kmalloc(sizeof(struct xpt_task), M_CAMXPT, M_INTWAIT);
    TASK_INIT(&task->task, 0, xpt_action_sasync_cb, task);
    task->data1 = cur_entry;            /* captures pointer */
    task->data2 = added;
    taskqueue_enqueue(taskqueue_thread[mycpuid], &task->task);
}
```

The task handler `xpt_action_sasync_cb` (line 2942) later dereferences
`task->data1` (`cur_entry`) and passes it through
`xpt_for_all_devices(xptsetasyncfunc, cur_entry)` (line 2957), which calls
`cur_entry->callback(...)` (line 2906).

The deregister path (line 3353) for an existing entry with
`csa->event_enable == 0` does:

```c
/* cam_xpt.c:3353-3358 */
SLIST_REMOVE(async_head, cur_entry, async_node, links);
atomic_add_int(&csa->ccb_h.path->device->refcount, -1);
kfree(cur_entry, M_CAMXPT);
```

**Race window:** if Call A enqueued a task with `data1=cur_entry` and Call B
(for the same `(callback, callback_arg)` on the same path's async list)
arrives with `event_enable=0` BEFORE the task runs, Call B frees `cur_entry`
while the task still holds a pointer to it. The task then UAFs at line 2906
(`cur_entry->callback(...)`).

## Reachability analysis (the crucial question)

`XPT_SASYNC_CB` is **not exposed via any syscall**. It is a CAM-internal CCB
issued only by `xpt_register_async()` (`sys/bus/cam/cam_xpt.c:7280`), which
is a kernel-internal API. In-tree callers (verified by
`grep -rn 'xpt_register_async' sys/`):

| Caller | Site | Trigger |
|---|---|---|
| `scsi_da`, `scsi_cd`, `scsi_ch`, `scsi_pass`, `scsi_pt`, `scsi_sa`, `scsi_ses`, `scsi_sg` | periph `init`/`register`/`oninvalidate` | module load (kldload, root-only), device attach (root-triggered hot-plug) |
| `scsi_targ_bh` | target-mode init | module load |
| `mps_sas`, `mpr_sas`, `isp`, `sym_hipd` | HBA attach/detach | kldload, device attach/detach |

**Key observation:** for the UAF race to fire, the same `(callback,
callback_arg)` pair must be registered with `event_enable != 0` and then
deregistered (`event_enable == 0`) on the **same path's async list** while
the task is pending. A survey of every caller shows:

1. The periph drivers register their global callback with `(cb, NULL)` at
   module-init time and `(cb, periph)` for each periph instance — these are
   *different* `(callback_arg)` values, so the deregister lookup at line 3340
   (`cur_entry->callback_arg == csa->callback_arg`) will not match.
2. `mpr_sas` calls `xpt_register_async(event, mprsas_async, sc, NULL)` at
   attach and `xpt_register_async(0, mprsas_async, sc, sassc->path)` at
   detach — the paths differ (`NULL` vs `sassc->path`), so the deregister
   lands on a *different* device's async list and does not see the original
   entry.
3. No in-tree caller issues two SASYNC_CB CCBs with the same `(callback,
   callback_arg, path)` tuple, one with non-zero `event_enable` and one
   with zero.

**An unprivileged local user cannot trigger this race.** The CAM passthrough
device (`/dev/passN`, `scsi_pass.c`) only forwards SCSI I/O CCBs, not
`XPT_SASYNC_CB`. On the audit guest:
- `/dev/pass0` is `crw------- root operator` (maxx cannot open it)
- `/dev/xpt0` is `crw------- root operator` (maxx cannot open it)
- `camcontrol devlist` requires operator group membership maxx lacks.

This is a valid hard blocker: the vulnerable code path is **only reachable
from already-root contexts** (kldload / wheel-only device ioctls), so there
is no privilege boundary to cross.

## Exploit chain
**Not applicable / blocked by valid hard blocker (root-only reachability).**
The race is real in the code, but no unprivileged user can issue
`XPT_SASYNC_CB`, and even the root-only paths in the in-tree drivers do not
naturally produce the matching tuple that would free a still-pending task's
`cur_entry`. There is no escalation chain to develop.

## Fix
The minimal defense-in-depth fix (in `fix.diff`) does two things:

1. **Deregister path no longer frees** `cur_entry` — instead NULLs
   `callback` and leaves the allocation in place, so any in-flight task
   pointing at it can safely deref. The entry is still removed from the
   list (so future dispatches skip it) and the device refcount is still
   decremented. Memory is leaked per deregister call (~40 bytes) —
   bounded, and preferable to the UAF.
2. **Task dispatch sites** (`xptsetasyncfunc`, `xptsetasyncbusfunc`)
   check `cur_entry->callback == NULL` before invoking, so a deregistered
   entry's pending task becomes a no-op.

The proper upstream fix would add a refcount to `struct async_node` so
the deregister path can wait for pending tasks to drain before freeing;
that is a more invasive change and is noted as the recommended next step.

## Fix validation
**Compile-only.** The bug is not reachable from any syscall on the default
guest, so booting the patched kernel cannot demonstrate a behavior change
(`fix_status: not_testable`). What was validated:

- `fix.diff` applies cleanly with `patch -p1` (rc=0).
- A single-fix kernel was built: `make -j6 nativekernel
  KERNCONF=X86_64_GENERIC` completed with `cam_xpt.o` built under `-Werror`
  (no warnings, no errors), and the kernel linked successfully
  (`/usr/obj/usr/src/sys/X86_64_GENERIC/kernel.stripped` 15.7MB, today's
  build). See `fix_build.log`.

## PoC changes
The PoC directory was seeded empty. I did not write a runtime trigger
because the bug cannot be exercised from userspace — the only path to
`XPT_SASYNC_CB` is through `xpt_register_async()` which is a kernel API.
Authored `fix.diff` (defense-in-depth) and this `VERDICT.md` documenting
the reachability analysis.
