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)
PoC verification
Evidence pack
findings/poc/DF-2532 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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)
-
Trigger: A BIO with
B_FAILONDISset is issued against an xa device whosespanqis 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 withB_FAILONDIS,subr_diskmbr.c:134). -
xa_strategy()(xdisk.c:799) dispatches the BIO: -xa_setup_cmd()(xdisk.c:846) allocates a tag, setstag->bio = bio(xdisk.c:856). - Callsxa_start(tag, NULL, 1)(xdisk.c:816). -
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 emptyNo valid span βtrans == NULLβgoto skip. -
skip:label (xdisk.c:956):msgis NULL. ChecksB_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) } -
xa_done()(xdisk.c:1006):c KKASSERT(tag->bio == NULL); // β PANIC: tag->bio is non-NULL (xdisk.c:1009) -
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_doneruns 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
fixedVALIDATED: 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.
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.
No comments yet.