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)
PoC verification
Evidence pack
findings/poc/DF-3813 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
mprsas_handle_reinit()βmprsas_complete_all_commands()β for sleeper-owned commands (cm_complete==NULL, MPR_CM_FLAGS_WAKEUP set) it only callswakeup(cm); it does NOT free them (mpr_sas.c:1178-1199).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 entiresc->commands[]array (mpr.c:791-797) while the sleeper is still blocked reacquiringmpr_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)
- 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.
- Thread A: any synchronous command (
MPRIO_MPR_COMMANDioctl, config-page read, SATA ID, task management) β asleep inmpr_wait_command(). - Thread B: force the reinit β e.g. unprivileged raw I/O against a wedged
target until CAM timeout β
mprsas_tm_timeout/mpr_wait_commandtimeout β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/mprmodule linked into the kernel; no LSI/AVAGO PCI device; no/dev/mpr*; nohw.mprsysctl tree
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)
mpr_wait_command()mpr.c:3577-3586 β sleeper path islksleep(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.- Concurrent reinit (
mpr_reinit()mpr.c:815, mpr_lock held): - mpr.c:853mprsas_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 theelse if (cm->cm_flags & MPR_CM_FLAGS_WAKEUP) { β¦ wakeup(cm); }branch β the command is NOT freed and stays owned by the sleeper. - mpr.c:861mpr_iocfacts_allocate(sc, FALSE); facts-changed test at mpr.c:551-576 setsreallocating; mpr.c:630-634 callsmpr_iocfacts_free(). -mpr_iocfacts_free()mpr.c:791-797 unconditionallykfree(sc->commands, M_MPR)β including the sleeper's still-ownedcm, andbus_dmamap_destroy()s its map. - Sleeper wakes: lksleep returns 0 β mpr.c:3604
if (error == EWOULDBLOCK)is false β noMPR_FLAGS_REALLOCATEDcheck on this path βreturn (error)with*cmpdangling. - Caller corruption sinks (traced, all reachable with the freed cm):
- mpr_user.c:701-716:
rpl = cm->cm_reply, thenRetFree:βmpr_free_command(sc, cm)(mprvar.h:568-602) writescm->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). - Same hole in
mpr_request_polled(): mpr.c:3641 loop condition readscm->cm_flagsevery 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_testableFix 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.
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
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).
No comments yet.