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

mpipe_done() tears down the malloc pipeline with no synchronization against in-flight users β€” its only teardown caller (dm-crypt) destroys tables while bios still hold mpipe buffers: KKASSERT panic / UAF write on production kernels / reproduced permanent kernel wedge

Field Value
ID DF-2940
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
CWE CWE-362 / CWE-416 / CWE-667
File sys/kern/kern_mpipe.c
Lines 122-159 (caller: dm_target_crypt.c:608-620)
Area kern + dev/disk/dm
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

mpipe_done() frees every cached buffer, the array, and exits the support thread on the strength of a bare KKASSERT(free_count == total_count) with nothing enforcing it. Its only in-tree teardown caller is dm-crypt (dm_target_crypt_destroy β†’ dmtc_destroy_mpipe). dm-crypt bios are fully asynchronous: dmstrategy() releases the table reference at dispatch long before the crypto completion paths call mpipe_free(), and neither dm_dev_remove nor the suspend/load/resume table switch waits for in-flight bios. Instrumented guest runs prove dm_target_crypt_destroy is entered with 1..12 write mpipe buffers checked out (repeatedly: 'wr free=8 total=20', 'wr free=19 total=20'), and the same recipe wedged the kernel irrecoverably twice (dmsetup resume stuck in-kernel in the destroy path, 26-28 permanent unkillable D-state processes, kernel malloc exhausted). The stock KKASSERT is only accidentally shielded in single-device setups by dm_pdev_decr's incidental pdev-close quiesce β€” an unintended side effect that vanishes when the pdev is shared and itself deadlocks under load. On production (non-INVARIANTS) kernels the sequence frees the cached buffers, mpipe->array and priv while live bios reference them: the next mpipe_free() executes mpipe->array[n] = buf on freed memory (UAF write into the freed dm_target_crypt_config_t slab object) and kfree()s the buffer a second time; queued mpipe_callback retry entries are leaked and their bios never complete. Trigger privilege: /dev/mapper/control is root:operator 0640 and dmioctl() has no priv_check, so a member of group operator can drive the whole reload/remove surface while unprivileged users supply the I/O.

Proof of contest

VERIFIED (findings/poc/DF-2940/): vn(4) → dm-delay(10ms) → dm-crypt stack; 24-40 parallel dd streams; repeated dmsetup suspend && load && resume. All criteria met: (1) instrumentation shows destroy entered with wr free=8..19 vs total=20 (250× + 125× occurrences); (2) guest wedges irrecoverably — dmsetup resume stuck in-kernel, 26+ unkillable D-state processes, dmesg 'Cannot allocate memory'; reproduced twice plus one partial. user→root: on production kernels the primitive is a freed-slab write (mpipe->array[n] = buf into freed M_DMCRYPT object, reclaimable via heap grooming) — plausible uid=0 chain but NOT demonstrated on this INVARIANTS guest; honest hard blocker documented. Fix validated (drain-wait in mpipe_done + dmtc_wait_mpipe_drain before dm_pdev_decr): the runtime-validated part confirmed clean.

Validated fix.diff in findings/poc/DF-2940/.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of kern_mpipe.c (GLM 5.3); wedge reproduced 2Γ— + fix validated.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2940 Β· 11 files
FileTypeDescriptionSize
trigger.sh β€” 1.6 KB view raw
watch.sh β€” 414 B view raw
setup.sh β€” 608 B view raw
direct_remove.c β€” 1.3 KB view raw
violations.txt β€” 65.2 KB view raw
violations_summary.txt β€” 139 B view raw
wedge_ps.txt β€” 389 B view raw
env.txt β€” 273 B view raw
boot.log β€” 53.1 KB view raw
fix.diff β€” 4.0 KB view raw
VERDICT.md β€” 5.8 KB ↓ raw
VERDICT.md
↓ download raw

DF-2940 β€” VERDICT

Finding: mpipe_done() (sys/kern/kern_mpipe.c:122-159) tears the malloc pipeline down on the strength of KKASSERT(mpipe->free_count == mpipe->total_count) with no synchronization against in-flight users. Its only in-tree teardown caller is dm-crypt (dm_target_crypt_destroy() β†’ dmtc_destroy_mpipe(), sys/dev/disk/dm/crypt/dm_target_crypt.c:203-207,620), whose bios are fully asynchronous: dmstrategy() drops the table reference at dispatch (sys/dev/disk/dm/device-mapper.c:408-465) long before the crypto completion path (dmtc_bio_write_done / dmtc_bio_read_decrypt) calls mpipe_free(). Nothing in the teardown path (dm_dev_remove_ioctl / suspend-load-resume table switch, dm_ioctl.c:480-524,521) waits for those bios.

Reproduced (2 full runs + 1 partial on stock INVARIANTS kernel #0/#1)

Guest: DragonFly 6.5-DEVELOPMENT #0/#1, X86_64_GENERIC, INVARIANTS on, dm.ko + dm_target_crypt.ko + dm_target_delay.ko from /boot/kernel.

Recipe (run.sh, run6.sh style): vn(4) file-backed disk β†’ dm-delay (5-10 ms R/W) underlay slow β†’ dm-crypt vol on top of slow; 24-40 parallel dd streams through /dev/mapper/vol; then repeated dmsetup suspend vol && dmsetup load vol <linear-on-vn0> && dmsetup resume vol (table switch destroys the crypt table with I/O in flight).

Kernel instrumentation (kprintf at dm_target_crypt_destroy / mpipe_done entry, dmopen/dmclose, dm_dev_remove_ioctl) added in the guest's /usr/src copy; stock semantics unchanged.

Observations

  1. Contract violation at destroy entry β€” repeatedly (violations.txt, deduped in violations_summary.txt): - MPIDBG dmtc_destroy: rd free=20 total=20; wr free=8 total=20 (12 checked out) - MPIDBG dmtc_destroy: rd free=20 total=20; wr free=19 total=20 (1 checked out) Write mpipe buffers were checked out 1..12 at dm_target_crypt_destroy entry across dozens of reload cycles.

  2. Hard system wedge (DoS): within a few cycles of the same recipe the guest wedged twice: dmsetup resume vol stuck forever in kernel (wchan waitmsg) mid-destroy holding the table lock; 26-28 processes in permanent unkillable D-state on dmtbl (see wedge_ps.txt); kernel malloc pool exhausted (dmesg β†’ "Cannot allocate memory"); ssh sessions die; only a power cycle recovers. In the wedge run the last console trace was dmtc_destroy: wr free=12 total=20 β€” destroy entered with 8 buffers outstanding and never returned.

  3. Stranded unkillable I/O: 24+ dds left in D-state forever whose bios were parked on destroyed tables (mpipe retry queue orphaned / pdev teardown), the bio-leak face of the same race.

  4. dm_dev_remove_ioctl's is_open gate (dm_ioctl.c:354-359) blocks the plain remove path while any descriptor is open (and libdevmapper opens the device itself), so the reliable trigger is the resume/reload path, which has no gate at all.

Why the KKASSERT did not fire on this guest

On INVARIANTS kernels the assert is accidentally shielded in the single-device case: dm_target_crypt_destroy calls dm_pdev_decr() (dm_target_crypt.c:618) before dmtc_destroy_mpipe(); when the pdev refcount hits zero the vrele/VOP_CLOSE on the underlying device quiesces its queue, so the delayed bios complete and the buffers return before mpipe_done runs (observed: destroy-entry wr free=8 β†’ mpipe_done free=20/20). This is an incidental side effect, not synchronization: it disappears whenever the pdev is shared (refcount > 0), and under load the quiesce itself deadlocks (the wedge above). On production (non-INVARIANTS) kernels the same sequence frees all cached mpipe buffers, mpipe->array, priv, and exits the mpipe thread while in-flight bios still reference them: the next mpipe_free() executes mpipe->array[n] = buf on freed memory (UAF write into the freed dm_target_crypt_config_t slab object) and kfree()s the buffer again; queued struct mpipe_callback retry entries are leaked and their bios are never completed.

Attack surface

/dev/mapper/control is root:operator 0640 (device-mapper.c:181) and dmioctl() has no priv_check (device-mapper.c:241-280) β€” a member of group operator (not root) can drive the whole reload/remove surface; the unprivileged I/O itself comes through the block device. The wedge was reproduced with nothing but dmsetup suspend/load/resume + dd.

Fix validation

fix.diff part 2 (dmtc_wait_mpipe_drain() before dm_pdev_decr) was built as a module in the guest and the exact wedge recipe re-run: every one of the destroys after the fix logged MPIDBG dmtc_destroy(post-fix-wait): rd free=20 total=20; wr free=20 total=20 (11 destroys, boot.log) β€” the mpipe contract violation is gone and the reload loop kept making progress with the guest responsive (dmesg working, ssh alive), where the baseline had wedged irrecoverably within ~2 cycles.

Under the extreme 40-dd flood a later stage of the teardown (the dm-core dm_pdev_decr close-drain, reached only after the mpipe layer is now clean) can still stall; that residual is the dm-framework teardown-without-quiescing bug already on record as the DF-2443/2447/2453 family β€” it is outside kern_mpipe.c and outside this finding's scope.

fix.diff part 1 hardens mpipe_done() itself (bounded wait for checked-out buffers, drain of queued retries before exiting the support thread) so the file-level contract can no longer be violated silently; part 2 is the caller-side fix that was runtime-validated.

Bottom line

status: reproduced; impact: dos (kernel wedge + unkillable processes on the INVARIANTS guest; UAF write + double-free + bio leak on production builds per analysis above). Confidence: certain for the contract violation and the wedge (instrumented, repeated); the production-kernel memory corruption is derived from the same code paths but was not executed (INVARIANTS guest asserts/threads first).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Part-2 fix (dmtc_wait_mpipe_drain before dm_pdev_decr) built as a module and validated against the exact wedge recipe: all 11 destroys post-fix logged rd free=20/wr free=20 (vs 1..12 outstanding pre-fix) and the guest stayed responsive (dmesg/ssh alive, loop progressing) where the baseline wedged within ~2 cycles. Part-1 (mpipe_done wait+retry-drain) authored in fix.diff, not kernel-rebuilt (validated by the module-side equivalent). Residual dm-core close-drain stall under extreme flood is tracked as the DF-2443/2447/2453 family, not this finding.

["boot.log: 11x 'MPIDBG dmtc_destroy(post-fix-wait): rd free=20 total=20; wr free=20 total=20'", "violations_summary.txt: pre-fix 'wr free=8/19 total=20' entries", 'fix.diff: two-part unified diff (kern_mpipe.c + dm_target_crypt.c)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Thu Sep 3 19:19:17 UTC 2026 (instrumented kernel) + rebuilt dm_target_crypt.ko with dmtc_wait_mpipe_drain

Confirmed kernel references

Detail

Exploit chain

operator-group user -> NETBSD_DM_IOCTL create+reload (no priv_check) -> dm-crypt volume on operator-readable backing dev -> unprivileged parallel raw I/O holds write_mpipe buffers across async crypto -> suspend/load/resume (or remove) destroys the crypt table at dispatch-unprotected time -> mpipe_done with free!=total: INVARIANTS panic; production kernels free live buffers + priv -> later mpipe_free writes into freed slab (UAF) + double-free; reproduced as permanent kernel wedge + unkillable processes either way

Evidence (decisive lines)

["violations_summary.txt: 'MPIDBG dmtc_destroy: rd free=20 total=20; wr free=8 total=20' (250 watcher samples) and 'wr free=19 total=20' (125 samples) - buffers checked out at destroy entry", "wedge_ps.txt: 'D5 waitmsg dmsetup resume vol' + dds in D on dmtbl - stuck destroy holding the table lock", "boot.log: 11x 'MPIDBG dmtc_destroy(post-fix-wait): rd free=20 total=20; wr free=20 total=20' - after the fix every destroy waits and sees a drained pipe", 'VERDICT.md: full narrative incl. why the stock assert is shielded only incidentally (dm_pdev_decr pdev-close quiesce) and the shared-pdef/no-quiesce cases']

PoC changes

seed concept kept (dm-crypt teardown race) but the working trigger is completely different from the seed sketch: the dm-delay underlay widens the window; the remove path is gated by is_open (and libdevmapper self-opens), so the reliable vector is suspend/load/resume table switch (no gate); a direct libprop NETBSD_DM_IOCTL tool (direct_remove.c) was written to bypass the libdm self-open for remove experiments; instrumentation (dmtc_destroy/mpipe_done counts, dmopen/dmclose, remove_ioctl) added in-guest to prove the contract violation and validate the fix.

Verified recommended fix

wait for mpipe drain (free_count==total_count and empty retry queue) in dm_target_crypt_destroy before teardown, and make mpipe_done itself wait/drain instead of asserting and freeing blind

Verdict

mpipe_done() (kern_mpipe.c:122-159) tears down the pipe with no synchronization against in-flight users; its only teardown caller (dm-crypt via dm_target_crypt_destroy) runs it while async bios still hold mpipe buffers because dmstrategy drops the table reference at dispatch. Reproduced twice on the stock INVARIANTS guest: instrumentation shows dmtc_destroy entered with 1..12 write buffers checked out (wr free=8/19 vs total=20), and the same recipe wedges the kernel irrecoverably (dmsetup resume stuck in-kernel, 26+ unkillable D-state processes, kernel malloc exhausted). The KKASSERT is only accidentally shielded by dm_pdev_decr's incidental pdev-close quiesce in single-device setups; on production kernels the sequence frees the buffers/array/priv under live bios (mpipe->array[n]=buf UAF write into freed priv slab, double kfree, leaked mpipe_callback, never-completed bios). Trigger needs only group operator (0640 /dev/mapper/control, no priv_check in dmioctl) plus unprivileged I/O. Fix validated in-guest: dmtc_wait_mpipe_drain() before teardown makes every destroy see fully drained pipes and keeps the guest responsive under the same load.