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

ahci_beg_exclusive_access() drain loop never processes deferred timeouts -> permanent port wedge with the port lock held

Summary

The exclusive-access drain loop spins on ap_active/ap_sactive calling only ahci_port_intr(); a deferred-expired CCB (timeout fired while other commands active, CCB not completed) can never be retired because its hw CI/SACT bit never clears and ahci_check_active_timeouts() is never called - even though ahci_poll()'s equivalent loop calls it for exactly this reason (callout deadlocked under port lock). Result: infinite spin holding ap->ap_lock; the ISR then deflects all port interrupts to the also-blocked port thread - port permanently dead, kernel thread wedged. PM with >=2 targets; hang target 0 mid-read + hot-(re)plug target 1 -> permanent soft hang of the SATA port. Hardware-conditioned DoS. One-line fix mirroring ahci_poll.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3773 Β· 5 files
FileTypeDescriptionSize
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 2.3 KB ↓ raw
env.txt β€” 551 B view raw
fix.diff β€” 621 B view raw
verdict.json β€” 2.1 KB view raw

DF-3773 β€” ahci_beg_exclusive_access() drain loop never processes deferred timeouts β†’ permanent port-thread wedge with the port lock held

File: sys/dev/disk/ahci/ahci.c (pass 2, GLM 5.3)

What the bug is

void
ahci_beg_exclusive_access(struct ahci_port *ap, struct ata_port *at)
{
    KKASSERT((ap->ap_flags & AP_F_EXCLUSIVE_ACCESS) == 0);
    ap->ap_flags |= AP_F_EXCLUSIVE_ACCESS;
    while (ap->ap_active || ap->ap_sactive) {         /* ahci.c:2266 */
        ahci_port_intr(ap, 1);
        ahci_os_softsleep();
    }
}

The loop assumes every active command will eventually complete via ahci_port_intr(). That is false for a CCB whose timeout has fired but was deferred: ahci_ata_cmd_timeout() (ahci.c:3873-3884) sets ap->ap_expired and returns without completing whenever the port is still running and other non-expired commands are active. The only other places a deferred-expired CCB gets processed are ahci_check_active_timeouts() (ahci.c:2192-2207) and the completion walk in ahci_port_intr (ahci.c:3186) β€” neither of which can retire a CCB whose hardware CI/SACT bit is still set (device hung: ~ci_saved & *active never includes it, ahci.c:3152).

The driver knows this and handles it everywhere else it spins: ahci_poll() calls ahci_check_active_timeouts(ap) inside its own wait loop (ahci.c:2160-2163, comment: "When polling we have to check if the currently active CCB(s) have timed out as the callout will be deadlocked while we hold the port lock"). ahci_beg_exclusive_access() β€” which holds the same port lock and can spin for the same reasons β€” does not.

Consequence

ahci_beg_exclusive_access() is called from the PM state machine (ahci.c:938) which runs under the port lock from ahci_xpt_action XPT_ENG_EXEC (ahci_cam.c:888-890) or the port thread's initial probe (ahci_dragonfly.c:375). If a command to a PM target is deferred-expired and the device never completes it:

  • the loop spins forever holding ap->ap_lock;
  • every subsequent I/O to the port blocks on that lock;
  • ahci_intr() can no longer take the lock non-blocking, so it defers all port interrupts to the port thread (ahci.c:2514-2517) β€” which also blocks on the lock β‡’ the port is permanently dead and a kernel thread is wedged.

Preconditions: port multiplier attached, a command to one target hangs (timeout deferred), then a hot-plug/state-machine event on another target enters ahci_beg_exclusive_access. Hardware-dependent DoS β†’ Low.

Why not reproduced on the guest

No AHCI controller and no PM on the audit guest (env.txt). Source-level logic is definite; reachability requires a hung SATA device behind a PM.

Reproduce on suitable hardware

PM with β‰₯2 targets; target 0 disk hangs mid-read (its timeout defers while target 1 has an in-flight command β€” or vice versa); then hot-(re)plug target 1 so the state machine calls ahci_beg_exclusive_access. Observe the port thread spin at 100% in the loop with ap_expired != 0 and the port lock held.

VERDICT.md
↓ download raw

DF-3773 VERDICT β€” not_reproduced (no AHCI/PM hardware on guest); logic gap definite by source

Bottom line

ahci_beg_exclusive_access() (ahci.c:2262-2270) spins on while (ap->ap_active || ap->ap_sactive) calling only ahci_port_intr(). A deferred-expired CCB β€” one whose timeout fired while other commands were active (ap->ap_expired set, CCB not completed: ahci.c:3873-3884) β€” can never be retired by that loop if the device never completes it, because ahci_port_intr() only retires slots whose hardware CI/SACT bit cleared (ahci.c:3152) and ahci_check_active_timeouts() is never called. The driver demonstrates the correct discipline in ahci_poll()'s wait loop (ahci.c:2160-2163), whose comment states the exact reason: the callout cannot run while the port lock is held. ahci_beg_exclusive_access() holds the port lock too (callers: ahci.c:938 via the state machine, run under ap_lock from ahci_cam.c:888-890 / ahci_dragonfly.c:375), so the deferred timeout is deadlocked by construction and the loop never exits.

Result: permanent wedge of the calling thread with ap->ap_lock held; the port interrupt handler then deflects all port interrupts to the port thread (ahci.c:2514-2517) which also blocks on the lock β€” the port is dead and one kernel thread spins forever. DoS only (no memory unsafety). Preconditions: port multiplier + hung target + deferred timeout + PM state-machine event.

Not-reproduced rationale

The audit guest has no AHCI controller at all (env.txt: pciconf shows none, root on virtio-blk, ahci static in GENERIC). The path needs real AHCI + PM hardware misbehavior. Source reasoning is line-accurate and the asymmetry with ahci_poll() is unambiguous.

Add timeout processing to the drain loop, mirroring ahci_poll():

    while (ap->ap_active || ap->ap_sactive) {
        ahci_port_intr(ap, 1);
        ahci_os_softsleep();
        ahci_check_active_timeouts(ap); /* NEW: retire deferred-expired CCBs */
    }

see fix.diff (one line + comment).

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
baseline no→ patch + rebuild →patched clean

One-line fix authored; no hardware to validate against.

fix.diff in this pack.
↓ fix.diffper-fix-DF-3773

Confirmed kernel references

Detail

Evidence (decisive lines)

VERDICT.md; ahci.c:2262-2270 (loop without timeout processing) vs ahci.c:2154-2168 (ahci_poll loop WITH it); ahci.c:3873-3884 (deferred timeout); ahci.c:3152 (completion walk cannot retire hung slots); ahci.c:938 (caller under port lock)

PoC changes

No runtime PoC possible - needs AHCI + port-multiplier hardware with a hung target.

Verified recommended fix

Call ahci_check_active_timeouts(ap) inside the beg_exclusive_access drain loop (one line), mirroring ahci_poll().

Verdict

ahci_beg_exclusive_access() (ahci.c:2262-2270) drains active commands with only ahci_port_intr(), but a deferred-expired CCB (timeout fired while other commands active - ap->ap_expired set, CCB not completed, ahci.c:3873-3884) can never be retired by that loop because its hardware CI/SACT bit never clears and ahci_check_active_timeouts() is never called - even though ahci_poll()'s equivalent loop does call it (ahci.c:2160-2163) for exactly this reason. The loop then spins forever holding the port lock; the port interrupt handler deflects to the also-blocked port thread (ahci.c:2514-2517), permanently wedging the port and a kernel thread. Requires PM hardware plus a hung target - DoS only. Not reproduced: guest has no AHCI controller.