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)
PoC verification
Evidence pack
findings/poc/DF-0974 Β· 7 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 != NULLbefore invoking fix_build.logβ kernel build output showingcam_xpt.ccompiles cleanly under-Werrorwith the fix appliedenv.txtβ guest environmentVERDICT.mdβ detailed analysismanifest.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"'
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:
- 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. mpr_sascallsxpt_register_async(event, mprsas_async, sc, NULL)at attach andxpt_register_async(0, mprsas_async, sc, sassc->path)at detach β the paths differ (NULLvssassc->path), so the deregister lands on a different device's async list and does not see the original entry.- No in-tree caller issues two SASYNC_CB CCBs with the same
(callback, callback_arg, path)tuple, one with non-zeroevent_enableand 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:
- Deregister path no longer frees
cur_entryβ instead NULLscallbackand 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. - Task dispatch sites (
xptsetasyncfunc,xptsetasyncbusfunc) checkcur_entry->callback == NULLbefore 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.diffapplies cleanly withpatch -p1(rc=0).- A single-fix kernel was built:
make -j6 nativekernel KERNCONF=X86_64_GENERICcompleted withcam_xpt.obuilt under-Werror(no warnings, no errors), and the kernel linked successfully (/usr/obj/usr/src/sys/X86_64_GENERIC/kernel.stripped15.7MB, today's build). Seefix_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_testablecompile 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.
No comments yet.