dm_target_delay _destroy() drains queued bios asynchronously; their completion path writes through stale &dmv->stats into the freed-and-reallocated dm_dev (deterministic write-after-free β cross-object devstat corruption β panic)
Summary
_submit_queue(submit_all) vn_strategy's every queued bio and returns - nothing waits for biodone; dm_dev_destroy then kfrees the object embedding the stats while the drained bios are in flight, and their completion writes through the stale pointer (semi-controlled adds/timestamps/busy_count into freed-then-reallocated memory, plus global devstat queue corruption). The delay target makes the generic hazard deterministic (holds bios past strategy return; delay-over-delay widens the window ~1s). VERIFIED 2/2 fresh-boot runs on guest: cross-object proof (devstat names the sprayed filler) then panic; fail-queued-ENXIO fix validated (clean under 2x48 + 5x96 stress).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4907 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df4907.c | β | 4.9 KB | view raw | |
| run.sh | β | 147 B | view raw | |
| build.sh | β | 89 B | view raw | |
| panic.txt | β | 20.5 KB | view raw | |
| panic.2.baseline.txt | β | 14.7 KB | view raw | |
| run.log | β | 767 B | view raw | |
| fix.diff | β | 1.0 KB | view raw | |
| fix_build.log | β | 1.4 KB | view raw | |
| fix_run.log | β | 367 B | view raw | |
| env.txt | β | 254 B | view raw | |
| VERDICT.md | β | 5.8 KB | β raw | |
| verdict.json | β | 4.6 KB | view raw |
DF-4907 β dm_target_delay async-drain use-after-free (verified)
Finding
_destroy() in sys/dev/disk/dm/delay/dm_target_delay.c:341 drains the delay
queue with _submit_queue(di, 1), which asynchronously submits every queued
bio to the underlying pdev via vn_strategy() (_submit, :267) and returns
immediately. Nothing in the target (or in the dm core teardown that follows)
waits for those bios to complete, yet their completion path dereferences memory
owned by the dm device that is about to be freed:
- the bios are nestbufs created by
dmstrategy()(sys/dev/disk/dm/device-mapper.c:425) whosebio_caller_info2.ptr = &dmv->statswas captured at strategy time; - when the pdev driver finally completes them,
nestiobuf_iodone()βnestiobuf_done(..., stats)(sys/kern/vfs_bio.c:4505) βdevstat_end_transaction()(sys/kern/subr_devstat.c:179) writeslast_comp_time,busy_count--,bytes_read += n,num_reads++through that stale pointer; - by then
dm_table_destroyβdm_target_delay_destroyhas returned anddm_dev_destroyβdm_dev_free(sys/dev/disk/dm/dm_dev.c:384) has kfree()d the dm_dev that embeds that devstat.
For the linear target the same window exists but is only as wide as one disk
latency; the delay target makes it hostile-friendly: it (a) holds bios long
past strategy() return and (b) dumps the whole queue at destroy time, so the
freed-memory window can be widened to seconds by stacking delay targets
(the PoC stacks d2(delay) β d1(delay, removed) β d0(delay) β vn0, giving a
~1 s gap between dm_dev_free(dmv1) and the completion of d1's orphaned bios).
Reproduction (baseline, stock module) β REPRODUCED 2/2 from fresh boots
Guest: DragonFly 6.5-DEVELOPMENT (#0 Jul 2 2026) X86_64_GENERIC, QEMU/KVM.
Per-iteration (see df4907.c for the full timeline):
1. d0 = delay(/dev/vn0, 1000ms), d1 = delay(/dev/mapper/d0, 1000ms),
d2 = delay(/dev/mapper/d1, 1000ms).
2. 48 reader threads pread() /dev/mapper/d2 β bios queue in d2 (1 s).
3. t0+1.0 they hop into d1's queue (nestbufs with stats=&dmv1->stats).
4. t0+1.2 dmsetup suspend d2; reload d2 (table on vn0); resume d2 β
resume destroys d2's OLD table β delay _destroy β dm_pdev_decr β
vn_close(d1) β dmclose(d1) β d1.is_open = 0 (release from pdev-hold;
d2's own fds stay held by the blocked readers, so d2 cannot be removed but
d1 can).
5. dmsetup remove d1 β _submit_queue(di,1) submits the queued bios into
d0's 1 s queue and returns; teardown frees dmv1 (~Β΅s later).
6. Spray dmsetup create f0..f7 β the freed dm_dev chunk is immediately
reallocated as live device f0.
7. t0+2.2 d0's timer fires, the orphaned bios complete on vn0, and
devstat_end_transaction writes into the live f0 device's devstat.
Observed on the serial console (identical both runs):
devstat_end_transaction: HELP!! busy_count for f00 is < 0 (-1)!
panic: bio_track_rel: bad count: 0xfffff8008da31610
Trace beginning at frame ...
biodone() at biodone+0x17b
biodone() at biodone+0x17b
vnstrategy() at vnstrategy+0x125
dev_dstrategy() at dev_dstrategy+0xf5
diskstrategy() at diskstrategy+0x3b
dev_dstrategy_chain() at dev_dstrategy_chain+0x8b
Debugger("panic")
HELP!! busy_count for f00 is the smoking gun: the late completion
decremented the devstat of f0 β a different, live device that happened to
realloc the freed chunk β i.e. a demonstrated cross-object write-after-free
into a reallocated kernel heap object, followed by secondary corruption and a
bio_track_rel panic in biodone.
Files: panic.txt (run 1), panic.2.baseline.txt (run 2, fresh boot), run.log (ssh capture; connection died when the box panicked).
Fix validation β FIXED
fix.diff changes the submit_all path of _submit_queue to fail the queued
bios (b_error = ENXIO; b_resid = b_bcount; biodone(&bp->b_bio1)) instead of
asynchronously submitting them past the target's lifetime. Built
sys/dev/disk/dm/delay/dm_target_delay.c as a module in the guest
(make obj; make in /usr/src/sys/dev/disk/dm/delay), loaded the patched
dm_target_delay_fixed.ko, and re-ran the exact PoC:
* 2 iterations x 48 readers: RC=0 HELP_before=0 HELP_after=0, guest up.
* 5 iterations x 96 readers (stress): clean, guest up, dmesg free of HELP.
Baseline stock module: panic on both runs. Patched: no panic, no HELP, no
leak of the corruption signature β bad behavior gone. (fix_build.log /
fix_run.log hold the untrimmed logs.)
Note on semantics: erroring queued I/O during target teardown also affects the table-reload case (resume destroying an old table with queued bios); those reads now see ENXIO instead of being submitted. That matches the "device/table disappearing under I/O" contract userspace already has to tolerate and is the safe default until the dm core gains per-bio lifetime refcounting (the deeper fix, out of scope for this file).
Threat model
Reaching this requires the device-mapper control device (/dev/mapper/control,
root-only), i.e. local root (or a jail/namespace given it). It is therefore
not an unprivβroot escalator by itself, but it turns an ordinary
create/destroy admin sequence into deterministic kernel heap corruption with
an attacker-chosen reallocation victim and repeated semi-controlled writes
(bytes_read += b_bcount, num_reads++, timestamps) β usable for
kernel-state corruption / DoS in the same class as the other verified dm
teardown bugs this cycle (DF-4900 family).
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
fixedfix.diff applied to the guest /usr/src copy, module rebuilt (fix_build.log) and loaded as dm_target_delay_fixed.ko; the exact PoC (2x48 and stress 5x96) runs clean: RC=0, no HELP message, no panic, guest alive. Baseline stock module panicked on 2/2 fresh-boot runs. Bad behavior gone.
fix_build.log (patch + module build, untrimmed); fix_run.log (patched runs 2x48 and 5x96 with RC=0 HELP_before=0 HELP_after=0 and uptime confirming guest alive); panic.txt/panic.2.baseline.txt (baseline 2/2 panics)
Confirmed kernel references
Detail
Exploit chain
root: dmsetup create d0=delay(vn0,1000ms), d1=delay(d0,1000ms), d2=delay(d1,1000ms) -> 48 threads pread /dev/mapper/d2 (bios reach d1's queue at t0+1.0) -> suspend/reload/resume d2 (releases d1 from pdev-hold so d1.is_open=0) -> dmsetup remove d1 at t0+1.2 (drain submits bios into d0's queue, dm_dev_free(dmv1) microseconds later) -> spray dmsetup create f0..f7 (f0 reuses the freed dm_dev chunk) -> t0+2.2 the orphaned bios complete and devstat_end_transaction writes into the LIVE f0 device -> HELP!! line + secondary bio_track_rel panic.
Evidence (decisive lines)
panic.txt:311-312 and panic.2.baseline.txt:222-223 (HELP!! busy_count for f00 < 0 then panic, identical on two fresh boots); fix_run.log (patched module, 5x96 readers, RC=0, HELP counts 0, guest up); VERDICT.md (full path:line analysis); df4907.c (trigger).
PoC changes
Seed design (from scratch this run): v1 single-layer remove hit dm's is_open EBUSY because blocked preads hold the vnode; v2 extra-fd close also fails (dmclose only on last vnode ref); v3 stacks the removed device under a user-facing d2 and uses suspend/reload/resume on d2 to drop d1's pdev-hold before remove, and sprays dm devices to make the UAF hit a live victim.
Verified recommended fix
_submit_queue: in the submit_all (teardown) path, fail queued bios with ENXIO (b_error/b_resid/biodone) instead of submitting them asynchronously past the target's lifetime
Verdict
dm_target_delay _destroy() (dm_target_delay.c:341) drains its bio queue via _submit_queue(di,1), which asynchronously vn_strategy()s every queued bio and returns; dm teardown then frees the dm_dev while those bios' completion path (nestiobuf_iodone -> nestiobuf_done -> devstat_end_transaction_buf, vfs_bio.c:4505) still writes through the strategy-time-captured &dmv->stats. Verified deterministically by stacking delay targets (d2<-d1<-d0<-vn0): removing the never-opened middle device 0.2s after bios enter its queue frees dmv1 while the bios sit in d0's 1s queue; spraying dm devices reallocates the chunk; the late completion then corrupts the live replacement device's devstat. Serial console 2/2 fresh-boot baseline runs: 'devstat_end_transaction: HELP!! busy_count for f00 is < 0 (-1)!' (naming the sprayed filler that reused the freed chunk - cross-object write-after-free proof) followed by 'panic: bio_track_rel: bad count' in biodone. Impact recorded as panic (the demonstrated primitive is a repeatable write-after-free into a chosen-size reallocated kernel object with semi-controlled values: bytes_read += b_bcount, num_reads++, timestamps, busy_count--; exploit-grade heap corruption root-gated by /dev/mapper/control).
No comments yet.