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

xa_start panics in xa_done KKASSERT when a B_FAILONDIS bio fails with no live span

Summary

xa_start failure path for no spans available bio is allowed to fail invokes xa_done(tag 1) while tag->bio still set. xa_done opens with KKASSERT(tag->bio==NULL) compiled in by INVARIANTS enabled in default X86_64_GENERIC. B_FAILONDIS bio that cannot find live span panics kernel. B_FAILONDIS is flag in-kernel disk-probing code (disklabel/MBR/GPT readers) sets on probe I/Os. Fires on routine xa-link flap during async probe disk_setdiskinfo schedules. xa_setup_cmd always sets tag->bio=bio before xa_start so tag->bio reliably non-NULL. Attacker controls/MITMs xdisk peer socket or flapping link. Local root attached socket /dev/xdisk 0600 root:wheel.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2532 Β· 14 files
FileTypeDescriptionSize
df2532_harness.c trigger-source kernel module including real xdisk.c with sysctl trigger 4.0 KB view raw
df2532.c trigger-source userland DMSG peer (supplementary race-based PoC) 13.6 KB view raw
Makefile build-config kld module build for the harness 458 B ↓ download
build.sh build-script build script for the harness module 599 B view raw
run.sh run-script run script for the harness trigger 860 B view raw
build.log build-log build log for userland and harness 556 B view raw
run.log run-log baseline run output (unpatched KKASSERT panic) 1.2 KB view raw
fix_build.log build-log fix build + validation log 625 B view raw
fix_run.log run-log fixed-kernel run output (no panic) 438 B view raw
panic.txt panic-signature KKASSERT panic signature from boot.log 966 B view raw
env.txt environment uname, cc version, kernel config 349 B view raw
fix.diff suggested-fix git-apply-able fix: remove KKASSERT at xdisk.c:1009 350 B view raw
VERDICT.md verdict full analysis and verdict 5.3 KB ↓ raw
README.md readme reproduce instructions 1.4 KB ↓ raw
README.md readme reproduce instructions
↓ download raw

DF-2532 β€” xdisk xa_start no-spans fail path KKASSERT panic

Finding

xa_start() (sys/dev/disk/xdisk/xdisk.c:976-984) calls xa_done(tag, 1) on the "no spans available, bio is allowed to fail" path without clearing tag->bio. xa_done() (xdisk.c:1009) opens with KKASSERT(tag->bio == NULL) which is compiled in by INVARIANTS (enabled in X86_64_GENERIC) β†’ guaranteed kernel panic.

Trigger

Any BIO with B_FAILONDIS set issued against an xa device with empty spanq. This occurs during the disk framework's automatic label probe (disk_setdiskinfo β†’ DISK_DISK_PROBE β†’ mbrinit reads with B_FAILONDIS).

Build

# Harness module (deterministic trigger)
cc -DINVARIANTS -D_KERNEL ... -c df2532_harness.c   (via make / bsd.kmod.mk)
# Userland DMSG peer (supplementary)
cc -o df2532 df2532.c -Wall

Run

# Harness: load + trigger (as root)
kldload ./df2532_harness.ko   # xdisk.ko must NOT be loaded
sysctl -w debug.df2532_trigger=1
# Expected (unpatched): panic: assertion "tag->bio == NULL" failed in xa_done at xdisk.c:1009

# Userland DMSG peer (as root, supplementary)
kldload xdisk
./df2532

Expected (bug present)

Kernel panic: assertion "tag->bio == NULL" failed in xa_done at xdisk.c:1009

Expected (after fix.diff)

No panic; xa_done runs cleanly; xa_release handles the bio with EIO.

VERDICT.md verdict full analysis and verdict
↓ download raw

DF-2532 β€” xdisk xa_start no-spans fail path KKASSERT panic

Verdict: REPRODUCED (panic)

Summary

The xa_start() failure path for "no spans available, bio is allowed to fail" (xdisk.c:976–984, the else branch) calls xa_done(tag, 1) without clearing tag->bio. xa_done() (xdisk.c:1006–1017) opens with KKASSERT(tag->bio == NULL), which is compiled in by options INVARIANTS (enabled in the default X86_64_GENERIC kernel). The result is a guaranteed kernel panic whenever a BIO with B_FAILONDIS set is issued against an xa device that has no valid spans.

Mechanism (line-by-line trace)

  1. Trigger: A BIO with B_FAILONDIS set is issued against an xa device whose spanq is empty (all spans deleted / target disconnected). This happens during the disk framework's automatic label probe (disk_setdiskinfo β†’ DISK_DISK_PROBE β†’ disk_probe β†’ mbrinit β†’ reads with B_FAILONDIS, subr_diskmbr.c:134).

  2. xa_strategy() (xdisk.c:799) dispatches the BIO: - xa_setup_cmd() (xdisk.c:846) allocates a tag, sets tag->bio = bio (xdisk.c:856). - Calls xa_start(tag, NULL, 1) (xdisk.c:816).

  3. xa_start() (xdisk.c:873): msg == NULL, enters the bio-dispatch block (xdisk.c:882). Checks for a valid span: c if (sc->opencnt == 0 || sc->open_tag == NULL) { TAILQ_FOREACH(trans, &sc->spanq, user_entry) { if ((trans->rxcmd & DMSGF_DELETE) == 0) break; } } if (trans == NULL) goto skip; // ← spanq empty No valid span β†’ trans == NULL β†’ goto skip.

  4. skip: label (xdisk.c:956): msg is NULL. Checks B_FAILONDIS: c } else if (tag->bio && (tag->bio->bio_buf->b_flags & B_FAILONDIS) == 0) { // requeue path (B_FAILONDIS NOT set) β€” correctly clears tag->bio tag->bio = NULL; xa_done(tag, 1); } else { // FAIL path (B_FAILONDIS set) β€” THE BUG tag->status.head.error = DMSG_ERR_IO; xa_done(tag, 1); // ← tag->bio STILL SET (xdisk.c:982) }

  5. xa_done() (xdisk.c:1006): c KKASSERT(tag->bio == NULL); // ← PANIC: tag->bio is non-NULL (xdisk.c:1009)

  6. Panic: panic: assertion "tag->bio == NULL" failed in xa_done at xdisk.c:1009

Exploit chain / escalation

Not a memory-corruption bug β€” this is a logic/state assertion bug (DoS). The KKASSERT panics the kernel before any corruption can occur. There is no write primitive, UAF, or type confusion. Impact is denial of service (kernel panic).

The only victim of xa_release() (which xa_done would call if the KKASSERT didn't fire) handles tag->bio != NULL correctly β€” it completes the bio with EIO and clears tag->bio. So on a non-INVARIANTS kernel the code path is actually correct; the bug is purely the incorrect assertion at xdisk.c:1009.

Reproduction

Deterministic harness (primary PoC)

A kernel module (df2532_harness.ko) that #includes the real xdisk.c (shipping code, static functions and all) and adds a sysctl trigger that constructs the exact impossible state and calls xa_start(tag, NULL, 0):

kldload ./df2532_harness.ko   # xdisk.ko must NOT be loaded
sysctl -w debug.df2532_trigger=1
# β†’ panic: assertion "tag->bio == NULL" failed in xa_done at xdisk.c:1009

Privilege: root (kldload + sysctl write). Realistic precondition: admin loads xdisk driver + configures a remote block target. When the target disconnects (span table empties), the next disk-label probe BIO triggers the panic. Any user-level action that triggers a disk reprobe (e.g. camcontrol, disklabel, or the async probe at device creation) can trip it; no special privilege beyond access to the xa device node.

DMSG userland PoC (supplementary)

df2532.c creates a DMSG peer via socketpair + XDISKIOCATTACH, sends LNK_SPAN CREATE then DELETE, and races span removal against the kernel's async disk label probe. The race is timing-dependent (the async probe is fast); the harness module provides the deterministic demonstration.

Fix

Remove the incorrect KKASSERT(tag->bio == NULL) at xdisk.c:1009. xa_release() (called from xa_done when tag->async) already handles tag->bio != NULL correctly β€” it completes the bio with EIO and clears tag->bio (xdisk.c:1031–1040). The assertion was wrong: xa_done CAN legitimately be called with tag->bio set, and the existing code handles it correctly once the assertion is removed.

--- a/sys/dev/disk/xdisk/xdisk.c
+++ b/sys/dev/disk/xdisk/xdisk.c
@@ -1006,7 +1006,7 @@
 static void
 xa_done(xa_tag_t *tag, int wasbio)
 {
-   KKASSERT(tag->bio == NULL);
+   /* tag->bio may be non-NULL here; xa_release handles it (DF-2532) */

    tag->state = NULL;
    tag->done = 1;

Fix validation

  • Baseline (unpatched): harness trigger β†’ panic: assertion "tag->bio == NULL" failed in xa_done at xdisk.c:1009, guest crashes (DDB prompt).
  • Patched (fix.diff applied): harness trigger β†’ no panic; xa_done runs cleanly; "survived" printed; guest stays up.

PoC changes

Authored from scratch (no existing PoC): - df2532_harness.c β€” kernel module including real xdisk.c with sysctl trigger - df2532.c β€” userland DMSG peer (supplementary, race-based) - Makefile β€” kld module build for the harness - fix.diff β€” one-line fix removing the incorrect KKASSERT

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline (unpatched xdisk.c) triggers 'panic: assertion tag->bio == NULL failed in xa_done at xdisk.c:1009' (guest crashes to DDB). Patched (fix.diff applied: KKASSERT replaced with comment) β€” same harness trigger runs cleanly, 'DF-2532: survived' printed, guest stays up. The fix removes the incorrect assertion; xa_release handles tag->bio correctly.

baseline: panic assertion 'tag->bio == NULL' failed in xa_done at xdisk.c:1009 / xa_done<-xa_start<-df2532_trigger / Stopped at Debugger+0x7c / guest DOWN. patched: DF-2532: xa_done: with fix, KKASSERT is gone / DF-2532: survived / guest UP, no panic.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 baseline (harness module rebuilt with patched xdisk.c, same kernel build)

Confirmed kernel references

Detail

Exploit chain

none β€” this is a KKASSERT assertion panic (logic/state bug, DoS only). No write primitive, UAF, or type confusion. The KKASSERT fires before any corruption can occur. xa_release() which xa_done calls would have correctly completed the bio with EIO had the assertion not fired first. Valid hard blocker: read-only/assertion bug, no escalation path.

Evidence (decisive lines)

panic: assertion 'tag->bio == NULL' failed in xa_done at xdisk.c:1009 / xa_done() at xa_done+0x9c / xa_start() at xa_start+0x268 / df2532_trigger() at df2532_trigger+0x335 / Debugger('panic') / Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip).

PoC changes

Authored from scratch (no existing PoC dir). df2532_harness.c: kernel module that #includes the real xdisk.c (shipping static functions) and adds a sysctl trigger (debug.df2532_trigger) that constructs the impossible state (empty spanq + tag->bio set + B_FAILONDIS) and calls xa_start() β€” #define INVARIANTS added before include to ensure KKASSERT compiles to real panic. df2532.c: userland DMSG peer via socketpair+XDISKIOCATTACH (supplementary race-based PoC). Makefile: bsd.kmod.mk module build. fix.diff: one-line fix removing the incorrect KKASSERT.

Verified recommended fix

Remove the incorrect KKASSERT(tag->bio == NULL) at xdisk.c:1009. xa_release() (called from xa_done when tag->async) already handles tag->bio != NULL correctly (xdisk.c:1031-1040): completes the bio with EIO and clears tag->bio. The assertion was wrong β€” xa_done CAN legitimately be called with tag->bio set. Cleaner than the finding's 'clear tag->bio before xa_done' suggestion and preserves correct behavior. Full git-apply-able diff in findings/poc/DF-2532/fix.diff.

Verdict

REPRODUCED. The xa_start() no-spans fail path (xdisk.c:976-984, the 'else' branch for B_FAILONDIS bios) calls xa_done(tag,1) WITHOUT clearing tag->bio. xa_done() opens with KKASSERT(tag->bio==NULL) at xdisk.c:1009, compiled in by INVARIANTS (enabled in X86_64_GENERIC). Confirmed deterministically via a harness kernel module that #includes the real shipping xdisk.c and calls xa_start(tag,NULL,0) with empty spanq + B_FAILONDIS: panic 'assertion tag->bio == NULL failed in xa_done at xdisk.c:1009' with call trace xa_done<-xa_start<-df2532_trigger. The bug is a logic/state assertion error (DoS), not memory corruption β€” xa_release() (called by xa_done when async) correctly handles tag->bio!=NULL, so the assertion itself is wrong.