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

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) and KCFLAGS += -DMFI_DEBUG is commented out in sys/dev/raid/mfi/Makefile:10, so this is opt-in. Bounded kernel memory disclosure gated behind two non-default preconditions.
  • Reachability: issue an MFIIO_PASSTHRU ioctl whose ioc_frame.header has cmd=MFI_CMD_DCMD, a large sg_count (e.g. 255), and flags=MFI_FRAME_SGL64, with a long header.timeout so the firmware does not complete quickly. After mfi_cmd_timeout seconds the watchdog fires mfi_timeout, which prints the OOB bytes via kprintf to 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_DEBUG is off by default.
  • Blast radius: intra-pool DMA-pool memory disclosed to dmesg (world readable). No write primitive, no control-flow impact.

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

Timeline

  • 2026-07-25 Discovered during automated audit.
  • 2026-07-25 Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2106 Β· 4 files
FileTypeDescriptionSize
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
VERDICT.md file
↓ download 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

sys/dev/raid/mfi/mfi_debug.c

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

fixed
baseline reproduced→ patch + rebuild →patched clean

batch build rc=0

batch build rc=0
↓ fix.diffcombined 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