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

mpt_timeout() uses the CCB after xpt_done() hands it back β€” callout_stop() does not interlock against the in-progress timeout callback (freed/recycled CCB -> wild deref or wrong-request abort)

Summary

mpt_req_untimeout is plain callout_stop() on a callout_init_mp() callout, which does not block / does not interlock against an in-progress callback. mpt_timeout reads ccb->ccb_h.ccb_mpt_ptr before the lock and ccb->ccb_h.ccb_req_ptr after acquiring it; if the callback already fired and is blocked on the mpt lock while the completion path recycles (pooled XPT_SCSI_IO CCBs) or kfree's (zone CTIO CCBs) the CCB, the callback resumes reading freed memory: garbage req pointer -> req->serno wild read -> panic; stale pointer -> TAILQ move of an innocent live request to the timeout list -> spurious abort of an unrelated command. Window user-amplifiable via pass-supplied ccb_h.timeout=1ms. Operator-group local user with a pass node, under I/O load with short timeouts: race-window kernel panic (DoS); list-corruption double-remove panics. Fix: arm the callout with the pool-resident request_t instead of the CCB + state re-validation under lock.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3673 Β· 10 files
FileTypeDescriptionSize
README.md β€” 958 B ↓ raw
VERDICT.md β€” 5.0 KB ↓ raw
mpt_timeout_race.c β€” 3.2 KB view raw
build.sh β€” 99 B view raw
run.sh β€” 150 B view raw
env.txt β€” 281 B view raw
run.log β€” 281 B view raw
fix.diff β€” 3.7 KB view raw
verdict.json β€” 3.6 KB view raw
manifest.json β€” 854 B view raw

DF-3673 β€” mpt_timeout() uses the CCB after xpt_done() hands it back

Severity: Medium (memcorrupt) β€” source-provable race; runtime-gated on MPT hardware.

Build / Run (host with an mpt-attached LUN)

cc -O -o mpt_timeout_race mpt_timeout_race.c
./mpt_timeout_race /dev/passN

Hammer the device with 1 ms-timeout READ(6)s; every completion that lands in the callout-callback window makes mpt_timeout() re-enter with a freed / recycled CCB (mpt_cam.c:1240-1252 vs completion at :2662/:2710).

Expected on vulnerable kernel under load: kernel panic (wild req pointer read via req->serno), or spurious abort of an unrelated command with pending/timeout-list confusion.

Why this guest cannot run it

Same as DF-3672: mpt is compiled in (X86_64_GENERIC:93) but the QEMU guest has no LSI PCI device β€” no mpt bus, no I/O, no callouts armed by this driver.

See VERDICT.md for the full interleaving proof and env.txt for the probe.

VERDICT.md
↓ download raw

DF-3673 β€” VERDICT

status: inconclusive (execution-gated: no MPT hardware on the audit guest; source-level certainty: likely β€” race is provable, timing-dependent)

Root cause

mpt_req_untimeout() is callout_stop() (sys/dev/disk/mpt/mpt.h:769-772) and mpt callouts are initialized callout_init_mp() (mpt.h:774-775, called from mpt.c:2703). For non-lock-aided callouts, DragonFly's callout_stop() "does not block" and "does not interlock against a callout that is in-progress" (sys/kern/kern_timeout.c:1073-1096).

The timeout callback:

/* sys/dev/disk/mpt/mpt_cam.c:1234-1254 */
static void
mpt_timeout(void *arg)
{
    union ccb    *ccb = (union ccb *)arg;
    struct mpt_softc *mpt;

    mpt = ccb->ccb_h.ccb_mpt_ptr;         /* read BEFORE the lock */
    MPT_LOCK(mpt);                         /* blocks if intr holds lock */
    req = ccb->ccb_h.ccb_req_ptr;          /* read AFTER ccb may be freed */
    mpt_prt(mpt, "request %p:%u timed out ...", req, req->serno, ...);
    if ((req->state & REQ_STATE_QUEUED) == REQ_STATE_QUEUED) {
        TAILQ_REMOVE(&mpt->request_pending_list, req, links);
        TAILQ_INSERT_TAIL(&mpt->request_timeout_list, req, links);
        req->state |= REQ_STATE_TIMEDOUT;
        mpt_wakeup_recovery_thread(mpt);
    }
}

The completion path (interrupt thread, mpt lock held):

/* sys/dev/disk/mpt/mpt_cam.c:2662, 2710 */
    mpt_req_untimeout(req, mpt_timeout, ccb);   /* callout_stop β€” no
                                                   * interlock if running */
    ...
    xpt_done(ccb);                              /* CCB handed back to CAM */

Interleaving (two CPUs):

  1. callout deadline expires; softclock enters mpt_timeout, reads ccb->ccb_h.ccb_mpt_ptr, blocks on MPT_LOCK.
  2. interrupt completes the command: callout_stop (no effect β€” callback already running), completes and recycles the CCB via xpt_done(): - XPT_SCSI_IO CCBS come from the periph pool (cam_periph_getccb) and are immediately reusable by the next command; - XPT_CONT_TARGET_IO CCBS (armed with a 60 s timeout at mpt_cam.c:4849) are zone-allocated by pass(4) (scsi_pass.c:495-504, USER_CCB β‡’ xpt_alloc_ccb) and kfree'd (xpt_free_ccb, sys/bus/cam/cam_xpt.c:3927-3934), so their memory is reusable by any zone allocation.
  3. mpt_timeout acquires the lock, reads ccb_req_ptr from freed/reused memory: - garbage pointer β†’ req->serno deref β†’ wild kernel read β†’ panic; - stale-but-valid pointer (pooled case) β†’ TAILQ_REMOVE + TAILQ_INSERT_TAIL move an unrelated, live request to the timeout list and mark it REQ_STATE_TIMEDOUT β†’ recovery thread aborts an innocent command; its later completion removes it from the timeout list (mpt_cam.c:2711-2717) β€” meanwhile the aborted-command bookkeeping (mpt_recover_commands, mpt_cam.c:4035-4116) operates on a ccb pointer (req->ccb) that belongs to the new owner β†’ confusion.

The same untimeout-then-done pattern exists at mpt_cam.c:5252 and mpt_cam.c:5315 (turbo target replies).

User amplification

ccb->ccb_h.timeout is a user-set field (ms) copied by xpt_merge_ccb(); mpt_execute_req{,_a64} arm the callout with it (mpt_cam.c:1638-1641, 2025-2028). A pass(4) caller issuing XPT_SCSI_IO with timeout = 1 on a busy device maximizes expiry-vs-completion collisions. (Upstream FreeBSD's mpt_timeout uses MPT_LOCK_ASSERT instead of taking the lock; DFly's self-locking variant additionally reads ccb_mpt_ptr before the lock, but the core race β€” non-interlocking untimeout + CCB handed back before the blocked callback resumes β€” is shared.)

Impact

Kernel use of a freed/recycled CAM CCB β†’ wild-pointer read (panic / local DoS from an operator-group user on hardware) or wrong-request abort with pending/timeout list confusion (double-remove panics). A write primitive via TAILQ_REMOVE(&mpt->request_pending_list, req, links) would require the OOB-fetched req to be attacker-shaped; not demonstrated here.

Why inconclusive on this guest

Same as DF-3672: mpt compiled into X86_64_GENERIC but zero LSI PCI devices β€” no mpt bus, no pass node on one, no completions, no callouts armed by this driver. The race is source-provable (see above) but its reproduction needs MPT hardware under I/O load.

Fix

Stop touching the CCB after the completion path may have handed it back: arm the callout with the pool-resident request_t * (never freed until detach), re-read req->ccb only after acquiring the lock, and bail unless the request is still queued. See fix.diff.

Evidence

  • mpt_timeout_race.c β€” the load pattern that would trigger it.
  • env.txt β€” guest hardware absence proof.

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

fix.diff authored against read-only sys/ (request_t gains to_arg + sc back-pointer; callout arg becomes the request; under-lock re-validation). Build-and-run validation impossible: bug not triggerable on the baseline guest (no MPT hardware).

fix.diff; env.txt documents hardware absence
↓ fix.diffper-fix-DF-3673

Confirmed kernel references

Detail

Exploit chain

operator-group user -> pass CAMIOCOMMAND XPT_SCSI_IO with ccb_h.timeout=1ms in a tight loop -> callout fires on CPU A, blocks on mpt lock -> completion on CPU B: callout_stop (no interlock), xpt_done recycles/frees CCB -> CPU A resumes, reads ccb_req_ptr from reused memory -> wild request_t* deref (panic) or wrong-request TAILQ_MOVE + REQ_STATE_TIMEDOUT -> recovery aborts unrelated command

Evidence (decisive lines)

VERDICT.md (full interleaving with path:line); mpt_timeout_race.c (load harness); env.txt/run.log (no LSI hardware on guest); fix.diff (pool-resident callout arg + under-lock re-validation)

PoC changes

n/a (authored from scratch)

Verified recommended fix

Arm the callout with the pool-resident request_t instead of the CCB; take the mpt lock via a request back-pointer, re-validate REQ_STATE_QUEUED under the lock before touching the CCB

Verdict

Source-provable CCB lifetime race, timing-dependent, not executable on this guest (no MPT hardware). mpt_req_untimeout() is plain callout_stop() (mpt.h:769-772) on a callout_init_mp() callout, which per kern_timeout.c:1073-1096 does not interlock against an in-progress callback. mpt_timeout() (mpt_cam.c:1234-1255) reads ccb->ccb_h.ccb_mpt_ptr before the lock and ccb->ccb_h.ccb_req_ptr after acquiring it; if the timeout callback is already executing and blocked on the mpt lock while the completion path (mpt_scsi_reply_handler:2662-2723, or the turbo target paths 5252/5315) runs callout_stop + xpt_done, the callback resumes with a CCB that CAM has recycled (pooled XPT_SCSI_IO CCBS) or kfree'd (zone CTIO CCBS, armed 60s at mpt_cam.c:4849): garbage req pointer -> req->serno wild read -> panic, or stale req -> TAILQ move of an innocent request to the timeout list -> spurious abort / list confusion. Window is user-amplifiable: pass(4) callers set ccb_h.timeout per command (1ms). Expected impact on hardware: local DoS/panic from an operator-group user; write-primitive via TAILQ_REMOVE not demonstrated.