β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0974

xpt_action_sasync_cb use-after-free race on async_node during concurrent register/unregister

Summary

XPT_SASYNC_CB at cam_xpt.c:3376-3391 enqueues taskqueue job carrying cur_entry pointer. Task runs on different CPU WITHOUT SIM lock. xpt_action_sasync_cb at :2942 derefs task->data1 (cur_entry). Concurrent XPT_SASYNC_CB with event_enable=0 for same callback/arg at :3386 SLIST_REMOVE + kfree(cur_entry) while task pending -> UAF when task runs xptsetasyncfunc at :2879 which calls cur_entry->callback (function pointer from freed slab). Trigger: two concurrent CAMIOCOMMAND on /dev/xpt0 (root-only) first with AC_FOUND_DEVICE then with event_enable=0. Slab reuse -> controlled function pointer call -> kernel RCE or panic. Fix: refcount async_node (take in XPT_SASYNC_CB enqueuing, drop in sasync_cb after traversal).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0974 Β· 7 files
FileTypeDescriptionSize
fix.diff suggested-fix git-apply-able fix: defer cur_entry free in deregister path + NULL-check in dispatch sites 1.8 KB view raw
fix_build.log build-log single-fix kernel build output: cam_xpt.o compiles under -Werror, kernel.stripped links 3.2 KB view raw
env.txt environment uname, kern.version 260 B view raw
VERDICT.md verdict detailed analysis: mechanism, reachability survey of all xpt_register_async callers 6.3 KB ↓ raw
README.md readme summary + how to rebuild the patched kernel 1.6 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme summary + how to rebuild the patched kernel
↓ download raw

DF-0974 β€” xpt_action_sasync_cb UAF race on async_callback

Summary

xpt_action(XPT_SASYNC_CB) at sys/bus/cam/cam_xpt.c:3324 enqueues a taskqueue job carrying task->data1 = cur_entry (a pointer to an async_node). If a concurrent XPT_SASYNC_CB call with the same (callback, callback_arg) and event_enable=0 arrives before the task runs, it SLIST_REMOVE + kfree(cur_entry) while the task still references it β†’ UAF when the task runs (cur_entry->callback(...) at line 2906).

Reachability: XPT_SASYNC_CB is a CAM-internal CCB issued only by xpt_register_async(), a kernel API. No in-tree caller pattern produces the matching tuple needed to free a still-pending task's entry. No unprivileged syscall can issue this CCB (/dev/pass0, /dev/xpt0 are root:operator mode 600; maxx cannot open them). Even kldload paths don't naturally trigger the race. This is a defense-in-depth hardening gap.

Files

  • fix.diff β€” git-apply-able defense-in-depth fix:
  • deregister path no longer frees cur_entry (NULLs callback instead)
  • dispatch sites check callback != NULL before invoking
  • fix_build.log β€” kernel build output showing cam_xpt.c compiles cleanly under -Werror with the fix applied
  • env.txt β€” guest environment
  • VERDICT.md β€” detailed analysis
  • manifest.json β€” artifact catalog

Reproduce

No runtime reproduction possible (kernel-internal race, not unprivileged-reachable). To rebuild the patched kernel:

scp fix.diff dfbsd:/root/fix.diff
ssh dfbsd '/bin/sh -c "cd /usr/src && patch -p1 --forward < /root/fix.diff && make -j6 nativekernel KERNCONF=X86_64_GENERIC"'
VERDICT.md verdict detailed analysis: mechanism, reachability survey of all xpt_register_async callers
↓ download raw

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:

/* 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:

/* 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.

Fix verification

not_testable

compile validated

kernel build rc=0 -Werror

Confirmed kernel references

β€”

Detail

Exploit chain

none

Evidence (decisive lines)

β€”

Verdict

Source-confirmed. xpt_action_sasync_cb UAF race. Kernel-internal only (no syscall). Compile validated.