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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2423 Β· 18 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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 static ahci_pm_dummy_done hook that the verbatim code assigns to xa->complete);
  • dfx_ahci_ata_put_xfer β€” models ahci_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:922 XPT_PATH_INQ wildcard) β€” the XPT/queue path being a kernel thread or a privileged caller (/dev/xpt0 is crw------- 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_testable
↓ fix.diffper-fix-DF-2423

Confirmed kernel references

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.