atapi_action() indexes softc->atadev[tid] with unbounded CAM target_id in XPT_SCSI_IO and XPT_PATH_INQ β tid=5 deterministically reads state_lock.lk_lockholder (curthread, non-NULL while the lock is held) and dereferences it as struct ata_device *, panicking the kernel
Summary
target_id is not bounded by the SIM's advertised max_target=1 before indexing the 2-element atadev[]; xpt_compile_path accepts any target id, so a crafted path with tid 2..15 reaches the SIM. state_lock immediately follows atadev[2]; tid=5 reads lk_lockholder which is curthread - non-NULL by construction because the lock was acquired before the slot read - so the NULL guard passes and the follow-on device_get_parent faults. REPRODUCED LIVE on the guest (its ATAPI DVD runs through this driver): camcontrol rescan 1:5:0 -> Fatal trap 12, fault VA=0x28, stopped at device_get_parent; 1:0:0 control clean. Fix kernel built and validated. Root-gated trigger (/dev/xpt0 0600). Bounds-check-all-three-paths fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4557 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.3 KB | β raw | |
| VERDICT.md | β | 5.9 KB | β raw | |
| run.sh | β | 146 B | view raw | |
| build.sh | β | 117 B | view raw | |
| run.log | β | 661 B | view raw | |
| run.2.log | β | 1.0 KB | view raw | |
| panic.txt | β | 2.1 KB | view raw | |
| env.txt | β | 483 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| manifest.json | β | 1.1 KB | view raw | |
| verdict.json | β | 3.9 KB | view raw |
DF-4557 β atapi-cam unbounded CAM target_id β softc->atadev[tid] OOB read β wild-pointer deref (kernel panic)
File
sys/dev/disk/nata/atapi-cam.c β atapi_action() XPT_SCSI_IO (:490, :615, :618) and
XPT_PATH_INQ (:385, :391).
Root cause
struct atapi_xpt_softc.atadev is a 2-element array (atadev[2], atapi-cam.c:73).
CAM target ids routed to the SIM are not bounded by the SIM's advertised
max_target = 1 (atapi-cam.c:367): xpt_compile_path() (sys/bus/cam/cam_xpt.c:3993)
allocates a cam_et for any target id a CCB path names β including 2..15.
atapi_action() then uses the raw id as an array index:
- XPT_PATH_INQ (atapi-cam.c:385,391):
softc->atadev[tid]β OOB read, NULL-checked but not bounds-checked; a non-NULL OOB qword is dereferenced (->mode). - XPT_SCSI_IO (atapi-cam.c:490,615,618):
softc->atadev[tid]β same OOB read; if the OOB qword is non-NULL,->modeis read and->devis loaded and handed toata_queue_request()β wild-pointer deref.
state_lock (a struct lock, sys/sys/lock.h:67-73) immediately follows atadev[2]
in struct atapi_xpt_softc (atapi-cam.c:72-74). struct lock field offsets:
lk_flags|lk_timo@0, lk_count@8, lk_wmesg@16, lk_lockholder@24.
Hence:
- tid=2 β lk_flags|lk_timo (0 β benign DEV_NOT_THERE)
- tid=3 β lk_count (0 when uncontended β benign)
- tid=4 β lk_wmesg ("atapicamattach" rodata ptr β non-NULL β garbage
->dev= ASCII "atapicam" β wild deref β same crash class) - tid=5 β
lk_lockholderβ and in the XPT_SCSI_IO pathstate_lockis HELD (acquired at :482) by the time :490 reads the slot, solk_lockholder== curthread β non-NULL by construction β passes the== NULLcheck βatadev[5]->dev= first qword ofstruct thread= run-queue link, NULL for a running thread βrequest->dev = NULLβata_queue_request()βdevice_get_parent(NULL)β supervisor read fault at VA 0x28. This is exactly the signature observed in panic.txt.
Reachability
/dev/xpt0 is crw------- root operator on the guest β root-only trigger
(same privilege gate as the known DF-1695/DF-2501 family, which cover only
XPT_RESET_DEV). The XPT core's own traffic is bounded by max_target, so this
needs a deliberately crafted path β e.g. the stock base-system command:
camcontrol rescan 1:5:0
camcontrol rescan <b:t:l> issues XPT_SCAN_LUN (whitelisted in xptioctl,
sys/bus/cam/cam_xpt.c:1052) on the crafted path; xpt_scan_lun() first sends
XPT_PATH_INQ (OOB read #1) and then the probe periph sends XPT_SCSI_IO INQUIRY
(OOB read #2 β wild deref β panic).
The guest (QEMU PIIX3 IDE, ATAPI DVD on ata1 β scbus1) runs this exact driver
live: cd0/pass0/sg0 are the atapicam SIM's devices.
Build
No build: uses stock /sbin/camcontrol + /dev/xpt0.
Run
./run.sh # as root on the DragonFly guest
Expected result (baseline, unpatched)
Guest panics ("Fatal trap ... page fault in supervisor mode") with a backtrace
ending in the ata layer (ata_queue_request path) β ssh connection dies,
vm.sh status β down. Serial console shows the panic text.
With the fix applied (tid bounds-checked): camcontrol rescan 1:5:0 returns
cleanly, no panic, guest stays up (probe just finds no device).
DF-4557 VERDICT β unbounded CAM target_id β atadev[tid] OOB read β wild-pointer deref
Bottom line: REPRODUCED (kernel panic, live guest), FIX VALIDATED (panic gone)
reproduced=1, impact=panic. An out-of-bounds read primitive past
softc->atadev[2] converts, deterministically, into a wild-pointer dereference
inside the kernel with a base pointer the caller (root, via /dev/xpt0) selects
from 14 fixed slots past the array.
How it reproduced (baseline, clean with-src snapshot, unpatched kernel)
Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 2026, QEMU PIIX3 IDE,
ATAPI DVD on ata1 β the atapicam SIM registers scbus1 (cd0/pass0/sg0
live through this driver; acd0 (atapi-cd) coexists on the same channel).
camcontrol rescan 1:0:0β "Re-scan ... successful" (legitimate target, sanity baseline). RC=0.-
camcontrol rescan 1:5:0β ssh session dies silently (command never returns);vm.sh statusβ down. Serial console (panic.txt):Fatal user address access from kernel mode from camcontrol at ffffffff8068d140 Fatal trap 12: page fault while in kernel mode fault virtual address = 0x28 fault code = supervisor read data, page not present instruction pointer = 0x8:0xffffffff8068d140 current process = 1086 (camcontrol) Stopped at device_get_parent: movq 0x28(%rdi),%rax
Exact chain (why tid=5 is deterministic)
camcontrol rescan 1:5:0βscanlun_or_reset_dev()opens/dev/xpt0and issuesXPT_SCAN_LUNon path (1,5,0) β allowed byxptioctl's whitelist (sys/bus/cam/cam_xpt.c:1052).xpt_compile_path()does NOT validatetarget_idagainst the SIM'smax_target(cam_xpt.c:3972-4004): it happily allocates the cam_et/cam_ed for target 5 and routes CCBs there.xpt_scan_lun()(cam_xpt.c:5619) sends XPT_PATH_INQ (OOB read #1, survives β only reads->mode) and then theprobeperiph sends XPT_SCSI_IO INQUIRY on the crafted path.atapi_action()XPT_SCSI_IO (sys/dev/disk/nata/atapi-cam.c):- :482
lockmgr(&softc->state_lock, LK_EXCLUSIVE)β state_lock now held - :490
if (softc->atadev[tid] == NULL)β tid=5 reads 40 bytes pastatadev[2], i.e.state_lock+ 24 =lk_lockholder(struct lock: lk_flags@0, lk_timo@8, lk_count@16, lk_wmesg@24, lk_lockholder@32 β sys/sys/lock.h:67-73). Because state_lock is held,lk_lockholder== curthread β non-NULL by construction β the guard passes. - :615
softc->atadev[tid]->modeβ reads curthread + offsetof(ata_device, mode) (~in-slab, mapped) β garbage value, harmless. - :618
request->dev = softc->atadev[tid]->devβdevis at offset 0 ofstruct ata_device(ata-all.h:415), so this loads the FIRST qword ofstruct thread= the run/sleep queue linktd_qq.tqe_nextβ NULL for a running thread βrequest->dev = NULL. - :639
ata_queue_request(request)βdevice_get_softc(device_get_parent( request->dev))βdevice_get_parent(NULL)reads0x28(%rdi)with rdi=0 β supervisor read fault at VA 0x28 β trap 12. Matches panic.txt exactly. - Neighbouring ids: tid=4 β
lk_wmesg("atapicamattach" rodata ptr) β->dev= first 8 bytes of the string (garbage 0x6d61636170697461) β same crash class; tid=6 β other in-slab qwords; tid=2/3 β zero qwords (benign DEV_NOT_THERE). The XPT core never sends these itself (scan bounded bymax_target), so the crafted path is required β hence privileged-only.
Impact ceiling β why impact=panic and not uid0
The primitive is an OOB read of one of 14 fixed qwords past the softc, then a
double-deref whose final pointer feeds ata_queue_request. A write chain would
require heap-grooming the softc-adjacent slab so atadev[tid] resolves to
attacker-controlled memory that survives the ->devβdevice_get_parentβ
device_get_softcβata-channel chase β but the trigger itself is gated on
/dev/xpt0 (crw------- root operator, 0600) or pass/sg (0600): an
unprivileged user cannot create the crafted CAM path at all. For the privileged
user who can, the guest is already an intentionally permissive target. So the
realistic ceiling is a privileged-triggered kernel panic / DoS β which is
what was reproduced. (Distinct from DF-1695/DF-2501, which cover only
XPT_RESET_DEV's atadev[tid] deref; the SCSI_IO and PATH_INQ paths carry NULL
checks but no bounds check, and tid=5's non-NULL guarantee is state_lock-held
curthread β a stronger trigger than RESET_DEV's plain-NULL crash.)
Fix validation (mandatory for memory-corruption class)
fix.diff bounds-checks tid (0..1) in XPT_PATH_INQ (wildcard-exempt),
XPT_RESET_DEV, and XPT_SCSI_IO before any atadev[] index.
- Applied to the guest's /usr/src with
patch -p1(3 hunks, clean);make -j8 nativekernel KERNCONF=X86_64_GENERICβ BUILD_DONE_RC=0;make installkernelβ OK; rebooted intoX86_64_GENERIC #1: Mon Sep 21 13:22:39 UTC 2026. - Re-ran the exact baseline sequence (run.2.log):
camcontrol rescan 1:0:0β successful (legit path unharmed);camcontrol rescan 1:5:0β error 0x8 (CAM_DEV_NOT_THERE), RC=1, no panic, guest up; same for tids 2,3,4,6,9,14; 3x repeat stable;- boot +
cd0probe prove wildcard PATH_INQ traffic unaffected.
Baseline panic vs patched clean-error: behavior change is exactly the fix.
Artifacts
panic.txt (serial console capture), run.log (baseline), run.2.log (patched),
env.txt (guest env), fix.diff (git-apply checked), README.md.
Guest was vm.sh reset with-src after validation β returned clean.
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.
Fix verification
fixedPatched kernel (X86_64_GENERIC #1 Mon Sep 21 13:22:39 2026, fix.diff applied via patch -p1, nativekernel RC=0) re-ran the exact PoC: camcontrol rescan 1:5:0 (and tids 2,3,4,6,9,14) now returns error 0x8 CAM_DEV_NOT_THERE with RC=1, no panic, guest up; legitimate rescan 1:0:0 still succeeds; 3x repeat stable.
findings/poc/DF-4557/run.2.log (patched behavior), /root/build.log on guest had BUILD_DONE_RC=0, fix.diff in pack (git apply --check OK)
Confirmed kernel references
Detail
Exploit chain
root opens /dev/xpt0 -> XPT_SCAN_LUN on (scbus1,tid=5,lun0) -> probe XPT_SCSI_IO -> atadev[5]=state_lock.lk_lockholder=curthread -> ->dev=NULL -> device_get_parent(NULL) -> trap 12 page fault (panic). Escalation beyond panic not applicable: trigger already requires root.
Evidence (decisive lines)
panic.txt (Fatal trap 12, VA=0x28, Stopped at device_get_parent, process=camcontrol); run.log (baseline: 1:0:0 OK then 1:5:0 kills guest); run.2.log (patched: error 0x8 for all OOB tids, STILL_ALIVE); VERDICT.md (full chain + fix validation); fix.diff (3 hunks, git apply --check OK)
PoC changes
No code PoC needed: the stock base-system camcontrol binary is the trigger (libcam/userland headers absent on guest; a hand-rolled CCB program would add nothing). run.sh encodes baseline+trigger; note csh used for guest-side scripts.
Verified recommended fix
Bounds-check ccb_h->target_id against NELEM(softc->atadev) in XPT_SCSI_IO, XPT_PATH_INQ (wildcard-exempt) and XPT_RESET_DEV before indexing atadev[]; return CAM_DEV_NOT_THERE
Verdict
Unbounded ccb_h->target_id indexes softc->atadev[2] in atapi_action XPT_SCSI_IO (:490,:615,:618) and XPT_PATH_INQ (:385,:391); tid=5 deterministically reads state_lock.lk_lockholder (= curthread while the lock is held at :482, non-NULL by construction), whose ->dev (first qword of struct thread, a NULL queue link) becomes request->dev=NULL -> ata_queue_request -> device_get_parent(NULL) -> supervisor read fault at VA 0x28. Reproduced live: 'camcontrol rescan 1:5:0' (XPT_SCAN_LUN is whitelisted in xptioctl cam_xpt.c:1052; xpt_compile_path does not bound target_id cam_xpt.c:3993) panicked the guest with exactly that signature. Trigger is root-only (/dev/xpt0 is 0600 root:operator), so impact ceiling is privileged panic/DoS, not uid0. fix.diff (bounds-check tid 0..1, wildcard-exempt in PATH_INQ) build-validated (nativekernel RC=0) and behavior-validated on guest: patched kernel returns CAM_DEV_NOT_THERE (0x8) for tids 2,3,4,5,6,9,14, legit 1:0:0 still rescans, guest stays up, boot/wildcard PATH_INQ unaffected.
No comments yet.