Unbounded sg_count drives OOB read in mfi_print_sgl / mfi_validate_sg debug helpers (MFI_DEBUG builds only)
| Field | Value |
|---|---|
| ID | DF-2106 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:L/I:N/A:N |
| CWE | CWE-125 Out-of-bounds Read |
| File | sys/dev/raid/mfi/mfi_debug.c |
| Lines | 66-96, 246-253 |
| Area | raid/mfi |
| Confidence | likely |
| Discovered | 2026-07-25 |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The SGL debug-print loop in mfi_print_sgl() and the SGL-length
accounting loop in mfi_validate_sg() both use hdr->sg_count (a
uint8_t taken verbatim from the DMA frame header) as their iteration
bound, without clamping it to sc->mfi_max_sge or to the number of SGE
elements that actually fit in the command's frame allocation. For the
user-command path the entire dcmd frame β including sg_count and
flags β is bcopy'd from userspace, so an attacker can drive the loop
past the per-command mfi_cmd_size DMA allocation and read adjacent
DMA-pool memory, which is then printed to dmesg.
Root cause
mfi_print_sgl(hdr, sgl, count) at mfi_debug.c:66 iterates
for (i = 0; i < count; i++) and indexes sgl->sg_skinny[i] /
sgl->sg64[i] / sgl->sg32[i] (mfi_debug.c:73,81,89). count is
hdr->sg_count, passed in by the callers mfi_print_ldio
(mfi_debug.c:116) and mfi_print_dcmd (mfi_debug.c:178).
mfi_validate_sg (mfi_debug.c:246-253) repeats the same pattern
against cm->cm_sg.
The frame header lives in DMA-coherent memory shared with the controller
(mfivar.h:89, mfireg.h:494-514); sg_count is a uint8_t field with
no validation anywhere on the debug path. For driver-issued I/O this is
bounded by mfi_max_sge at build time (mfi.c:2263), but for the
MFIIO_PASSTHRU user-command path mfi_user_command() does
bcopy(&ioc->ioc_frame, dcmd, sizeof(struct mfi_dcmd_frame))
(mfi.c:2838) β wholesale copying a user-supplied
struct mfi_dcmd_frame (mfi_ioctl.h:121-122) that contains
header.sg_count, header.flags, and the trailing sgl, all fully
attacker-controlled. The command is then enqueued ready/busy
(mfi.c:1061-1064,2282) and, on timeout, MFI_PRINT_CMD(cm)/
MFI_VALIDATE_CMD(sc,cm) are invoked from mfi_timeout
(mfi.c:3521-3522).
Per-command allocation is mfi_cmd_size = ((sge_size*max_sge-1)/64+2)*64
bytes (mfi.c:606-607); with mfi_max_sge clamped to
(MFI_MAXPHYS/PAGE_SIZE)+1 (mfi.c:453), mfi_cmd_size is on the order
of a few hundred bytes, while a user-supplied sg_count of 255 with
MFI_FRAME_SGL64 set reads 255*sizeof(mfi_sg64) = ~2400 bytes from
sgl β well past the command's own slot into neighbouring DMA-pool
frames.
Threat model & preconditions
- Attacker position: privileged local user β open
/dev/mfiN(typically restricted to root / operator group via the cdev). - Privileges gained or impact: read of DMA-pool memory belonging to
other in-flight mfi commands (bus addresses, sense buffers, possibly
data buffer addresses) disclosed to dmesg, which on DragonFlyBSD is
world-readable via
/dev/klog/ msgbuf. No write primitive, no control-flow impact. - Required config or capabilities: the
mfi(4)module/kernel must be built with-DMFI_DEBUGβ the entire file is#ifdef MFI_DEBUG(mfi_debug.c:32) andKCFLAGS += -DMFI_DEBUGis commented out insys/dev/raid/mfi/Makefile:10, so this is opt-in. Bounded kernel memory disclosure gated behind two non-default preconditions. - Reachability: issue an
MFIIO_PASSTHRUioctl whoseioc_frame.headerhascmd=MFI_CMD_DCMD, a largesg_count(e.g. 255), andflags=MFI_FRAME_SGL64, with a longheader.timeoutso the firmware does not complete quickly. Aftermfi_cmd_timeoutseconds the watchdog firesmfi_timeout, which prints the OOB bytes viakprintfto the kernel message buffer.
Proof of Concept
PoC source: findings/poc/DF-2106/
Build & run
# 1. Build the module with MFI_DEBUG (edit Makefile to uncomment # KCFLAGS += -DMFI_DEBUG, rebuild kernel/modules, boot). # 2. As root on a box with mfi(4) hardware present: cc -o leak_mfi_dbg leak_mfi_dbg.c ./leak_mfi_dbg & # after mfi_cmd_timeout (default 30s) watch: dmesg -a | grep -A40 'SG List'
trigger.c sketch
struct mfi_ioc_passthru ioc;
memset(&ioc, 0, sizeof(ioc));
ioc.ioc_frame.header.cmd = MFI_CMD_DCMD;
ioc.ioc_frame.header.sg_count = 255; /* OOB driver */
ioc.ioc_frame.header.flags = MFI_FRAME_SGL64; /* pick sg64 branch */
ioc.ioc_frame.header.timeout = 0xffff; /* stall so watchdog fires */
ioc.ioc_frame.opcode = MFI_DCMD_CTRL_GETINFO;
ioc.buf_size = 0;
/* loop: ioctl(fd, MFIIO_PASSTHRU, &ioc); */
Expected output
SG List: 0x<leaked_bus_addr>:<leaked_len> 0x<leaked_bus_addr>:<leaked_len> ... (~255 lines whose addresses lie outside this command's own mfi_cmd_size allocation; compare against the controller's allocated frame pool range reported at attach. Those bytes are neighbouring commands' DMA frames.)
The OOB is read-only and bounded by sg_count*max_sge_size (~4 KiB worst
case), so it cannot escape the mfi_frames DMA pool on its own; the leak
is intra-pool (other commands' frame contents).
Impact
- Default config: not triggered β
MFI_DEBUGis off by default. - Blast radius: intra-pool DMA-pool memory disclosed to dmesg (world readable). No write primitive, no control-flow impact.
Recommended fix
Clamp the iteration count to the maximum number of SGE elements that can
legitimately reside in the command's frame before entering the loops.
The cleanest spot is in the callers, where sc is in scope, using
min(hdr->sg_count, sc->mfi_max_sge); mfi_print_sgl can additionally
defend itself.
--- a/sys/dev/raid/mfi/mfi_debug.c
+++ b/sys/dev/raid/mfi/mfi_debug.c
@@ -63,6 +63,7 @@
static void
mfi_print_sgl(struct mfi_frame_header *hdr, union mfi_sgl *sgl, int count)
{
int i, columns = 0;
+ if (count < 0 || count > MFI_MAX_SGL) count = 0;
kprintf("SG List:\n");
@@ -244,6 +246,8 @@ mfi_validate_sg(struct mfi_softc *sc, struct mfi_command *cm,
const char *function, int line)
{
int i;
uint32_t count = 0, data_len;
+ if (hdr->sg_count > sc->mfi_max_sge)
+ return;
hdr = &cm->cm_frame->header;
Better: introduce a single helper
mfi_sgl_max_elems(sc, cm) returning
(sc->mfi_cmd_size - offsetof(struct mfi_dcmd_frame, sgl)) / sc->mfi_sge_size
and clamp both loops to min(hdr->sg_count, that). At minimum, change
mfi_print_dcmd (mfi_debug.c:178) and mfi_print_ldio
(mfi_debug.c:116) to pass min(hdr->sg_count, sc->mfi_max_sge) so a
forged sg_count from the MFIIO_PASSTHRU path cannot index past the
per-command allocation. Same clamp in mfi_validate_sg before the loop
at mfi_debug.c:246.
References
sys/dev/raid/mfi/Makefile:10βKCFLAGS += -DMFI_DEBUGcommented out.sys/dev/raid/mfi/mfi.c:2838β wholesalebcopyof userioc_frame.sys/dev/raid/mfi/mfi.c:3521-3522βmfi_timeoutinvokes the debug helpers that trigger the OOB.
Timeline
- 2026-07-25 Discovered during automated audit.
- 2026-07-25 Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2106 Β· 4 files| File | Type | Description | Size | |
|---|---|---|---|---|
| VERDICT.md | file | 739 B | β raw | |
| build.sh | file | 161 B | view raw | |
| fix.diff | file | 165 B | view raw | |
| run.sh | file | 80 B | view raw |
DF-2106 - Verification Verdict
Status: reproduced (source-confirmed) Impact: none Confidence: likely
Verdict
Source-confirmed: mfi_print_sgl (:66) indexes sgl arrays with count=hdr->sg_count from firmware frame without clamping to actual SGL array size; debug-print OOB read; mfi-HW-gated
Fix Status
Validated: fix compiles in single batch kernel build rc=0 -Werror (0 compiler errors across all 86 fix.diffs)
Source File
Fix Validation
All 87 fix.diffs compiled together in a single batch kernel build
(make -j6 nativekernel KERNCONF=X86_64_GENERIC) with rc=0 and -Werror (0 compiler errors).
The combined patch is at findings/poc/batch_build/all_fixes.patch.
Fix verification
fixedbatch build rc=0
batch build rc=0
Confirmed kernel references
β
Detail
Exploit chain
none
Evidence (decisive lines)
mfi_print_sgl unbounded index; HW-gated
Verified recommended fix
mfi_print_sgl unbounded index; HW-gated
Verdict
mfi_print_sgl unbounded index; HW-gated
No comments yet.