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

xa_close leaks xdisk_lk and releases wrong lock on raced-destruction defensive paths

Summary

Two defensive early-return paths in xa_close copied from xa_open but mistyped: both return while still holding xdisk_lk and terminating-path branch releases sc->lk (never acquired) instead of xdisk_lk. Result: leaked exclusive xdisk_lk deadlocking every subsequent xdisk operation OR lockmgr corruption/panic from releasing unowned lock. Correct pattern visible in xa_open lines 703-715. [VERIFIED pass2: reproduced both - si_drv1 NULL path returns holding exclusive xdisk_lk with probe thread wedged forever on the leaked lock, terminating path releases never-acquired sc->lk panicking lockmgr LK_RELEASE no lock held; D_TRACKCLOSE makes the raced path reachable by any node holder; unlock-pattern fix-validated]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2533 Β· 14 files
FileTypeDescriptionSize
df253x_harness.c β€” 15.7 KB view raw
build.sh β€” 207 B view raw
run.sh β€” 218 B view raw
run.log β€” 542 B view raw
run.patched.log β€” 864 B view raw
panic.txt β€” 3.2 KB view raw
serial_console.baseline.log β€” 14.0 KB view raw
fix_build.log β€” 284 B view raw
fix_run.log β€” 1.8 KB view raw
sha256_gate.log β€” 65 B view raw
env.txt β€” 984 B view raw
fix.diff β€” 694 B view raw
VERDICT.md β€” 5.4 KB ↓ raw
verdict.json β€” 4.5 KB view raw
VERDICT.md
↓ download raw

VERDICT β€” DF-2533 (REPRODUCED: kernel panic + permanent lock leak)

Bottom line

Both defensive early-return paths in xa_close are defective exactly as claimed, and both defects were mechanically demonstrated by driving the REAL xa_close (verbatim, sha256-gated, compiled into the guest kernel) into each path with fabricated raced-destruction state:

  1. Path 1 β€” si_drv1 == NULL (xdisk.c:769-770): returns holding the exclusively-acquired xdisk_lk (leak). Observed: xa_close returned 6 (ENXIO) and xdisk_lk lockstatus=2 after return (LK_EXCLUSIVE=2 => EXCLUSIVE LOCK LEAKED). A probe thread running the REAL xa_open on another CPU wedged forever on the leaked lock (started=1 finished=0 => STUCK FOREVER) β€” the leak deadlocks every later xdisk subsystem operation: xdisk_open, xdisk_close, xdisk_ioctl (XDISKIOCATTACH), xa_open, xa_close, xaio_rcvdmsg (every dmsg rx dispatch), xa_terminate_check callers. The harness released the lock afterwards only to keep the guest alive; a production caller has no owner to release it: the leak is permanent.

  2. Path 2 β€” sc->terminating (xdisk.c:771-773): releases sc->lk, a lock that was NEVER acquired, while still holding xdisk_lk. Observed: unconditional kernel panic panic: lockmgr: LK_RELEASE: no lock held backtrace lockmgr_release() <- xa_close() at xa_close+0x183 β€” kern_lock.c:768 panics on any release of a lock with no holders; this is NOT INVARIANTS-gated, so production kernels panic too. Pre-call state printed: sc->lk.lk_count=0x0 (lockinit leaves count 0). Had a kernel not panicked, xdisk_lk would additionally be leaked on this path as well.

The correct pattern exists 60 lines above in xa_open (:703-715): both of its defensive paths lockmgr(&xdisk_lk, LK_RELEASE) before returning.

Production reachability (honest quantification)

  • The device ops carry D_TRACKCLOSE (xdisk.c:188), so devfs dispatches d_close for EVERY close of a /dev/xa%d node, not just the last (sys/vfs/devfs/devfs_vnops.c:1104/1170). Two processes holding the node open: peer drops the span AND the first closer drives opencnt to 0 β†’ xa_terminate_check frees the softc and NULLs si_drv1 (:665-680) under xdisk_lk β†’ the second closer enters xa_close, acquires xdisk_lk, reads si_drv1 == NULL and takes the leak path. A variant: xa_open's BLK_OPEN error unwind (:749-753) runs xa_terminate_check while devfs still owes the error-path d_close.
  • The terminating branch requires observing si_drv1 != NULL together with sc->terminating == 1; in the current code both transitions happen under xdisk_lk inside xa_terminate_check, so this branch is defensive (dead today) β€” but it is a booby trap: any future reordering (e.g. moving the terminating = 1 store, or splitting the lock) turns it into the panic above. The panic was verified mechanically with fabricated state.
  • Threat model (per team directive): xdisk devices are created by root via dmsg attach, but the resulting /dev/xa%d nodes are ordinary block devices; ANY holder of an open node can trigger xa_close at the moment the raced destruction state arises. Impact ceiling: permanent subsystem wedge of all xd* devices plus the dmsg rx machinery (local DoS), and β€” on the terminating path β€” an unconditional kernel panic. No memory-corruption primitive: no uid=0 chain exists for this bug class; the escalation ceiling is denial of service.

Exploit chain

Not memory corruption. Chain is: (unprivileged holder of an xa node) close(2) concurrent with span-loss teardown β†’ leaked exclusive xdisk_lk β†’ every subsequent xdisk/dmsg operation sleeps forever (xaopen/xawait stuck threads pile up) β†’ subsystem-wide local DoS; or the terminating variant β†’ instant node panic.

Fix validation (fixed)

fix.diff mirrors the xa_open pattern: both defensive paths release xdisk_lk; the terminating branch no longer touches sc->lk. Applied in guest /usr/src (both DF-2533+DF-2534 diffs, combined tree sha256 15d189799d499d96ee6bbb5796ef3b3c5e316fce06d15507a47776119ae43385), kernel rebuilt (#3 Mon Oct 19 21:13:06) and the EXACT same triggers re-run:

  • baseline: lockstatus=2 after xa_close return, probe thread stuck; patched: lockstatus=0, probe completes (run.patched.log)
  • baseline: panic: lockmgr: LK_RELEASE: no lock held; patched: clean ENXIO return, no panic, sc->lk.lk_count=0x0 untouched

PoC notes

  • The harness kernels were built from the verbatim guest source (sha256 gate in build.sh, hash recorded in env.txt/sha256_gate.log); only the external registration calls were interposed via pre-include #define renames β€” xa_close/xa_open machine code is the shipping code.
  • The probe thread calls the real xa_open, not a stubbed lock take.
  • Harness iterations: first race-hammer version starved a CPU (LWKT cooperative scheduler) and wedged the guest mid-run; fixed with periodic 1-tick tsleep in the hammer loops (harness-only artifact, unrelated to the finding).

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

not_testable
↓ fix.diffper-fix-DF-2533

Confirmed kernel references

Detail

Exploit chain

Unprivileged holder of a root-created /dev/xa%d node close(2)s concurrently with span-loss teardown: a first closer (or the xa_open BLK_OPEN error unwind) drives opencnt to 0 so xa_terminate_check frees the softc and NULLs si_drv1 under xdisk_lk; D_TRACKCLOSE makes devfs dispatch d_close for every close, so the raced second close enters xa_close, reads si_drv1==NULL and returns holding xdisk_lk exclusively and permanently: every subsequent xdisk_open/xdisk_close/xdisk_ioctl/xa_open/xa_close/xaio_rcvdmsg blocks forever (xaopen/xawait threads pile up; root cannot even attach or detach anymore). The terminating variant instead panics the node instantly (lockmgr LK_RELEASE of a never-held lock). Impact ceiling: local DoS / kernel panic.

Evidence (decisive lines)

run.log (lockstatus=2 LEAKED + probe STUCK FOREVER lines), panic.txt (panic: lockmgr: LK_RELEASE: no lock held; xa_close+0x183 backtrace; sc->lk.lk_count=0x0 printed pre-call), run.patched.log (lockstatus=0, probe finished=1, no panic), fix_build.log, sha256_gate.log, env.txt

PoC changes

Authored the harness fresh (no seed): includes verbatim xdisk.c with pre-include #define interposition of make_dev/destroy_dev/dev_ops_remove_all/disk_destroy/devstat_remove_entry; fabricated kmalloc cdev + xa_softc; sysctl triggers call the real xa_close/xa_open; lockstatus probe + real-xa_open watchdog thread prove the leak; kernel-compiled via conf/files because runtime kldload GPFs on this guest. Two harness-only fixes during bring-up: dropped static on interposed stubs (-Werror prototype mismatch) and nothing else; the DF-2534 half of the harness needed periodic tsleep (see DF-2534 verdict).

Verified recommended fix

In xa_close, release xdisk_lk (not sc->lk) on both raced-destruction early-return paths, mirroring xa_open:703-715.

Verdict

Both defensive paths of the real xa_close (verbatim sha256-gated xdisk.c compiled into the guest kernel) are defective exactly as claimed. Path si_drv1==NULL (xdisk.c:769-770) returns ENXIO still holding the exclusive xdisk_lk: observed lockstatus=2 after return and a probe thread running the real xa_open stuck forever on the leaked lock, wedging every later xdisk/dmsg operation (permanent subsystem DoS). Path sc->terminating (xdisk.c:771-773) releases sc->lk, a lock that was never acquired, while still holding xdisk_lk: observed the unconditional kern_lock.c:768 panic (lockmgr: LK_RELEASE: no lock held) with backtrace lockmgr_release <- xa_close, on the stock INVARIANTS kernel and on any kernel since the check is not ifdef-gated. Production reachability of the NULL path is real (D_TRACKCLOSE per-close dispatch in devfs plus terminate_check freeing under xdisk_lk); the terminating branch is currently dead-defensive but is a booby trap. Escalation ceiling honestly quantified: panic / permanent subsystem wedge, no memory-corruption primitive, no uid=0 chain. Fix mirrors the xa_open pattern; A/B validated on a rebuilt kernel: lock released, no panic, probe completes.