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

mlx_pci_attach() double mlx_free() on mlx_attach() failure β€” kernel heap double-free/UAF (mlx_enq2 kfree'd twice, DMA tags/s/g/IRQ resources released twice)

Summary

mlx_attach tears itself down on all 10 error exits but the glue frees again - mlx_free NULLs nothing, so the second pass re-frees re-ownable heap (enq2 kfree, DMA tags, sg, IRQ/mem resources). VERIFIED on guest with the kernel's real mlx_free(): the exact two-call sequence panics 'already free!' through bus_dma_tag_destroy<-mlx_free, and a kmalloc between frees took ownership of the enq2 block. Hardware-gated boot/kldload trigger. Delete-the-redundant-free fix validated on a rebuilt+booted kernel (3->2 call sites, fixed sequence clean).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5141 Β· 18 files
FileTypeDescriptionSize
README.md β€” 4.8 KB ↓ raw
VERDICT.md β€” 8.7 KB ↓ raw
df5141.c β€” 5.5 KB view raw
df5141_fixed.c β€” 5.3 KB view raw
Makefile β€” 264 B ↓ download
build.sh β€” 166 B view raw
run.sh β€” 122 B view raw
build.log β€” 5.7 KB view raw
run.log β€” 1.8 KB view raw
panic.txt β€” 877 B view raw
env.txt β€” 1.2 KB view raw
guest_raw_logs.txt β€” 12.2 KB view raw
fix.diff β€” 323 B view raw
fix_build.log β€” 1.7 KB view raw
fix_poc_build.log β€” 5.8 KB view raw
fix_run.log β€” 505 B view raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 5.8 KB view raw

DF-5141 β€” mlx_pci_attach() double mlx_free() on mlx_attach() failure

File: sys/dev/raid/mlx/mlx_pci.c:202-206 Class: kernel heap double-free / use-after-free (driver error-path unwind) Verdict: REPRODUCED (kernel panic, allocator double-free detection) β€” see verdict.json Fix: VALIDATED on a patched kernel build β€” see fix.diff, fix_run.log

The bug

mlx_attach() (sys/dev/raid/mlx/mlx.c) calls mlx_free(sc) internally on every error return (mlx.c:308, 333, 348, 356, 373, 383, 397, 410, 443, 453). mlx_free() is a full teardown β€” it releases/kfrees the DMA tags, the s/g table, the interrupt, the IRQ resource, the register window and mlx_enq2, and it does not NULL any softc pointers. The PCI glue then calls mlx_free(sc) a second time on the error path:

    error = mlx_attach(sc);
    if (error != 0) {
        mlx_free(sc);        /* <-- mlx_pci.c:204: SECOND full teardown */
        return(error);
    }

The second mlx_free() re-executes kfree(sc->mlx_enq2), bus_dma_tag_destroy(sc->mlx_parent_dmat) / mlx_buffer_dmat, bus_dmamem_free(...), bus_teardown_intr(...), bus_release_resource(... IRQ ...) and bus_release_resource(... mlx_mem ...) on objects that are already freed β€” and, in the window between the two calls, already re-ownable by any other kernel allocation.

Trigger (hardware-gated)

Any mlx_attach() failure after those resources exist: controller initialisation handshake failure (mlx.c:333), IRQ allocation failure (:348), bus_setup_intr failure (:356), buffer DMA tag failure (:373), s/g mapping failure (:383/:453), ENQUIRY2 failure (:397), ENQUIRY_OLD failure (:410). Requires a Mylex DAC960-family PCI controller (0x1069:0x0001/0002/0010 or 0x1011:1065 subdev 0x1069:0x0020) to be present β€” the QEMU guest has none, so the entry path cannot fire there; the PoC below drives the consequence with the running kernel's real mlx_free().

PoC (what is in this directory)

  • df5141.c β€” KLD harness. Builds a softc in exactly the state mlx_attach() leaves behind on a failing error return (callout_init'd timeout, initialised queues, real parent DMA tag created exactly as mlx_pci.c:185-192 does, mlx_enq2 allocated as mlx.c:395 does, mlx_dev = the real pci0 device_t), then performs the exact bug sequence: mlx_free() #1 (= the mlx.c:453 error path), re-allocates the freed block the way any concurrent kernel allocation would, and mlx_free() #2 (= mlx_pci.c:204).
  • df5141_fixed.c β€” same harness with the second call removed, i.e. the sequence the patched mlx_pci_attach() performs. Used for fix validation.
  • Makefile β€” out-of-tree kmod makefile (headers copied next to it).
  • build.sh / run.sh β€” exact commands.
  • build.log β€” module build output (guest, -Werror clean).
  • run.log / panic.txt β€” serial-console capture of the decisive run.
  • env.txt β€” guest kernel / PCI state.
  • fix.diff β€” one-line caller-side fix (remove the redundant mlx_free).
  • fix_build.log β€” patched-kernel build (see provenance note inside).
  • fix_run.log β€” patched-kernel validation run (clean).

Result (stock kernel)

DF5141: mlx_free #1 (mlx.c:453 path) ... returned (clean teardown)
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!
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")   -> db>
  • v1 == enq2-block proves the freed block is immediately re-allocatable between the two mlx_free() calls β€” the second free is a genuine use-after-free/double-free of live-owned kernel heap memory, not a benign re-free.
  • The allocator catches the first double-kfree it reaches (the parent DMA tag) and panics. In the same sequence, kfree(mlx_enq2) (M_DEVBUF, 128 bytes) is also executed twice β€” on a system where the tag memory was reused between the calls this is silent slab corruption instead of a detected panic.

Result (patched kernel, fix.diff applied)

DF5141F: mlx_free #1 (only) ... returned (clean teardown)
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

Binary-level confirmation: the stock /boot/kernel.old mlx_pci_attach contains 3 call mlx_free sites; the patched mlx_pci.o contains 2 (only the two legitimate pre-mlx_attach failures at mlx_pci.c:176/:195).

Reproduce

./build.sh        # inside the DragonFly guest (root)
./run.sh          # kldload; watch the serial console / dmesg for DF5141: + panic
VERDICT.md
↓ download raw

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (one line: delete mlx_free(sc) from the mlx_attach()-failure branch) applied to the guest's pristine /usr/src, kernel rebuilt with make -j6 nativekernel (RC=0, -Werror clean), installed and booted (kernel #1 Sep 30 07:26:18). Baseline (stock #0): bug-sequence run panics with double-free detection. Patched: the fixed-caller sequence (df5141f.ko, identical harness minus the removed second call) runs clean β€” no panic, no aliasing, module loads AND unloads, guest stays up. Caller-side fix means the harness's emulation of the caller is updated to match the patched caller; corroborated objectively: patched mlx_pci.o contains 2 mlx_free call relocations vs 3 in the stock kernel's mlx_pci_attach, so the shipped driver can no longer perform the double teardown.

fix.diff; fix_run.log (DF5141F RESULT=CLEAN lines); fix_build.log (build RC=0, kernel #1 artifacts, objdump call counts 3->2); fix_poc_build.log (df5141f.ko build); env.txt (both unames).
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Wed Sep 30 07:26:18 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

n/a for user-driven exploitation (trigger is driver-internal error handling at boot/module-load on Mylex hardware, not attacker-controlled input). Demonstrated primitive chain: mlx_attach() error -> mlx_free() #1 (clean teardown; freed chunks return to the live allocator) -> [any kernel allocation may take ownership, shown: kmalloc(128,M_DEVBUF) returned the just-freed mlx_enq2 block] -> mlx_pci.c:204 mlx_free() #2 -> kfree/bus_dma_tag_destroy on already-freed/re-owned memory -> allocator double-free detection panic (or, if the chunks were reused, silent slab corruption).

Evidence (decisive lines)

run.log/panic.txt: panic text + stack through the kernel's mlx_free; the 'v1=0xfffff800897a5000 owns the freed block: YES' line proves re-ownership between the frees (enq2=0xfffff800897a5000 from the first DF5141 line). fix_run.log: patched-kernel run of df5141f.ko (fixed single-free sequence) completes and unloads cleanly. fix_build.log: patched mlx_pci.o has 2 mlx_free call sites vs 3 in stock /boot/kernel.old mlx_pci_attach. build.log: -Werror-clean module build. env.txt: guest kernels (stock #0 Jul 2 / patched #1 Sep 30), no Mylex PCI device on guest, source md5 sync.

PoC changes

No seed existed (orchestrator reserved fresh IDs). Harness authored from scratch: KLD module calling the kernel's real mlx_free(); had to (1) copy mlx_compat/io/var/reg headers next to the module because CFLAGS include paths are rebuilt by bsd.kmod.mk, (2) add generated device_if.h/bus_if.h/pci_if.h to SRCS, (3) include sys/disk.h+sys/devicestat.h for mlxvar.h's mlxd_softc, (4) drop MODULE_DEPEND(df5141,mlx,...) β€” in-kernel mlx registers as pci/mlx so the KLD linker refused the dependency, (5) use a real pci0 device_t for mlx_dev, (6) transfer via openssl base64 through the ssh-stdin shell (guest /bin/sh truncates >1KB lines).

Verified recommended fix

Remove the redundant mlx_free(sc) at sys/dev/raid/mlx/mlx_pci.c:204 (mlx_attach() already tears down fully on every error return).

Verdict

mlx_pci_attach() calls mlx_free(sc) a second time on the mlx_attach()-failure path (mlx_pci.c:204) even though mlx_attach() already performs a complete teardown on every one of its 10 error exits (mlx.c:308-453); mlx_free() NULLs no pointers, so the second call re-executes kfree(mlx_enq2), double bus_dma_tag_destroy, double bus_dmamem_free, double IRQ/register-window release on freed, re-ownable objects. Proven on the guest with the running kernel's real mlx_free(): the exact two-call sequence panics with 'memory chunk ... is already free!' (stack: chunk_mark_free <- _kfree <- bus_dma_tag_destroy <- mlx_free), and an ordinary kmalloc between the two frees took ownership of the mlx_enq2 block (v1 == enq2 pointer), demonstrating a true double-free/UAF of live-owned kernel heap rather than a benign re-free. Entry path is hardware-gated (needs a Mylex DAC960 PCI controller plus an attach failure: IRQ alloc/setup failure, DMA tag failure, s/g map failure, ENQUIRY2/ENQUIRY_OLD failure, firmware handshake error) β€” no unprivileged trigger exists, so uid=0 escalation is not applicable; realistic ceiling on affected hardware is boot-time kernel heap corruption/panic.