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

NVMe-encapsulated pass-thru: unvalidated u32 DataSize drives PRP walk past the 8MiB arena (deterministic OOB write); kmalloc result unchecked

Summary

mpr_user_pass_thru passes raw user DataSize/DataOutSize into mpr_build_nvme_prp whenever the user-controlled Function byte is 0x33; the PRP build's while(length) walk writes 8-byte entries linearly through one contiguous 8MiB arena with no end check, BEFORE any firmware round-trip. DataSize=0xFFFFFFFF needs 1,050,625 entries -> 16,416 bytes OOB even from chunk 0, ~8.2MB when the first free chunk is near the end. cm_data=kmalloc(MAX(...)) result never checked - a failed 4GB kmalloc leaves cm_data=NULL and the walk still runs. Single ioctl, no FW cooperation needed. Arithmetic replicated verbatim on guest. Validate-against-maxio + NULL check + arena guard fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4009 Β· 5 files
FileTypeDescriptionSize
prp_overwalk.c replication-source verbatim PRP-walk / arena-size arithmetic 3.2 KB view raw
replication_run.log run-log OOB distances across DataSize x start_chunk 5.7 KB view raw
build.log build-log guest cc output + uname 150 B view raw
README.md readme finding summary + trigger + fix sketch 4.1 KB ↓ raw
verdict.json verdict machine verdict 3.3 KB view raw
README.md readme finding summary + trigger + fix sketch
↓ download raw

DF-4009 β€” mpr NVMe-encapsulated pass-thru: unbounded DataSize drives PRP walk past the arena (OOB write)

What

mpr_user_pass_thru() (sys/dev/raid/mpr/mpr_user.c:740) takes the user's DataSize/DataOutSize (raw uint32_t from mpr_pass_thru_t) and, when the request's Function byte is MPI2_FUNCTION_NVME_ENCAPSULATED (0x33 β€” a byte the user fully controls in the request header, checked at mpr_user.c:945), calls:

mpr_build_nvme_prp(sc, cm, nvme_encap_request, cm->cm_data,
    data->DataSize, data->DataOutSize);          /* mpr_user.c:970 */

cm->cm_data was allocated as kmalloc(MAX(DataSize, DataOutSize)) β€” with the result never checked (mpr_user.c:904-914): a size the kernel cannot satisfy leaves cm->cm_data == NULL, and the PRP build still runs.

mpr_build_nvme_prp() (sys/dev/raid/mpr/mpr.c:2680-2864) translates the length into a linear walk of 8-byte PRP entries through one contiguous arena and never bounds-checks the walk against the arena end β€” it relies entirely on length being sane:

while (length) {                                  /* mpr.c:2757 */
        ... *prp_entry = htole64(paddr); prp_entry++; prp_entry_phys++; ...
        if (entry_len > length) length = 0; else length -= entry_len;
}

The arena is PAGE_SIZE * ((maxio/PAGE_SIZE)/511 + 1) * NVME_QDEPTH (mpr.c:1593-1598) with NVME_QDEPTH = MPR_REQ_FRAMES = 2048 (mprvar.h:40,59) β€” 8 MiB for any maxio ≀ ~2 MB. The number of entries needed for length L is ceil(L/4096) + ceil(L/4096)/511 (one extra list pointer per full PRP page, mpr.c:2765-2783). For L = 0xFFFFFFFF that is 1,050,625 entries = 8,405,000 bytes > the 8,388,608-byte arena β€” the walk runs 16,416 bytes past the end even from chunk 0, and since the walk may begin at an arbitrary free chunk (mpr_alloc_prp_page, mpr.c:2716), starting from a higher chunk index overruns proportionally more β€” up to ~8.2 MB of OOB write when the first allocated chunk is near the end of the arena (see replication_run.log). The walk happens before mpr_wait_command() (mpr_user.c:1050) β€” no firmware cooperation is needed; the only gate is the driver being attached (arena allocated at attach).

Written values are semi-controlled: paddr starts at (bus_addr_t)(uintptr_t)data (mpr.c:2750) β€” the kernel virtual address of cm->cm_data (or 0 when the kmalloc failed / was skipped) β€” and increments by 4096 per entry. Deterministic, groomable length; 8-byte aligned writes.

Trigger (privileged surface)

/dev/mprN 0640 root:operator (mpr_user.c:205); O_RDONLY open + ioctl suffices (ioctls not gated on open mode β€” demonstrated live in the DF-1326 replica run on the same node mode). One ioctl(MPTIOCTL_PASS_THRU, {Function=0x33, DataDirection=READ, DataSize= 0xFFFFFFFF, PtrRequest->...}) from an operator-group user. Note cm->cm_length = MAX(DataSize, DataOutSize) with DataSize=0xFFFFFFFF β†’ kmalloc of 4 GB fails fast on this class of machine β†’ cm->cm_data=NULL β†’ paddr=0 β†’ the walk still writes 1M+ entries. (A smaller DataSize with a successful kmalloc writes KVA-derived values instead.)

Replication (guest, userspace arithmetic-level)

prp_overwalk.c replicates the walk/arena arithmetic verbatim (replication_run.log on 6.5-DEVELOPMENT):

maxio=128KB -> pages_required=1 arena=8388608 bytes (1048576 entries)
  DataSize=4294967295 start_chunk=0    walk=8405024 in_arena=8388608 OOB=16416   <== OOB WRITE
  DataSize=4294967295 start_chunk=512  ...                                 OOB=2113568 <== OOB WRITE
  DataSize=4294967295 start_chunk=2000 ...                                 OOB=8208416 <== OOB WRITE
  DataSize=1073741824 start_chunk=1536 ...                                 OOB=4104    <== OOB WRITE

Hardware gate & fix

Live trigger requires an mpr-attached controller with the NVMe-encapsulated path compiled in (any SAS3 with NVMe support; the build of the PRP list needs no FW round-trip). Fix: validate DataSize/DataOutSize against sc->maxio before building (reject > maxio), check the kmalloc result, and add an arena-end guard in the walk (prp_entry vs prp_page + pages_available).

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

fix.diff not yet authored (see README.md fix sketch)

per-fix-DF-4009

Confirmed kernel references

Detail

Exploit chain

operator-group ioctl user -> MPTIOCTL_PASS_THRU {Function=0x33, DataDirection=READ, DataSize=0xFFFFFFFF} -> kmalloc(4GB)=NULL unchecked (mpr_user.c:908) -> mpr_build_nvme_prp(..., NULL, 0xFFFFFFFF, 0) at mpr_user.c:970 -> 1M+ entry linear write marching past the 8MiB DMA arena into adjacent kernel memory with attacker-chosen length. Requires an mpr-attached controller; the OOB write itself needs no FW cooperation (occurs before mpr_wait_command).

Evidence (decisive lines)

replication_run.log (OOB distances for DataSize x start_chunk incl. 16416 bytes from chunk 0 and 8208416 bytes from chunk 2000), prp_overwalk.c (verbatim walk/arena arithmetic), VERDICT.md

PoC changes

n/a (new finding; replication authored during pass 2)

Verified recommended fix

Validate DataSize/DataOutSize <= sc->maxio before the NVMe branch (mpr_user.c:945), check the kmalloc result at :908, and bound the PRP walk against the arena end in mpr_build_nvme_prp().

Verdict

Mechanism certain at source level, hardware-gated live: MPTIOCTL_PASS_THRU with Function=0x33 (NVME_ENCAPSULATED, user-controlled byte) feeds raw user u32 DataSize/DataOutSize into mpr_build_nvme_prp() whose while(length) walk (mpr.c:2757) writes 8-byte PRP entries linearly through the 8 MiB contiguous PRP arena with no end check, before any firmware interaction. For DataSize=0xFFFFFFFF the walk needs 1,050,625 entries (8,405,000 bytes) vs a 1,048,576-entry arena -> 16,416 bytes of OOB write even from chunk 0, scaling to ~8.2 MB when the first allocated PRP chunk sits near the arena end; kmalloc failure for the 4 GB cm_data is NOT checked (mpr_user.c:908) and does not gate the walk (paddr=0 variant). Written values are semi-controlled (0- or KVA-based incrementing dwords in 8-byte steps). Not live-reproduced: no mpr controller on the guest; the walk/arena arithmetic is replicated verbatim on-guest (prp_overwalk).