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

mpr_wait_command()/mpr_request_polled() miss the realloc check on the concurrent-reinit wakeup/poll path β€” use-after-free on struct mpr_command

Summary

A thread asleep in mpr_wait_command()'s lksleep that is woken by a CONCURRENT diag-reset reinit keeps using its mpr_command after mpr_iocfacts_free() kfreed the whole sc->commands[] array: mprsas_complete_all_commands() only wakeup()s WAKEUP-owned commands without freeing them, then the reallocating pass frees the array under the still-blocked sleeper. The sleeper's lksleep returns 0 and the function returns success with *cmp dangling - only the own-timeout EWOULDBLOCK path checks MPR_FLAGS_REALLOCATED. mpr_request_polled()'s poll loop likewise keeps dereferencing cm; its REALLOCATED check exists only after its own 60s timeout. Any synchronous command sleeper (ioctl, config-page reads, SATA ID, task management) racing a facts-changing reinit (triggerable by unprivileged raw-I/O timeouts on a faulting controller, the FAULT watchdog, or root FW-download + reset). Callers then read cm->cm_reply and call callout_stop()/mpr_free_command() on freed memory; mpr_free_command re-inserts the dangling pointer into req_list - after the immediate same-size realloc it aliases a live new-pool command, giving one mpr_command two owners -> kernel memory corruption. Hardware-gated (no SAS3 HBA on guest); PoC builds on the guest.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3813 Β· 10 files
FileTypeDescriptionSize
README.md β€” 3.6 KB ↓ raw
VERDICT.md β€” 4.0 KB ↓ raw
poc_concurrent_reinit.c β€” 4.3 KB view raw
build.sh β€” 107 B view raw
run.sh β€” 86 B view raw
build.log β€” 741 B view raw
guest_run.log β€” 300 B view raw
fix.diff β€” 1.7 KB view raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 3.3 KB view raw

DF-3813 β€” mpr_wait_command()/mpr_request_polled() miss MPR_FLAGS_REALLOCATED on the concurrent-reinit wakeup/poll path β†’ use-after-free on struct mpr_command

What

mpr_wait_command() (sys/dev/raid/mpr/mpr.c:3557-3621) sleeps on the caller's command:

error = lksleep(cm, &sc->mpr_lock, 0, "mprwait", timeout*hz);   /* mpr.c:3578 */

A diag-reset reinit from ANY other thread (CAM I/O timeout via mprsas TM β†’ mpr_reinit(), the periodic FAULT watchdog at mpr.c:2041-2048, a TM timeout callout at mpr_sas.c:1263, another ioctl user) does:

  1. mprsas_handle_reinit() β†’ mprsas_complete_all_commands() β€” for sleeper-owned commands (cm_complete==NULL, MPR_CM_FLAGS_WAKEUP set) it only calls wakeup(cm); it does NOT free them (mpr_sas.c:1178-1199).
  2. mpr_iocfacts_allocate(sc, attaching=FALSE) β€” if the IOC Facts changed across the reset (reallocating, mpr.c:551-576) β†’ mpr_iocfacts_free() (mpr.c:631) kfrees the entire sc->commands[] array (mpr.c:791-797) while the sleeper is still blocked reacquiring mpr_lock.

The sleeper then reacquires the lock, lksleep() returns 0 and mpr_wait_command() returns success with *cmp pointing into freed memory. Only the function's own-timeout (EWOULDBLOCK) path checks MPR_FLAGS_REALLOCATED (mpr.c:3611-3617) β€” the woken-by-concurrent-reinit path does not, and mpr_request_polled()'s polling loop (mpr.c:3641, while ((cm->cm_flags & MPR_CM_FLAGS_COMPLETE) == 0)) keeps dereferencing cm after such a free as well (its REALLOCATED check at mpr.c:3668 only runs after its own 60 s timeout).

Callers then use the dangling cm: - mpr_user.c:701/1050 β€” rpl = cm->cm_reply (UAF read), mpr_free_command() (UAF write: re-inserts a pointer into the middle of the freed-and-reused slab onto sc->req_list β†’ the same mpr_command gets handed to two owners β†’ cross-command kernel memory corruption) - mpr_sas_lsi.c:1173 β€” callout_stop(&cm->cm_callout) (UAF write) - mpr_user.c:846 β€” mprsas_free_tm(sc, cm) (UAF write)

This is the mpr.c-side sibling of DF-3800 (filed on mpr_sas.c for the stale sassc->maxtargets/mprsas_realloc_targets aspect of the same realloc pass).

Trigger (needs hardware)

  1. SAS3 controller present; firmware whose IOC Facts differ across a diag reset (mid FW-upgrade, or a fault that comes back with different RequestCredit/MaxTargets/IOCCapabilities) so the reinit reallocates.
  2. Thread A: any synchronous command (MPRIO_MPR_COMMAND ioctl, config-page read, SATA ID, task management) β†’ asleep in mpr_wait_command().
  3. Thread B: force the reinit β€” e.g. unprivileged raw I/O against a wedged target until CAM timeout β†’ mprsas_tm_timeout/mpr_wait_command timeout β†’ mpr_reinit().

Build / run

cc -O -Wall -o poc poc_concurrent_reinit.c -lpthread
./poc            # opens /dev/mpr0, floods IOC Facts sync requests

Success criterion (on equipped hardware): kernel panic or corrupted command state (double-owned mpr_command) shortly after forcing the reinit; console shows "Reinitializing controller" + realloc path (MPR_FLAGS_REALLOCATED) with a concurrent mprwait sleeper.

Honest negative on this guest

The audit guest has no SAS3 HBA. kldstat -v | grep -w mpr shows 142 pci/mpr (driver compiled into X86_64_GENERIC), but /dev/mpr0 does not exist, so the race cannot be exercised at runtime. See guest_run.log, VERDICT.md.

Env

  • DragonFly dfbsd 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 (X86_64_GENERIC), x86_64
  • pci/mpr module linked into the kernel; no LSI/AVAGO PCI device; no /dev/mpr*; no hw.mpr sysctl tree
VERDICT.md
↓ download raw

DF-3813 β€” VERDICT

status: inconclusive (runtime) β€” the code defect is source-certain; the audit QEMU guest has no SAS3 controller, so the use-after-free cannot be fired there. Source-level chain verified line-by-line below.

Reproduced?

No. poc_concurrent_reinit.c compiles on the guest (cc -O -Wall … -lpthread, BUILD_OK) and runs, exiting:

DF-3813: open /dev/mpr0: No such file or directory β€” no SAS3 controller present, cannot exercise mpr_wait_command()

kldstat -v confirms the driver itself is present (142 pci/mpr); only the PCI device (and therefore /dev/mpr0) is absent. Neither proves nor disproves the defect at runtime β†’ inconclusive, per the no-hardware honesty rule.

Why the defect is real (source trace)

  1. mpr_wait_command() mpr.c:3577-3586 β€” sleeper path is lksleep(cm, &sc->mpr_lock, 0, "mprwait", timeout*hz); the lock is released while asleep, so a concurrent reinit holder runs to completion before the sleeper reacquires.
  2. Concurrent reinit (mpr_reinit() mpr.c:815, mpr_lock held): - mpr.c:853 mprsas_handle_reinit() β†’ mprsas_complete_all_commands() (mpr_sas.c:1155-1210): for a waiter command (cm_complete == NULL, MPR_CM_FLAGS_WAKEUP set at mpr.c:3568) it takes the else if (cm->cm_flags & MPR_CM_FLAGS_WAKEUP) { … wakeup(cm); } branch β€” the command is NOT freed and stays owned by the sleeper. - mpr.c:861 mpr_iocfacts_allocate(sc, FALSE); facts-changed test at mpr.c:551-576 sets reallocating; mpr.c:630-634 calls mpr_iocfacts_free(). - mpr_iocfacts_free() mpr.c:791-797 unconditionally kfree(sc->commands, M_MPR) β€” including the sleeper's still-owned cm, and bus_dmamap_destroy()s its map.
  3. Sleeper wakes: lksleep returns 0 β†’ mpr.c:3604 if (error == EWOULDBLOCK) is false β†’ no MPR_FLAGS_REALLOCATED check on this path β†’ return (error) with *cmp dangling.
  4. Caller corruption sinks (traced, all reachable with the freed cm): - mpr_user.c:701-716: rpl = cm->cm_reply, then RetFree: β†’ mpr_free_command(sc, cm) (mprvar.h:568-602) writes cm->cm_flags, TAILQ_INSERT_TAIL(&sc->req_list, cm, …) β€” inserting a pointer that (after the immediate same-size realloc at mpr.c:1522) aliases a live command of the new pool β†’ two owners β†’ kernel memory corruption. - mpr_sas_lsi.c:1173-1178: callout_stop(&cm->cm_callout) β€” UAF write. - mpr_user.c:846-868: mprsas_free_tm(sc, cm).
  5. Same hole in mpr_request_polled(): mpr.c:3641 loop condition reads cm->cm_flags every 50 ms; a concurrent reallocating reinit frees it mid-loop; the REALLOCATED check exists only on the 60 s timeout path (mpr.c:3662-3674).

Exploit chain (postulated, hardware required)

slab UAF β†’ mpr_free_command() on dangling cm inserts alias pointer into sc->req_list β†’ one struct mpr_command (with cm_req DMA frame, cm_dmamap, cm_callout, function pointers cm_complete) owned twice β†’ attacker-shaped SCSI/ioctl command contents into cm_req frame while the kernel believes a different operation owns it β†’ controlled corruption of command state; plausible path to arbitrary kernel write via cm_sge/cm_req reuse, not demonstrated here (no hardware).

Fix

fix.diff β€” generation counter (sc->realloc_gen) bumped on every reallocating pass; both waiters snapshot it and treat a change as "command is gone" (*cmp = NULL, ENXIO). Existing callers already handle *cmp == NULL.

Cross-references

  • DF-3800 β€” same realloc pass, stale sassc->maxtargets (filed on mpr_sas.c).
  • DF-3660 (isp) β€” analogous "background context outlives teardown" class.

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 validation impossible on this guest: no SAS3 controller, so neither baseline nor single-fix kernel can drive the mpr_wait_command sleeper path (fix.diff authored against the read-only sys/ tree, never applied).

fix.diff (generation counter in mprvar.h + checks at both wait sites); guest_run.log device-absence proof.
↓ fix.diffper-fix-DF-3813

Confirmed kernel references

Detail

Exploit chain

concurrent waiter (root ioctl / config-page / SATA-ID / TM) + facts-changing diag reset -> mpr_iocfacts_free() kfrees commands[] under sleeper -> sleeper returns 0 with dangling cm -> caller callout_stop/cm_reply read/mpr_free_command() on freed slab -> mpr_free_command re-inserts alias of a live new-pool command into req_list -> one mpr_command owned twice -> kernel memory corruption (controlled via subsequent SCSI/ioctl command contents); full chain requires hardware, not demonstrated on this guest.

Evidence (decisive lines)

VERDICT.md (step-by-step source trace); guest_run.log (BUILD_OK + 'open /dev/mpr0: No such file or directory' + kldstat '142 pci/mpr'); fix.diff (generation-counter fix).

PoC changes

Added missing #include to the seed sketch (first build failed, see build.log); otherwise the harness was structurally correct (ioctl 210 struct verified against mpr_ioctl.h:116-124, Function offset 3 per mpi2.h:855-866).

Verified recommended fix

Bump a realloc generation counter on every reallocating pass and have mpr_wait_command()/mpr_request_polled() treat a generation change as 'command freed' (*cmp = NULL, ENXIO).

Verdict

Source-certain use-after-free: mpr_wait_command()'s lksleep wakeup path (mpr.c:3578,3604) and mpr_request_polled()'s poll loop (mpr.c:3641) never re-check for a concurrent reallocating diag reset, so a sleeper woken by mprsas_complete_all_commands() (which does not free WAKEUP commands, mpr_sas.c:1178-1199) keeps using a cm that mpr_iocfacts_free() kfreed with the whole commands[] array (mpr.c:791-797). Only the own-timeout path checks MPR_FLAGS_REALLOCATED (mpr.c:3611). Runtime verdict inconclusive: the audit guest has the mpr driver compiled in (kldstat: '142 pci/mpr') but no SAS3 controller (/dev/mpr0 absent), so the race cannot be fired; the compiled+run PoC harness exits with the honest ENXIO negative (guest_run.log).