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

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.

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

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1964 Β· 5 files
FileTypeDescriptionSize
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
README.md readme PoC trigger description
↓ download raw

DF-1964 PoC β€” orphaned worker thread UAF on dm-delay write-side init failure

Build & run

  1. Boot a DragonFlyBSD guest with the dm_target_delay KLD loaded.
  2. As root (or any principal in the operator group): 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
  3. The create returns an error (write-side ENOENT) but the read worker thread is now orphaned against the freed tdc.
  4. Churn M_DMDELAY-sized allocations (e.g. create/destroy many 3-arg delay tables) until the orphaned thread's tsleep ident is reallocated, or simply wait β€” any unrelated wakeup colliding 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
VERDICT.md verdict verification narrative
↓ download raw

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.

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 --check on 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

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.