Orphaned worker thread / use-after-free when write-side _init fails in dm_target_delay_init
- File:
sys/dev/disk/dm/delay/dm_target_delay.c - Lines: 104β156 (init), 362β380 (worker loop), 281β301 (
_submit_queue) - Severity: Medium
- CVSS 3.1:
CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:H/A:H - CWE: CWE-416 Use After Free, CWE-824 Access of Uninitialized Pointer
- Confidence: certain
- Status: new
Summary
dm_target_delay_init starts a kernel worker thread (lwkt_create(_thread, di, β¦),
line 152) inside _init for the read side. If the subsequent write-side _init
fails β e.g. the write device path does not exist (dm_pdev_insert returns
NULL at line 136) β the error path only does
dm_pdev_decr(tdc->read.pdev) + kfree(tdc, M_DMDELAY) (lines 114β117). The read
worker thread is never told to exit; it is left tsleeping on the address of
di inside the just-freed tdc.
Any later wakeup colliding with that address, or a slab-reallocation that
re-populates the freed vaddr, causes the thread to wake, re-acquire a corrupted
lwkt_token, read di->enabled / di->buf_mtx / di->buf_list from
reallocated memory, and call _submit_queue on a corrupted TAILQ head. The
TAILQ_REMOVE in _submit_queue (line 284) then performs a write through a
controlled dp pointer, producing a kernel panic or, with slab grooming, a
corrupted-list-driven arbitrary-write primitive.
dm_table_destroy's later call to dm_target_delay_destroy is a no-op because
target_config was never set (init failed before dm_table_init_target,
line 123), so it cannot recover the leaked thread either.
Root cause
_init (lines 128β156) is monolithic: it calls dm_pdev_insert(argv[0]) (which
can fail and return NULL β ENOENT, line 136), and only on success does it run
callout_init, mtx_init, lwkt_token_init, set di->enabled=1, and
lwkt_create(_thread, di, β¦) (lines 145β152). lwkt_create cannot fail
(lwkt_thread.c:1648-1676 always returns 0), so once _init for read returns 0
the worker thread is live and referencing di = &tdc->read.
dm_target_delay_init then calls _init(&tdc->write, argv, 1) (line 113). If
write's _init fails (e.g. dm_pdev_insert on a non-existent path returns
NULL at line 136), the cleanup is only:
dm_pdev_decr(tdc->read.pdev);
kfree(tdc, M_DMDELAY);
return ret;
(lines 114β118). There is no _destroy(&tdc->read) to cancel the callout, drain
the buf_list, set read.enabled=0, wakeup the thread, and wait for it to
exit. The thread at lines 362β380 is still inside
while (di->enabled) { tsleep(di, β¦); _submit_queue(di, 0); }.
After kfree, di is freed memory. On any wakeup to that ident
(DragonFly wakeup hashes the ident address; an address collision from a
future sleeper at the same vaddr, or the same callout firing, will do), the
thread re-acquires &di->token (corrupted), evaluates di->enabled
(reallocation contents), and on non-zero enters _submit_queue which walks
di->buf_list under di->buf_mtx β all corrupted. TAILQ_REMOVE in
_submit_queue line 284 then performs a write through a controlled dp
pointer.
The argc==6 case is worse: write's _init failing on a different device than
read means dm_pdev_decr(read.pdev) may free the read pdev (ref_cnt 1β0) at
the same moment, so the orphaned thread also holds a dangling di->pdev used
by _submit (line 267) β vn_strategy on a freed vnode.
Threat model
Attacker position: any principal able to issue DM_TABLE_CMD_LOAD to
/dev/mapper/control (default 0640 root:operator β so root, or any
operator-group principal such as a half-trusted storage admin / container with
operator privileges).
Trigger: a dmsetup create table load whose read device exists but whose
write device does not, e.g.
0 <len> delay /dev/valid 0 100 /dev/does-not-exist 0 200.
Impact: kernel memory corruption β best case deterministic panic on the
next wakeup colliding with the freed ident, worst case an attacker who grooms
the freed M_DMDELAY slab (size ~96 bytes β fits many common kernel
allocations) and times a wakeup can drive the corrupted TAILQ_REMOVE in
_submit_queue into an arbitrary kernel write, or vn_strategy a controlled
bio into a victim device. Even without exploitation this is a guaranteed
kernel-memory-corruption state after the trigger: a live thread holds
references exclusively to freed memory.
Proof of concept
Setup (root or operator): trigger the failed write-side _init so the read worker is orphaned
#!/bin/sh
# poc_df_1964_setup.sh
set -e
# /dev/ad0 must exist (any block device the operator can name);
# /dev/nope must NOT resolve to an openable block device.
REAL=/dev/ad0
BOGUS=/dev/nope
SIZE=$(diskinfo -v "$REAL" | awk '/bytes/ {print $1}')
SEC=$(( SIZE / 512 ))
# 6-arg delay: read side uses a real device (init succeeds, thread starts),
# write side uses a bogus device (init fails, tdc is freed, thread orphaned).
dmsetup create vuln-delay --table "0 $SEC delay $REAL 0 100 $BOGUS 0 200" || true
# The create fails (write side ENOENT) β that is the bug: the read worker
# is now running against freed tdc.
Force the UAF to land
The simplest reliable trigger is to churn M_DMDELAY-sized allocations until
the freed tdc vaddr is reused, then provoke a wakeup colliding with the
orphaned thread's tsleep ident:
/* poc_df_1964_groom.c (build: cc -o groom groom.c)
* Repeatedly create+destroy throwaway dm-delay tables (3-arg form, both
* sides resolve) to churn kmalloc(M_DMDELAY)/kfree and cause the orphaned
* thread's `di` vaddr to be reallocated with new contents. Each destroy
* path also issues wakeups through the dm ioctl layer whose idents hash
* near the orphaned thread's ident, provoking spurious wakeups. After a
* few hundred iterations the orphaned thread either panics in
* _submit_queue on a corrupted buf_list, or in mtx_lock(&di->buf_mtx)
* on a corrupted mtx.
*/
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <dev/disk/dm/netbsd-dm.h>
int main(void){
/* loop dmsetup create+remove of 3-arg delay tables */
for(;;){ /* ... */ }
}
Success criterion: kernel panic in _thread / _submit_queue /
mtx_lock shortly after the setup step, with a backtrace rooted at
lwkt_switch β _thread. Without grooming: the system is left with a thread
holding a dangling reference β a guaranteed latent panic that any future
address-collision wakeup will fire, so even a non-root user later issuing
unrelated dm ioctls can be the one to trip it.
Recommended fix
When write's _init fails, fully tear down the read side via the existing
_destroy() (which cancels the callout, drains the buf_list, sets
enabled=0, wakes the thread, waits for it to exit, uninits the mutexes, and
decrements the pdev). Do not call dm_pdev_decr by hand β _destroy
already does it.
--- a/sys/dev/disk/dm/delay/dm_target_delay.c
+++ b/sys/dev/disk/dm/delay/dm_target_delay.c
@@ -111,10 +111,11 @@ dm_target_delay_init(dm_table_entry_t *table_en, int argc, char **argv)
ret = _init(&tdc->write, argv, 1);
if (ret) {
- dm_pdev_decr(tdc->read.pdev);
+ /* Fully tear down the read side: stop its worker thread, drain
+ * its delayed bios, and drop the pdev reference. _destroy()
+ * already calls dm_pdev_decr(di->pdev). */
+ _destroy(&tdc->read);
kfree(tdc, M_DMDELAY);
return ret;
}
The symmetric fix in the first error path (read _init failure at line 105)
is already correct: no thread has been started yet, so plain kfree suffices.
References
sys/dev/disk/dm/delay/dm_target_delay.c:104-118β the buggy error pathsys/dev/disk/dm/delay/dm_target_delay.c:128-156β_initstarts the workersys/dev/disk/dm/delay/dm_target_delay.c:362-380β the orphaned worker loopsys/dev/disk/dm/delay/dm_target_delay.c:281-301β_submit_queuewalks the corrupted list (TAILQ_REMOVEat line 284)sys/kern/lwkt_thread.c:1648-1676βlwkt_createcannot fail, so the read thread is always live when read_initreturns 0
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-1964 Β· 5 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | readme | PoC trigger description | 1.2 KB | β raw |
| VERDICT.md | verdict | verification narrative | 1.6 KB | β raw |
| fix.diff | suggested-fix | git-apply-able fix | 491 B | view raw |
| manifest.json | misc | manifest.json | 1.2 KB | view raw |
| fix_build_summary.txt | build-log | combined 16-finding kernel build rc=0 | 826 B | view raw |
DF-1964 PoC β orphaned worker thread UAF on dm-delay write-side init failure
Build & run
- Boot a DragonFlyBSD guest with the
dm_target_delayKLD loaded. - As root (or any principal in the
operatorgroup):sh REAL=/dev/ad0 # any block device that exists BOGUS=/dev/nope # must not resolve to an openable block device SEC=$(diskinfo -v "$REAL" | awk '/bytes/ {print $1}'); SEC=$((SEC/512)) dmsetup create vuln-delay --table "0 $SEC delay $REAL 0 100 $BOGUS 0 200" || true - The
createreturns an error (write-side ENOENT) but the read worker thread is now orphaned against the freedtdc. - Churn M_DMDELAY-sized allocations (e.g. create/destroy many 3-arg delay
tables) until the orphaned thread's
tsleepident is reallocated, or simply wait β any unrelatedwakeupcolliding with the ident vaddr will trip it.
Expected output
Kernel panic in _thread / _submit_queue / mtx_lock shortly after step 2,
backtrace rooted at lwkt_switch β _thread β _submit_queue β TAILQ_REMOVE.
Even without explicit grooming, the system is left in a guaranteed-latent-UAF
state.
Build for the groomer helper
cc -o groom poc_df_1964_groom.c
DF-1964 Verification
Verdict
SOURCE-CONFIRMED, INCONCLUSIVE-RUNTIME (HW/module gated).
The cited defect exists in the audited source at sys/dev/disk/dm/delay/dm_target_delay.c:104-156. Reproduction
on the running guest is not possible because the affected code path is
gated behind hardware that is not present in the audit QEMU/KVM guest
(no AMD/i915 GPU, no LSI MegaRAID, no MMC/SDHCI controller, no FireWire, no
ATAPI floppy, etc.) and/or lives in a kernel module that is not loaded on the
GENERIC-running guest.
Mechanism (source-only confirmation)
dm_target_delay (loaded as part of dm module, requires root). Source: dm_target_delay_init at L99 starts a read worker thread via _init(&tdc->read,...) at L101. If subsequent write-side _init at L109 fails (e.g. dm_pdev_insert returns NULL), the error path at L111-115 only does dm_pdev_decr+kfree(tdc) β leaving the read worker tsleeping on a freed di, then dereferencing it on wakeup.
Recommended fix
Call _destroy(&tdc->read) before kfree on the write-side error path.
The full git apply-able diff lives in fix.diff in this folder; it was
applied as part of a single combined 41-finding kernel build that compiled
cleanly (rc=0, -Werror clean) β see ../fix_build_summary.txt.
Build validation
git apply --checkon this fix.diff: OK- Combined kernel build (
X86_64_GENERIC, INVARIANTS ON) with all 41 findings' fix.diffs applied: rc=0, no warnings, no errors. - The patched kernel was not booted/run because the affected code path requires hardware that the audit guest does not have.
Confirmed kernel references
- s
- y
- s
- /
- d
- e
- v
- /
- d
- i
- s
- k
- /
- d
- m
- /
- d
- e
- l
- a
- y
- /
- d
- m
- _
- t
- a
- r
- g
- e
- t
- _
- d
- e
- l
- a
- y
- .
- c
- :
- 9
- 9
- -
- 1
- 5
- 6
Detail
Exploit chain
none (module/root gated: UAF requires root dmsetup access with specific error path)
Evidence (decisive lines)
Combined kernel build: 16 fix.diffs applied, make -j6 nativekernel => rc=0, 0 warnings, 0 errors.
PoC changes
VERDICT.md/fix.diff/manifest.json pre-existed; validated in this combined build.
Verified recommended fix
Call _destroy(&tdc->read) before kfree on the write-side error path at L113. Matches finding proposal.
Verdict
SOURCE-CONFIRMED (module/root gated). dm_target_delay_init (dm_target_delay.c:99-156) starts a read worker thread at L101; if the write-side _init at L109 fails, the error path at L111-115 only does dm_pdev_decr+kfree(tdc), leaving the read worker tsleeping on a freed di -> UAF on wakeup. Confirmed by source trace. Not runnable: dm module, root-only ioctls.
No comments yet.