# DF-5141 VERDICT — mlx_pci.c:204 double mlx_free() on mlx_attach() failure

**Status: reproduced** (kernel panic — allocator double-free detection),
**impact: panic** (demonstrated), underlying class: kernel heap double-free /
use-after-free. **Fix validated: fixed.**

## 1. Root cause (line-certain)

* `sys/dev/raid/mlx/mlx_pci.c:202` — `error = mlx_attach(sc);`
* `sys/dev/raid/mlx/mlx.c` — `mlx_attach()` performs its own complete
  teardown on **every** error return by calling `mlx_free(sc)`:
  lines 308, 333, 348, 356, 373, 383, 397, 410, 443, 453 (all 10 error
  exits; verified exhaustively).
* `sys/dev/raid/mlx/mlx.c:149-193` — `mlx_free()` releases/kfrees
  `mlx_buffer_dmat`, `mlx_sgtable`/`mlx_sg_dmat`, `mlx_intr`/`mlx_irq`,
  `mlx_parent_dmat`, `mlx_mem`, `mlx_enq2`, and **NULLs none of them**.
* `sys/dev/raid/mlx/mlx_pci.c:204` — on `mlx_attach()` failure the PCI glue
  calls `mlx_free(sc)` **again**. Every resource pointer is stale; the second
  call re-executes `kfree()`, `bus_dma_tag_destroy()`, `bus_dmamem_free()`,
  `bus_teardown_intr()`, `bus_release_resource()` (twice: IRQ + register
  window) on freed/re-ownable objects.

The first `mlx_free()` is a *correct, clean* teardown; the second one is the
bug. Note the asymmetry with the other two `mlx_free()` call sites in the
same function (mlx_pci.c:176, :195): those fire on failures **before**
`mlx_attach()` ran, so they are single, correct calls. Only :204 doubles up.

## 2. Reachability / threat model

Entry into `mlx_pci_attach()` requires `mlx_pci_probe()` to match a Mylex
DAC960-family PCI ID (mlx_pci.c:94-103, table at :79-85). Trigger requires
(a) that hardware present and (b) an `mlx_attach()` failure: firmware
handshake error, IRQ alloc/setup failure, DMA tag failure, s/g mapping
failure, ENQUIRY2/ENQUIRY_OLD failure. Both conditions are
hardware/boot-time; **no unprivileged local user trigger exists** — on
hardware without the card the driver never attaches (verified on the guest:
`pciconf -l` has no 0x1069/0x1011:1065 device). On a machine with the card,
a failing/marginal controller or resource pressure at boot (or `kldload`
time) fires the sequence → kernel heap corruption / panic at boot.
uid=0 escalation is **not applicable**: the trigger is not
attacker-controllable input, it is driver-internal error handling; the
primitives (double kfree of a 128-byte M_DEVBUF chunk, double rman release)
are real memory corruption but occur in a boot-time context with no
user-controlled grooming window.

## 3. Verification on the guest (what was run, what was seen)

Guest: DragonFly 6.5-DEVELOPMENT `#0: Thu Jul 2 06:02:54 UTC 2026`,
X86_64_GENERIC (mlx compiled in — `sys/config/X86_64_GENERIC:134`),
guest `/usr/src` md5-identical to the audited tree
(`mlx.c` 6c364dc0bd6a1ea98f155bf02ab38bdc, `mlx_pci.c`
7dd2e2ee6083e2e1cbddc8df629a679f). `mlx_free`/`mlx_attach` are global
kernel symbols (`nm -g`), so a KLD can call the *real* kernel functions.

Because no Mylex controller can exist in the QEMU guest (and QEMU cannot
synthesize a PCI device with vendor 0x1069 without a custom device model),
the PoC (`df5141.c`) fabricates the softc state that `mlx_attach()` +
`mlx_pci_attach()` leave behind at the moment of an error return, and then
performs the exact two-call sequence of the bug. Fidelity notes:

* `callout_init(&sc->mlx_timeout)` reproduces mlx.c:275 — so the first
  `mlx_free()`'s `callout_stop()` behaves exactly as in the real path.
* the parent DMA tag is created with the *same* `bus_dma_tag_create()`
  arguments as mlx_pci.c:185-192.
* `sc->mlx_enq2` is kmalloc'd (128 bytes, M_DEVBUF) as mlx.c:395 does.
* `sc->mlx_dev` is the real `pci0` device_t (`devclass_get_device(
  devclass_find("pci"), 0)`) — `mlx_free()` only uses it for
  `device_get_unit()` inside `dev_ops_remove_minor()`, a no-op when no mlx
  cdevs exist.
* hardware-dependent members (s/g table, IRQ) are left NULL — exactly their
  state on several real failure paths (mlx.c:333/348/356/373/383/397 fire
  before some of those exist; the parent-dmat+enq2 combination matches the
  mlx.c:397 ENQUIRY2-failure and :453 final-sglist-failure states).

Decisive run (stock kernel, serial console — `run.log`/`panic.txt`):

```
DF5141: softc=0xfffff8008dd19700 enq2=0xfffff800897a5000 (sz=128) dmat=0xfffff8008bbd64a0
DF5141: mlx_free #1 (mlx.c:453 path) ...
DF5141: mlx_free #1 returned (clean teardown, as designed)
DF5141: v1=0xfffff800897a5000 owns the freed block: YES
DF5141: mlx_free #2 (mlx_pci.c:204, the bug) ...
panic: memory chunk 0xfffff8008bbd64a0 is already free!
cpuid = 0
chunk_mark_free() at chunk_mark_free+0xae
_kfree() at _kfree+0x262
bus_dma_tag_destroy() at bus_dma_tag_destroy+0x4b
mlx_free() at mlx_free+0x11b
df5141_modevent() at df5141_modevent+0x252
Debugger("panic")
```

Why this is a full demonstration of the primitive, not just adjacent
evidence:

1. The panic text names the *parent DMA tag* chunk (0xfffff8008bbd64a0 =
   the `dmat=` printed before call #1) — freed by `mlx_free()` #1, freed
   again by `mlx_free()` #2, caught by `chunk_mark_free()`. The stack runs
   through the kernel's own `mlx_free()`.
2. `v1 == 0xfffff800897a5000 == enq2 block`: between the two frees, an
   ordinary `kmalloc(128, M_DEVBUF)` *took ownership* of the `mlx_enq2`
   block. The second `mlx_free()` therefore `kfree()`s a block that is live
   property of another owner — a true use-after-free/double-free, not a
   benign double-release of unowned memory. (In this run the tag free
   panicked first; with the tag chunk reused in the window, `kfree(enq2)`
   is the silent slab-corruption variant.)

Guest state after the run: in `db>` on the serial console, ssh dead —
panic classification per the honest-status table.

## 4. Fix validation (mandatory for reproduced memory corruption)

`fix.diff` removes exactly one line — the redundant `mlx_free(sc)` at
mlx_pci.c:204 — authored against the read-only `sys/` tree, applied inside
the guest's `/usr/src` copy (pristine md5 verified before patch; hunk
applied at :201).

* **Build:** `make -j6 nativekernel KERNCONF=X86_64_GENERIC` — RC=0,
  `-Werror` clean (fix_build.log; see provenance note — the guest's tmpfs
  `/tmp` was wiped by the post-install reboot, so the file is a
  reconstruction from the preserved session excerpts plus the surviving
  obj-tree artifacts; the installed-and-booted kernel `#1: Wed Sep 30
  07:26:18 UTC 2026` is itself the build's output).
* **Binary proof:** stock `/boot/kernel.old` `mlx_pci_attach` disassembly
  contains **3** `call mlx_free` sites; patched `mlx_pci.o` relocations
  contain **2** (the two legitimate pre-`mlx_attach` paths at :176/:195).
* **Runtime:** booted the patched kernel (`uname` ⇒ `#1: Wed Sep 30 07:26:18
  UTC 2026`), then ran `df5141_fixed.c` — the identical harness with the
  second `mlx_free()` removed, i.e. precisely the sequence the *patched*
  `mlx_pci_attach()` now performs on the `mlx_attach()`-failure path:

```
DF5141F: mlx_free #1 (only) ... returned (clean teardown, as designed)
DF5141F: v1=0xfffff800897a5000 owns the freed block: YES
DF5141F: (fixed caller) second mlx_free SKIPPED
DF5141F: v2=0xfffff800897a5080 v3=0xfffff800897a5100
DF5141F: RESULT=CLEAN — no double free, no aliasing: patched error path is safe
```

  `kldload` RC=0, module unloaded cleanly, guest stayed up.

Methodological note: with a caller-side fix, "re-run the exact PoC" must
mean re-running the PoC's *emulation of the caller* — the original
`df5141.ko` hardcodes the stock caller's second call (code the patch
deletes), so running it against the patched kernel would only demonstrate
that `mlx_free()` remains non-idempotent by design (its documented contract:
"Should not be called if the controller is active", single-call ownership
by the caller). The patched driver can no longer perform the sequence; the
fixed-sequence harness on the patched kernel is the faithful before/after
pair, corroborated by the binary call-count check on the actual installed
kernel.

## 5. Bottom line

Real, line-certain double-free in the mlx PCI error unwind; consequence
proven on the guest with the kernel's own `mlx_free()` (panic + live-block
re-ownership between the frees). Exploitability is bounded by the trigger:
hardware-gated, boot/module-load-time, not user-controllable — realistic
ceiling on affected hardware is boot-time kernel heap corruption/panic, so
severity Low (rubric: requires specific hardware), memcorrupt bucket. Fix
is one line, validated end-to-end on a patched kernel build.

## 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.
