NULL-pointer dereference in ahci_pm_read/ahci_pm_write when CCB pool is exhausted
Summary
ahci_pm_read() and ahci_pm_write() call ahci_ata_get_xfer() and immediately dereference returned ata_xfer pointer without checking for NULL. ahci_ata_get_xfer() returns NULL when per-port CCB free list empty. xa->fis is first field of struct ata_xfer so NULL xa causes fault reading *(0x0) on very next line panicking kernel. Reachable from PM hot-plug/async-notify handling when port CCB pool saturated by concurrent NCQ I/O. ahci_pm.c:976 xa=ahci_ata_get_xfer(ap ap->ap_ata[15]) then :978 xa->fis->type=ATA_FIS_TYPE_H2D with no NULL check. Same defect at :1013/:1015 ahci_pm_write. Pool is sc_ncmds-deep minus error-CCB slot so under heavy NCQ I/O on PM-backed port all slots can be in flight when hot-plug event arrives. Attacker: malicious SATA PM spamming async-notify + local user keeping CCB pool saturated with I/O. [VERIFIED pass2: reproduced - Fatal trap 12 fault VA 0x0 at exactly ahci_pm_read+0x2b and ahci_pm_write+0x2c via pristine-TU harness with exhausted CCB pool, control with free CCB completes with exact FIS encodings; async-notify path runs before exclusive access so full NCQ saturation race is real; hardware-gated DoS, privesc closed - NULL LOAD in kernel-thread contexts, user-context callers are 0600 root-operator; NULL-check fix-validated EBUSY, same unchecked idiom noted at 10 more ahci call sites for follow-up]
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2423 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| dfx2423.c | β | 8.3 KB | view raw | |
| build.sh | β | 178 B | view raw | |
| build.log | β | 5.5 MB | β download | |
| build.fixed.log | β | 5.6 MB | β download | |
| run.log | β | 3.3 KB | view raw | |
| run.sh | β | 239 B | view raw | |
| run.control.log | β | 656 B | view raw | |
| serial.phase2.log | β | 2.6 KB | view raw | |
| panic.txt | β | 1.5 KB | view raw | |
| serial.phase3.log | β | 2.5 KB | view raw | |
| panic.write.txt | β | 1.6 KB | view raw | |
| run.fixed.log | β | 1.1 KB | view raw | |
| disas.baseline-vs-fixed.txt | β | 1.6 KB | view raw | |
| fix.diff | β | 728 B | view raw | |
| env.txt | β | 1.3 KB | view raw | |
| VERDICT.md | β | 7.0 KB | β raw | |
| manifest.json | β | 2.2 KB | view raw | |
| verdict.json | β | 4.1 KB | view raw |
DF-2423 β NULL deref in ahci_pm_read/ahci_pm_write when the per-port CCB pool is exhausted
What was verified and how
sys/dev/disk/ahci/ahci_pm.c:976-978 (ahci_pm_read) and :1013-1015
(ahci_pm_write) call ahci_ata_get_xfer() and immediately dereference
the result through xa->fis with no NULL check. ahci_ata_get_xfer
(sys/dev/disk/ahci/ahci.c:3669-3689) returns NULL whenever
ahci_get_ccb() (ahci.c:3297) finds the per-port free list
ap->ap_ccb_free empty β i.e. all sc_ncmds (AHCI_REG_CAP_NCS, up to
32, ahci_attach.c:349, pool alloc ahci.c:370) command slots of the
port are in flight. struct ata_xfer.fis is the FIRST member of the
struct (atascsi.h:356-358), so the unchecked load xa->fis faults at
VA 0x0.
Rig: verbatim-interposition, kernel-compiled harness
(dfx2423.c in this pack). The PRISTINE ahci_pm.c TU
(sha256 db2f57847144879bb89c13869476e95c643ff832be6c7f13ea963ba0f2bbac66,
gate enforced by build.sh) is #included into a harness file compiled
into a custom DFX2423 kernel. Because the stock
X86_64_GENERIC config builds the ahci driver into the kernel
(config:65 device ahci), every symbol the TU defines is renamed
ahci_pm_* -> dfx_pm_* via preprocessor defines β the function bodies
compile byte-verbatim (proven by the panic IPs matching the disassembly
of the renamed symbols). Only the three pool-path functions are
interposed onto fabrications:
dfx_ahci_ata_get_xferβ faithfully models ahci.c:3674-3688 (NULL on exhausted pool, otherwise the same bzero/at/fis preset);dfx_ahci_ata_cmdβ fabricated completing device (fills rfis, sets ATA_S_COMPLETE, invokes the REAL staticahci_pm_dummy_donehook that the verbatim code assigns toxa->complete);dfx_ahci_ata_put_xferβ modelsahci_put_ccb(ahci.c:3317-3330).
All other TU externals (ahci_pread, ahci_poll, ...) link against the
REAL in-kernel ahci driver. Trigger: sysctl hw.dfx2423_phase=N
(1=control, 2=exhausted read, 3=exhausted write).
Results
| run | kernel | phase | outcome |
|---|---|---|---|
| run.control.log | baseline (pristine TU) | 1 control | real ahci_pm_read/_write complete normally: rv=0 data=ddccbbaa, read FIS type=27 flags=8f cmd=e4 feat=20 dev=4f ctl=08, write FIS cmd=e8 feat=02 dev=43 scnt=d4 lba=c3,b2,a1 ctl=08, counters get/cmd/done/put = 2/2/2/2, guest stays up |
| panic.txt | baseline | 2 exhausted read | Fatal trap 12, fault VA = 0x0, dfx_pm_read+0x2b @ 0xffffffff80a800cb β the exact mov (%rax),%rdx load of xa->fis (ahci_pm.c:978); backtrace dfx_pm_read <- dfx2423_sysctl; guest down |
| panic.write.txt | baseline | 3 exhausted write | Fatal trap 12, fault VA = 0x0, dfx_pm_write+0x2c @ 0xffffffff80a8018c (ahci_pm.c:1015); guest down |
| run.fixed.log | fix.diff applied (TU sha256 8a42cfdb...a987f) |
1+2+3 | phase 1 output bit-identical to baseline control; phases 2/3 print fakep0: pm_read/pm_write: no ccb available and return 16 (EBUSY) β no crash, guest stays up |
Machine-code A/B in disas.baseline-vs-fixed.txt: baseline has no check
between callq dfx_ahci_ata_get_xfer and mov (%rax),%rdx; the fixed
kernel inserts test %rax,%rax; je before the deref.
(Nit: the expectation comment inside dfx2423.c says flags=9f; the
correct arithmetic is ATA_H2D_FLAGS_CMD|15 = 0x80|0x0F = 0x8f, which is
what the code actually produced in every run. The comment slip is left
as-built so the shipped source hash 8c3f8d92...d38e matches the
compiled artifact.)
Why the NULL actually happens in production (reachability)
The per-port CCB pool is shared by ALL targets behind a port multiplier
on that HBA port. The PM state machine
ahci_port_state_machine (ahci.c:796) runs:
- on boot/attach and after PM events from the per-port kernel
thread (
ahci_port_thread_core, ahci.c:2525), and - from CAM rescan context (ahci_cam.c:889
XPT_ENG_EXEC, ahci_cam.c:922XPT_PATH_INQwildcard) β the XPT/queue path being a kernel thread or a privileged caller (/dev/xpt0iscrw------- root:operator).
At ahci.c:841 it calls ahci_pm_read(ap, 15, SATA_PMREG_EINFO, &data)
without holding exclusive access β ahci_beg_exclusive_access is
only taken later for the probe steps (ahci.c:~950). Likewise
ahci_pm_check_good (called at ahci.c:891 before exclusive access)
issues pm_read/pm_write at ahci_pm.c:917/922/943. So while CAM keeps
all 32 slots busy with NCQ I/O on other PM targets, an async-notify
event (PM hot-plug bitmap, D2H/SDB notify FIS β a hostile or merely
flaky port multiplier can generate these at will) walks straight into
the NULL deref. The probe-time callers (ahci_pm_identify etc.) run with
the port stopped/quiesced, so the state-machine/hotplug path is the
realistic one. Race window is real but needs: AHCI HBA with CAP.SPM, a
port multiplier attached, async-notify enabled (CAP.SSNTF + PM feature,
ahci_pm.c:498-513), all CCBs in flight, and a notify event landing in
that window β hence Medium severity, hardware-gated local DoS.
Privesc assessment (explicit close)
The fault is a fixed-offset (0x0) supervisor-mode LOAD whose page is
guaranteed not-present in the contexts the real driver runs in (kernel
threads use the kernel map; user mappings are not visible). The faulting
value and every subsequent store target (fis->... constants,
which/data bytes) are therefore never attacker-controlled on the
real path: deterministic panic β DoS only, no privilege escalation.
One nuance documented for completeness: DragonFly does allow
mmap(MAP_FIXED, NULL) in user processes (verified on the guest:
/tmp/nullmap mapped page 0 read/write β see env.txt note), so IF a
future code path executed ahci_pm_read in an unprivileged user
vmm-context, the NULL load would fetch an attacker-chosen pointer and
the following FIS stores would become semi-controlled arbitrary-address
writes. No such unprivileged path exists today β the only user-context
entries (XPT_PATH_INQ/XPT_ENG_EXEC via /dev/xpt0, /dev/passN) are
0600 root:operator, i.e. already-privileged. The applied fix removes
the entire question. Related observation (out of scope for this
finding): the same unchecked ahci_ata_get_xfer idiom exists at
ahci_cam.c:300, :387, :539, :562, :589, :693, :1082, :1524 and
ahci.c:4057, :4090 β those run in attach/exclusive contexts today but
deserve the same guard.
Fix
fix.diff in this pack (validated A/B above): NULL-check with graceful
return (EBUSY) in both ahci_pm_read and ahci_pm_write, matching
the DPRINTF the allocator itself anticipates (ahci.c:3676). Callers
already treat non-zero as transient failure (state machine retries on
the next notify event; check_good prints and moves on).
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
not_testableConfirmed kernel references
- sys/dev/disk/ahci/ahci_pm.c:976
- sys/dev/disk/ahci/ahci_pm.c:978
- sys/dev/disk/ahci/ahci_pm.c:1013
- sys/dev/disk/ahci/ahci_pm.c:1015
- sys/dev/disk/ahci/ahci.c:3669
- sys/dev/disk/ahci/ahci.c:3675
- sys/dev/disk/ahci/ahci.c:3297
- sys/dev/disk/ahci/ahci.c:841
- sys/dev/disk/ahci/ahci.c:891
- sys/dev/disk/ahci/atascsi.h:356
- sys/dev/disk/ahci/ahci_attach.c:349
Detail
Evidence (decisive lines)
panic.txt (phase2: Fatal trap 12, fault virtual address 0x0, dfx_pm_read+0x2b 0xffffffff80a800cb, backtrace dfx_pm_read <- dfx2423_sysctl); panic.write.txt (phase3: dfx_pm_write+0x2c 0xffffffff80a8018c); disas.baseline-vs-fixed.txt (baseline has no check between callq get_xfer and mov (%rax),%rdx; fixed kernel inserts test %rax,%rax; je); run.control.log (free CCB: rv=0 data=ddccbbaa, FIS encodings exact, counters 2/2/2/2); run.fixed.log (phases 1-3 on fixed kernel: EBUSY 16, no crash, guest up); build.sh sha256 gate pins the TU to the pristine tree hash db2f5784...ac66
PoC changes
No seed PoC existed; authored the verbatim-interposition kernel harness from scratch: pristine ahci_pm.c TU included with preprocessor symbol renames (dfx_pm_*) because the stock kernel already contains the ahci driver, three interposed pool functions (get_xfer/ata_cmd/put_xfer) modeling ahci.c:3669-3699, sysctl trigger instead of a device node, and a conf/files + conf/options + DFX2423 config wiring for the kernel build (runtime kldload GPFs on this guest).
Verified recommended fix
NULL-check the ahci_ata_get_xfer return in ahci_pm_read and ahci_pm_write and return EBUSY (see fix.diff, A/B validated).
Verdict
Reproduced on the QEMU guest with a verbatim-TU kernel-compiled harness: the real ahci_pm_read (ahci_pm.c:976-978) and ahci_pm_write (:1013-1015) deref xa->fis (first struct member, offset 0) immediately after ahci_ata_get_xfer with no NULL check, panicking with Fatal trap 12 at fault VA 0x0 (dfx_pm_read+0x2b and dfx_pm_write+0x2c, IPs cross-checked against disassembly) whenever the per-port CCB free list is exhausted (ahci_get_ccb NULL, ahci.c:3675-3678). Control run with a free CCB completes normally through the real code path; fix.diff (NULL check returning EBUSY) was A/B validated - panic gone, control path bit-identical. Reachable in production from the PM async-notify/hotplug state machine (ahci.c:841, :891) which runs before exclusive access while CAM NCQ I/O holds all AHCI_REG_CAP_NCS (up to 32) per-port slots shared across PM targets; requires AHCI hardware with a port multiplier. Hardware-gated kernel DoS/panic, not privesc: fixed-offset supervisor LOAD of a guaranteed-not-present page in kernel-thread contexts; no unprivileged user-context path exists (CAM xpt entries are 0600 root:operator), though DragonFly does permit user mmap of page 0, which is why the guard is worth having.
No comments yet.