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

UAF on kdmsg state in diskiodone: state refcount not held across async I/O

Field Value
ID DF-0117
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:A/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-911 Use After Free
File sys/kern/subr_diskiocom.c
Lines 372-662
Area kern
Confidence likely
Discovered 2026-06-30
Reported pending

Summary

In disk_blk_read/write/flush/freeblks, the kdmsg state pointer is stored in bio->bio_caller_info1.ptr and dispatched to dev_dstrategy for async I/O, but no reference is taken on the state β€” the kdmsg_state_hold() and matching kdmsg_state_drop() calls are commented out (:375, :451, :503, :554, :661). If the kdmsg peer sends a DELETE or the connection drops while I/O is in flight, the kdmsg core frees the state. When diskiodone fires on I/O completion, it dereferences the freed state (:580, :582), producing a use-after-free on a small kmalloc object.

Root cause

All four I/O dispatch sites follow the same pattern (:372-381):

bio->bio_caller_info1.ptr = msg->state;
/* kdmsg_state_hold(msg->state); */   // <-- COMMENTED OUT
...
dev_dstrategy(dp->d_rawdev, bio);

The completion handler (:577-662) dereferences it unconditionally:

kdmsg_state_t *state = bio->bio_caller_info1.ptr;  // :580
struct dios_io *iost = state->any.any;              // :582 β€” UAF
...
kdmsg_msg_alloc(state, ...);                        // :650 β€” UAF
...
/* kdmsg_state_drop(state); */                       // :661 β€” COMMENTED OUT

The kdmsg core frees states when both rxcmd and txcmd carry DMSGF_DELETE (kern_dmsg.c:1678-1707). The peer controls when to send DELETE. If the I/O has not completed, the state is freed while the bio still holds a raw pointer to it. Connection drop (kdmsg_state_abort) also walks the state tree and drops all references.

Threat model & preconditions

  • Attacker position: kdmsg cluster peer (authenticated via the cluster protocol), or local privileged user who issued DIOCRECLUSTER on a disk device node.
  • Impact: Use-after-free on kdmsg_state_t (small kmalloc object). Reliable kernel panic (DoS). Potentially exploitable for kernel code execution via slab grooming β€” the attacker controls the timing between free and reuse.
  • Required config: kdmsg/iocom active (HAMMER2 cluster or DIOCRECLUSTER). Default kernel builds include the code.

Proof of concept

PoC source: findings/poc/DF-0117/

Build & run

# Requires a kdmsg/iocom connection to a target disk.
# 1. Establish connection (local: DIOCRECLUSTER with socket fd)
# 2. Open a BLK transaction
# 3. Send BLK_READ (no DELETE) to start I/O
# 4. Before I/O completes, send DELETE on the same state
# 5. Wait for diskiodone to fire on the freed state

Expected output

Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x...  (freed kdmsg_state_t memory)
panic: page fault

Impact

Remote/local kernel UAF. In a DragonFly cluster, any authenticated cluster peer can trigger this. The freed kdmsg_state_t is a small kmalloc object β€” its slab is predictable and can be groomed with attacker-controlled data between the free and the diskiodone dereference, making code execution plausible.

Uncomment the hold/drop pair in all four dispatch sites and the completion handler:

--- a/sys/kern/subr_diskiocom.c
+++ b/sys/kern/subr_diskiocom.c
@@ -372,7 +372,7 @@
        bio->bio_caller_info1.ptr = msg->state;
-       /* kdmsg_state_hold(msg->state); */
+       kdmsg_state_hold(msg->state);
@@ -451,7 +451,7 @@
        bio->bio_caller_info1.ptr = msg->state;
-       /* kdmsg_state_hold(msg->state); */
+       kdmsg_state_hold(msg->state);
@@ -503,7 +503,7 @@
        bio->bio_caller_info1.ptr = msg->state;
-       /* kdmsg_state_hold(msg->state); */
+       kdmsg_state_hold(msg->state);
@@ -554,7 +554,7 @@
        bio->bio_caller_info1.ptr = msg->state;
-       /* kdmsg_state_hold(msg->state); */
+       kdmsg_state_hold(msg->state);
@@ -661,7 +661,7 @@
-       /* kdmsg_state_drop(state); */
+       kdmsg_state_drop(state);

Additionally, free iost when its refcount hits zero in the eof path (see DF-0118), before dropping the state reference.

References

  • The commented-out hold/drop is a clear indication the developer knew the reference was needed but disabled it (possibly during debugging).
  • kdmsg_state_hold/kdmsg_state_drop are defined in sys/kern/kern_dmsg.c and manipulate state->refs.

Timeline

  • 2026-06-30 Discovered during automated audit.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0117 Β· 16 files
FileTypeDescriptionSize
trigger_race.c trigger-source self-contained UAF trigger: DIOCRECLUSTER + BLK_READ|CREATE(MAXPHYS) + immediate socket-close teardown race 7.0 KB view raw
trigger.c trigger-source prior trigger (many separate states, one I/O each β€” did not reproduce; kept for reference) 7.6 KB view raw
build.sh build-script cc -O2 -o trigger trigger.c -lpthread + race variant 262 B view raw
run.sh run-script kills hammer2, runs trigger_race 131072 0 0 1 457 B view raw
fix.diff suggested-fix git-apply-able fix: defer DELETE reply to diskiodone when iost->count > 0 (set eof=1 instead of kdmsg_msg_reply) 3.6 KB view raw
panic.txt panic-signature fatal trap / panic: assertion pstate != NULL in kdmsg_msg_alloc from diskiodone+0x120 via vtblk_vq_intr 752 B view raw
run.log run-log baseline run on unpatched #0 kernel β€” SSH timeout, guest panicked 1.4 KB view raw
fix_run.log run-log patched #1 kernel run β€” clean exit 0, blk_active 0, guest up, stress tests all pass 1.3 KB view raw
fix_build.log build-log patched kernel build output (NK_DONE rc=0) 4.9 KB view raw
env.txt environment guest uname, cc version, sysctl state 264 B view raw
VERDICT.md verdict full narrative: mechanism trace, prior-verdict gap analysis, fix validation 8.5 KB ↓ raw
README.md readme human-facing build/run/expected summary 3.4 KB ↓ raw
manifest.json manifest this file 3.8 KB view raw
build.log build-log kernel build log excerpt proving -Werror clean compile of patched source 67 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human-facing build/run/expected summary
↓ download raw

DF-0117 β€” PoC (UAF on kdmsg state in diskiodone)

Finding: findings/DF-0117-diskiocom-uaf-kdmsg-state.md

Verdict: REPRODUCED β€” confirmed by kernel panic on the unpatched #0 kernel. Fix VALIDATED β€” the eof=1 patch eliminates the panic.

Summary

The prior "NOT REPRODUCED" verdict had a gap: it missed that the connection-drop teardown's simulated DELETE triggers disk_blk_read's error path (subr_diskiocom.c:384-394), which calls kdmsg_msg_reply unconditionally β€” even when iost->count > 0 (I/O in flight). That reply drives cleanuptx, which tears down the state (parent = NULL, removed from rbtree, topology refs dropped) while the bio still holds a raw state pointer. When the I/O completes, diskiodone dereferences the cleaned-up state β†’ panic.

Files

file purpose
trigger_race.c self-contained trigger: DIOCRECLUSTER + BLK_READ|CREATE(MAXPHYS) + immediate teardown race
trigger.c prior trigger (many separate states, one I/O each β€” did not reproduce)
build.sh cc -O2 -o trigger trigger.c -lpthread + race variant
run.sh kills hammer2, runs the race trigger
fix.diff git-apply-able fix: defer DELETE reply to diskiodone when I/O in flight
panic.txt kernel panic signature from boot.log (crash proof)
run.log baseline run (unpatched kernel) β€” SSH timeout, guest panicked
fix_run.log patched kernel run β€” clean exit, no panic
fix_build.log patched kernel build output
env.txt guest uname, cc version, sysctl state
VERDICT.md full narrative: mechanism trace, prior-verdict gap, fix validation
manifest.json machine-readable catalog

Build & run (as root on the guest)

# Build (any user)
cc -O2 -o trigger_race trigger_race.c -lpthread

# Run (as root β€” needs /dev/vbd0 + DIOCRECLUSTER)
pkill -9 -x hammer2     # free the disk iocom from the hammer2 daemon
sleep 1
./trigger_race 131072 0 0 1
#   args: iosize nreads preclose_us iters
#   iosize=131072  MAXPHYS (128 KB) β€” slow enough for the teardown to win the race
#   nreads=0       no additional mid-stream I/Os
#   preclose_us=0  close socket immediately after writing
#   iters=1        one iteration (panics on first run if kernel is vulnerable)

Expected output

Kernel Result
Unpatched (#0) PANIC: assertion "pstate != NULL" failed in kdmsg_msg_alloc at kern_dmsg.c:1822 from diskiodone+0x120 via vtblk_vq_intr
Patched (#1, eof=1) trigger exits 0, blk_active β†’ 0, guest stays up, no panic

Fix

The fix (fix.diff) defers the DELETE reply to diskiodone when I/O is still in flight. In all four disk_blk_* error paths, when iost->count > 0, set iost->eof = 1 instead of calling kdmsg_msg_reply. The last diskiodone (when count reaches 0) sends the DELETE reply itself, after all derefs are done. This keeps the state valid until diskiodone has finished using it.

The finding markdown's proposed fix (uncomment kdmsg_state_hold/drop) does NOT work: those are file-local static macros in kern_dmsg.c, invisible to subr_diskiocom.c. Even when exported, the hold/drop alone does NOT prevent the UAF (confirmed empirically β€” the hold/drop kernel still panicked) because the teardown's kdmsg_subq_delete sets parent = NULL regardless of refcount.

VERDICT.md verdict full narrative: mechanism trace, prior-verdict gap analysis, fix validation
↓ download raw

DF-0117 β€” VERDICT

Status: REPRODUCED (UAF on kdmsg_state_t in diskiodone, confirmed by kernel panic). Fix VALIDATED (eof=1 deferral eliminates the UAF on the patched kernel).

One-line

The prior "NOT REPRODUCED" verdict had a gap in its teardown analysis: it claimed the state's topology refs prevent freeing during diskiodone, but missed that the connection-drop teardown's simulated DELETE triggers the disk callback's error path (subr_diskiocom.c:384-394), which calls kdmsg_msg_reply unconditionally β€” even when iost->count > 0 (I/O in flight). That reply drives cleanuptx, which removes the state from the rbtree, sets parent = NULL, and drops the topology refs β€” all while the bio still holds a raw pointer to the state. When the in-flight I/O completes, diskiodone dereferences the freed/cleaned state β†’ panic.

Reproduction

A single MAXPHYS (128 KB) BLK_READ|CREATE on msgid=1 (eof=0), followed by immediate socket close (teardown), reproduces the panic reliably on the unpatched #0 kernel:

panic: assertion "pstate != NULL" failed in kdmsg_msg_alloc at kern_dmsg.c:1822
cpuid = 5
Trace beginning at frame 0xfffff8008ed4c860
kdmsg_msg_alloc() at kdmsg_msg_alloc+0x267
diskiodone() at diskiodone+0x120
vtblk_vq_intr() at vtblk_vq_intr+0xa7
vtpci_msix_intr() at vtpci_msix_intr+0x32
lwkt_serialize_handler_call() at lwkt_serialize_handler_call+0xd3

The 128 KB read is slow enough (1-10 ms on QCOW2-backed virtio) that the teardown frees the state before the I/O completes. diskiodone (called from the virtio block interrupt) then reads state->parent on the cleaned-up state β€” parent was set to NULL by kdmsg_subq_delete during the teardown's cleanuptx β€” triggering KKASSERT(pstate != NULL) in kdmsg_msg_alloc.

Mechanism (trigger β†’ primitive β†’ effect)

1. Peer sends BLK_READ|CREATE msgid=1, eof=0 (no DELETE in the message)
   subr_diskiocom.c:341  disk_blk_read()
   :360  iost = kmalloc(...)
   :365  bp = getpbuf_mem(NULL)
   :372  bio->bio_caller_info1.ptr = msg->state   ← RAW state ptr, no hold
   :378  eof = 0 (no DELETE)
   :381  dev_dstrategy(dp->d_rawdev, bio)          ← async I/O dispatched

2. Connection drops (socket close β†’ reader EOF β†’ writer teardown)
   kern_dmsg.c:547  teardown loop
   :555  kdmsg_simulate_failure(&state0, 0, ...)
    β†’ kdmsg_state_abort(state)                      kern_dmsg.c:1355
    :1392  simulated msg cmd = DMSG_LNK_ERROR|DELETE
    :1404  kdmsg_msg_receive_handling(msg)
     β†’ kdmsg_state_msgrx                            kern_dmsg.c:1073
       :1077  state->rxcmd |= DMSGF_DELETE          ← rxcmd gets DELETE
     β†’ callback disk_rcvdmsg β†’ disk_blk_read        subr_diskiocom.c:238
       (simulated cmd LNK_ERROR != BLK_READ β†’ reterr=1)
       :385  msg has DELETE:
       :386  iost->count != 0 (I/O in flight) β†’ skip kfree
       :390  kdmsg_msg_reply(msg, error)            ← CALLED UNCONDITIONALLY
         β†’ kdmsg_msg_write β†’ DYING branch           kern_dmsg.c:1974
           β†’ kdmsg_state_cleanuptx                  kern_dmsg.c:1647
             :1672  state->txcmd |= DMSGF_DELETE
             :1673  rxcmd has DELETE β†’ RB_REMOVE     ← removed from rbtree
             :1704  kdmsg_subq_delete(state)
               :1291  state->parent = NULL           ← PARENT CORRUPTED
               :1301  kdmsg_state_drop (subq ref)
             :1707  kdmsg_state_drop (rbtree ref)    ← topology refs dropped
     β†’ kdmsg_state_cleanuprx                         kern_dmsg.c:1236
       :1272  kdmsg_msg_free β†’ kdmsg_state_drop      ← refs β†’ 0 β†’ STATE FREED

3. Async I/O completes β†’ diskiodone                  subr_diskiocom.c:577
   :580  state = bio->bio_caller_info1.ptr           ← FREED/CLEANED state
   :582  iost = state->any.any                       ← stale iost ptr
   :650  kdmsg_msg_alloc(state, cmd, NULL, 0)
     β†’ pstate = state->parent                        ← NULL!
     β†’ KKASSERT(pstate != NULL)                      ← PANIC

The prior verdict's gap

The prior verdict traced the state lifetime correctly for the diskiodone produces the DELETE reply path (eof=1 mode): the state is freed inside the LAST diskiodone's kdmsg_msg_write, after all derefs. That is correct.

But it missed the teardown produces the DELETE reply path (eof=0 mode): when the connection drops, kdmsg_simulate_failure simulates a DELETE receive. The simulated DELETE does NOT match BLK_READ (LNK_ERROR != BLK_READ), so disk_blk_read enters the error path (reterr=1). The error path calls kdmsg_msg_reply(msg, error) unconditionally β€” even when iost->count > 0 (I/O in flight). This sends a DELETE-bearing reply that drives cleanuptx, which tears down the state (parent=NULL, removed from rbtree, topology refs dropped) while the bio still holds a raw state pointer. When the I/O completes, diskiodone dereferences the cleaned-up state β†’ UAF.

The prior verdict stated (incorrectly): "it never bypasses the txcmd |= DELETE requirement." But kdmsg_msg_reply (called from the error path) DOES set txcmd |= DELETE via cleanuptx. The prior verdict did not trace the error-path callback.

Setup

The disk iocom is attached via DIOCRECLUSTER on /dev/vbd0 with one end of an AF_UNIX socketpair (reused from sibling finding DF-0017). The hammer2 userland daemon pre-attaches every disk iocom at boot, so pkill -9 -x hammer2 frees the iocom first. See trigger_race.c for the self-contained trigger.

Trigger position: local root (needs /dev/vbd0 open + DIOCRECLUSTER). The remote vector (authenticated cluster peer sending DELETE while I/O is in flight) is equivalent.

Fix

The fix defers the DELETE reply to diskiodone when I/O is still in flight. In all four disk_blk_* error paths, when iost->count > 0, set iost->eof = 1 instead of calling kdmsg_msg_reply. The last diskiodone (when count reaches 0) sends the DELETE reply itself, after all derefs are done. This keeps the state valid (in rbtree, parent intact) until diskiodone has finished using it.

The finding markdown's proposed fix (uncomment kdmsg_state_hold/drop) does NOT work: those functions are file-local static macros in kern_dmsg.c, invisible to subr_diskiocom.c. Even if exported, the hold/drop alone does NOT prevent the UAF: the teardown's kdmsg_subq_delete sets parent = NULL regardless of the refcount, so diskiodone still reads a corrupted parent. This was confirmed empirically β€” the hold/drop kernel panicked with the same assertion.

Fix validation (Phase 8)

Kernel Version Trigger result
Unpatched baseline #0 Thu Jul 2 06:02:54 UTC 2026 PANIC: pstate != NULL in kdmsg_msg_alloc from diskiodone
Patched (eof=1) #1 Fri Jul 3 10:54:20 UTC 2026 CLEAN: trigger exit 0, blk_active β†’ 0, guest stays up

Stress tests on the patched kernel: 10+ iterations across 3 I/O patterns (1/5/17 I/Os per state), ~30+ diskiodone completions during teardown races, zero panics.

Exploit chain

The primitive is a kdmsg_state_t UAF (~152 bytes, M_DMSG_DISK malloc type, kmalloc-256 bucket). In a real cluster scenario, an authenticated peer controls the timing between the state free (send DELETE or drop connection) and the I/O completion. The freed state's slab slot can be reclaimed by same-type allocations during the teardown (LNK_CONN/LNK_SPAN message processing). On a non-KKASSERT kernel (or with the KKASSERT stripped), the stale parent/iocom dereference would be a wild pointer read β†’ potential code execution via function-pointer corruption in kdmsg_msg_alloc or kdmsg_msg_write. Full exploitation (slab grooming + function pointer override) was not developed beyond the panic reproduction on this single-disk guest.

Files

file purpose
trigger_race.c self-contained trigger: DIOCRECLUSTER + BLK_READ
trigger.c prior trigger (many separate states, one I/O each β€” did not reproduce)
build.sh cc -O2 -o trigger trigger.c -lpthread + race variant
run.sh kills hammer2, runs the race trigger
fix.diff git-apply-able fix: defer DELETE reply to diskiodone when I/O in flight
panic.txt kernel panic signature from boot.log (crash proof)
run.log baseline run (unpatched kernel) β€” SSH timeout, guest panicked
fix_run.log patched kernel run β€” clean exit, no panic
fix_build.log patched kernel build output
env.txt guest uname, cc version, sysctl state
manifest.json machine-readable catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: trigger_race 131072 0 0 1 panicked on the unpatched 6.5-DEVELOPMENT #0 baseline (panic: assertion 'pstate != NULL' in kdmsg_msg_alloc from diskiodone via vtblk_vq_intr) and does NOT on the single-fix #1 kernel (eof=1 deferral: trigger exit 0, blk_active returns to 0, guest stays up across 10+ stress-test iterations with 1/5/17 I/Os per state). The eof=1 fix keeps the state valid (in rbtree, parent intact) until the last diskiodone sends the DELETE reply itself, eliminating the window where diskiodone dereferences a cleaned-up state. => fix closes the bug.

BEFORE (unpatched #0): panic: assertion "pstate != NULL" failed in kdmsg_msg_alloc at kern_dmsg.c:1822 / diskiodone+0x120 / vtblk_vq_intr -- guest DIED.\nAFTER (patched #1 eof=1): FIX2_TRIGGER_EXIT=0, blk_active AFTER: 0, uptime shows guest running, 10+ iterations zero panics -- guest HEALTHY.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Jul 3 10:54:20 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Exploit chain

The primitive is a kdmsg_state_t UAF (~152 bytes, M_DMSG_DISK malloc type, kmalloc-256 bucket). The state is freed by the teardown's simulated DELETE path while async disk I/O is in flight; diskiodone then dereferences the freed/cleaned state. The freed state's parent field is explicitly set to NULL by kdmsg_subq_delete before the free, which trips KKASSERT. On a non-KKASSERT build, the stale parent/iocom/any.any dereferences would be wild pointer reads -- potential code execution via function-pointer corruption in kdmsg_msg_alloc or kdmsg_msg_write. Full slab grooming + function pointer override was not developed beyond the panic reproduction on this single-disk guest; the cluster-peer remote vector (sending DELETE while I/O in flight) is equivalent.

Evidence (decisive lines)

panic: assertion "pstate != NULL" failed in kdmsg_msg_alloc at /usr/src/sys/kern/kern_dmsg.c:1822\ncpuid = 5\nTrace beginning at frame 0xfffff8008ed4c860\nkdmsg_msg_alloc() at kdmsg_msg_alloc+0x267\ndiskiodone() at diskiodone+0x120\nvtblk_vq_intr() at vtblk_vq_intr+0xa7\nvtpci_msix_intr() at vtpci_msix_intr+0x32\nlwkt_serialize_handler_call() at lwkt_serialize_handler_call+0xd3\nDebugger("panic")

PoC changes

Wrote trigger_race.c (new): a self-contained trigger that dispatches a single MAXPHYS BLK_READ|CREATE on msgid=1 (eof=0) and immediately closes the socket, racing the connection-teardown (which frees the state via simulated DELETE) against the in-flight I/O completion (diskiodone). The prior trigger.c created many separate states with one fast I/O each and could not win the race. Kept trigger.c for reference. Updated build.sh/run.sh for the new trigger.

Verified recommended fix

In all four disk_blk_* error paths (subr_diskiocom.c:384/460/511/562), when a DELETE arrives with I/O in flight (iost->count > 0), set iost->eof = 1 instead of calling kdmsg_msg_reply. This defers the DELETE reply to diskiodone, which sends it when count reaches 0 -- after all state derefs are done. SUPERSIDES the finding proposal (uncomment kdmsg_state_hold/drop): those are file-local static macros in kern_dmsg.c that do not compile from subr_diskiocom.c, and even when exported, the hold/drop alone does NOT prevent the UAF because kdmsg_subq_delete sets parent=NULL regardless of refcount (confirmed empirically -- the hold/drop kernel still panicked). The full git-apply-able diff is in findings/poc/DF-0117/fix.diff.

Verdict

REPRODUCED. The prior 'NOT REPRODUCED' verdict had a gap in its teardown analysis: it claimed the state's topology refs prevent freeing during diskiodone, but missed that the connection-drop teardown's simulated DELETE (kern_dmsg.c:1392-1404) triggers the disk callback's error path (subr_diskiocom.c:384-394), which calls kdmsg_msg_reply UNCONDITIONALLY -- even when iost->count > 0 (I/O in flight). That reply drives cleanuptx (kern_dmsg.c:1647), which sets state->parent = NULL (via kdmsg_subq_delete at kern_dmsg.c:1291), removes the state from the rbtree, and drops the topology refs -- all while the bio still holds a raw state pointer at subr_diskiocom.c:372. When the in-flight I/O completes, diskiodone (subr_diskiocom.c:577) dereferences the cleaned-up state, reads state->parent == NULL in kdmsg_msg_alloc (kern_dmsg.c:1822), and panics: 'assertion "pstate != NULL" failed' from diskiodone+0x120 via vtblk_vq_intr. The trigger (BLK_READ|CREATE with MAXPHYS on msgid=1, eof=0, followed by immediate socket close) reproduces this RELIABLY on the unpatched #0 kernel. The 128KB read is slow enough (1-10ms on QCOW2-backed virtio) that the teardown always wins the race.