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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2940 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
-
Contract violation at destroy entry β repeatedly (
violations.txt, deduped inviolations_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 atdm_target_crypt_destroyentry across dozens of reload cycles. -
Hard system wedge (DoS): within a few cycles of the same recipe the guest wedged twice:
dmsetup resume volstuck forever in kernel (wchanwaitmsg) mid-destroy holding the table lock; 26-28 processes in permanent unkillable D-state ondmtbl(seewedge_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 wasdmtc_destroy: wr free=12 total=20β destroy entered with 8 buffers outstanding and never returned. -
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.
-
dm_dev_remove_ioctl'sis_opengate (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
fixedPart-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)']
Confirmed kernel references
- sys/kern/kern_mpipe.c:122
- sys/kern/kern_mpipe.c:128
- sys/kern/kern_mpipe.c:143
- sys/kern/kern_mpipe.c:153
- sys/kern/kern_mpipe.c:337
- sys/kern/kern_mpipe.c:349
- sys/dev/disk/dm/crypt/dm_target_crypt.c:608
- sys/dev/disk/dm/crypt/dm_target_crypt.c:618
- sys/dev/disk/dm/device-mapper.c:408
- sys/dev/disk/dm/device-mapper.c:465
- sys/dev/disk/dm/dm_ioctl.c:521
- sys/dev/disk/dm/dm_table.c:150
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.
No comments yet.