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

xa_strategy / xa_size dereference sc without lifetime lock racing xa_terminate_check free

Summary

Both xa_strategy and xa_size dereference sc=dev->si_drv1 without taking xdisk_lk. xa_terminate_check (run under xdisk_lk) frees sc at :680 and NULLs si_drv1 at :665. xa_size NULL-checks but then dereferences sc->info.d_media_blocks without lock classic check/use TOCTOU. xa_strategy reads si_drv1 at :801 immediately dereferences sc->stats at :805 with neither NULL check nor lock. disk_destroy synchronous so strategy path largely drained before si_drv1 NULLed making trigger narrow but xa_size has no such protection. [VERIFIED pass2: reproduced - 664 UAF reads captured in 157M real xa_size iterations vs 51.5M frees each observing a value planted only after the kfree, strategy NULL write-fault at 0x90 in devstat_start_transaction; production-reachable via diskpsize DSO_RAWPSIZE re-dispatch with no drain; groomable 8-byte heap disclosure as device size; lock-plus-recheck fix-validated 0 UAF reads over 82M iterations]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2534 Β· 12 files
FileTypeDescriptionSize
df253x_harness.c β€” 15.7 KB view raw
build.sh β€” 140 B view raw
run.log β€” 3.0 KB view raw
run.patched.log β€” 833 B view raw
panic.txt β€” 2.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
env.txt β€” 984 B view raw
fix.diff β€” 1.6 KB view raw
VERDICT.md β€” 5.2 KB ↓ raw
verdict.json β€” 4.7 KB view raw
VERDICT.md
↓ download raw

VERDICT β€” DF-2534 (REPRODUCED: use-after-free READ window in real xa_size; NULL-write panic in xa_strategy)

Bottom line

Both cited defects are real and were mechanically demonstrated against the REAL functions (verbatim sha256-gated xdisk.c compiled into the guest kernel, fabricated cdev/softc, interposed disk/dmsg externals):

  1. xa_size TOCTOU/UAF read (production-reachable window). xa_size reads sc = a_dev->si_drv1 at :833, NULL-checks it, then dereferences sc->info.d_media_blocks at :835 β€” with NO lock. xa_terminate_check NULLs si_drv1 at :665 and kfree(sc)s at :680 under xdisk_lk; a reader that loaded the pointer before the NULL-store dereferences freed memory after it. Hammer proof: cpu0 thread in the real xa_size vs cpu1 thread driving the real xa_terminate_check:

    reader xa_size iterations: 157,024,256 (151M on the safe ENXIO path) terminator frees: 51,550,501 (100% same-address LIFO reuse) UAF READS CAPTURED: 664

Each capture is airtight: the reader observed 0xC0FFEE00DF2534 β€” a value that was only ever written into the slot AFTER kfree(sc) and re-allocation, so the :835 load provably landed after the free (a stale-pointer dereference of freed kernel heap through shipping code). First capture arrived within milliseconds of start.

  1. xa_strategy missing NULL check (write-fault panic). :801 loads sc = a_dev->si_drv1 with no check and :805 passes &sc->stats to devstat_start_transaction β†’ with sc == NULL: Fatal trap 12 ... fault virtual address = 0x90, supervisor write data at devstat_start_transaction+0xa: lock xaddl %eax,0x58(%rdi) β€” 0x90 == offsetof(xa_softc, stats) + 0x58, i.e. a write through the NULL softc exactly as coded. Demonstrated with fabricated state.

Honest narrowness quantification

  • xa_size is the live production window. psize is not drained by destruction: diskpsize (sys/kern/subr_disk.c:1259-1276) re-dispatches dev_doperate to the raw device when dssize() returns -1 and the disk sets DSO_RAWPSIZE β€” which xdisk sets (xdisk.c:491). dev_doperate (kern_device.c:544) has no closing/refs gate; it dispatches straight through si_ops. So a psize racing span-loss teardown reads freed heap. What the primitive yields: an 8-byte read at a fixed offset of a freed M_XDISK slab slot, returned to the caller as the device size (via dssize/DIOCGPART paths) β€” an attacker-groomable kernel heap info-leak channel (spray an object into the freed slot, read its content at that offset). It is a READ: no control-flow or write primitive; uid=0 is not reachable from this bug alone.
  • xa_strategy is practically narrowed by destruction ordering: xa_terminate_check calls disk_destroy at :663 BEFORE NULLing si_drv1 at :665, and disk_destroyβ†’destroy_dev drains device references; xa_terminate_check also refuses to free while bioq/tag_pendq are non-empty (:646-647). So a NULL sc at strategy entry requires a caller that slipped past the drain β€” the NULL-write panic demonstration is fabricated-state (post-drain) and labeled as such; its value is proving the code has no defense at all if the drain assumption ever breaks.

Threat model (per team directive)

xdisk devices are created by root via dmsg; afterwards the /dev/xa%d nodes are ordinary disks. Whoever can open the node (permissions permitting) can race lseek/size-query paths against span teardown by an unprivileged-triggerable disconnect. Ceiling: 8-byte heap disclosure per hit (Low), repeatable at will; not an escalation primitive by itself.

Fix validation (fixed)

fix.diff takes xdisk_lk and re-checks si_drv1 under it in both functions, matching the file's own idiom (the same lock xa_terminate_check uses to NULL+free). In xa_strategy the bio is queued before the lock is dropped so xa_terminate_check's empty-queue precondition protects the softc; lock order xdisk_lk -> sc->lk matches xaio_rcvdmsg. Applied in guest /usr/src together with DF-2533's fix (combined tree sha256 15d189799d499d96ee6bbb5796ef3b3c5e316fce06d15507a47776119ae43385), kernel rebuilt (#3 Mon Oct 19 21:13:06), EXACT same triggers re-run:

  • race: 82,313,216 xa_size iterations vs 30,146,560 frees (100% same-address reuse) β†’ 0 UAF reads (baseline: 664)
  • strategy NULL: clean return 0, bio completed with b_error=6 (ENXIO), b_flags=0x800 (B_ERROR) via biodone β€” no fault

PoC notes

  • The magic-value method cannot false-positive: si_drv1 never points at the re-allocated block while set, and the planted value exists only after the free.
  • The hammer threads periodically tsleep 1 tick (a spinning kernel thread starves its CPU under the cooperative LWKT scheduler β€” harness artifact, not part of 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-2534

Confirmed kernel references

Detail

Exploit chain

Root creates an xa device via dmsg; any holder of the /dev/xa%d node can race size queries (dssize/DIOCGPART paths reach xa_size via diskpsize raw re-dispatch) against span-loss teardown. The reader captures sc from si_drv1 before xa_terminate_check NULLs it, then dereferences after the free: it reads whatever now occupies the freed M_XDISK slab slot (8 bytes at offsetof d_media_blocks) and that value is returned to userspace as the device size. Grooming the freed slot with a chosen kernel object converts this into a repeatable fixed-offset heap info leak. Read-only primitive; escalation ceiling is information disclosure, not code execution.

Evidence (decisive lines)

run.log (DF2534-RACE summary: 157024256 iterations, 664 UAF READS CAPTURED; DF2534-S Fatal trap 12 at 0x90 in devstat_start_transaction+0xa), panic.txt (full trap dump), run.patched.log (0 UAF reads; xa_strategy returned 0 with biodone b_error=6 b_flags=0x800), fix_build.log, env.txt

PoC changes

Authored the race harness fresh (no seed): magic-value method (plant 0xC0FFEE00DF2534 into the freed slot after same-address reallocation; si_drv1 never points at the reused block, so observation cannot false-positive). Two bring-up fixes: lwkt_yield() did not prevent CPU starvation under the cooperative LWKT scheduler (spinning kthread never gives the cpu back to user threads) - replaced with a 1-tick tsleep every 256K iterations; first frozen run still captured 3 UAF reads before the wedge (serial_console.baseline.log), final run completed with the full summary and the guest healthy.

Verified recommended fix

Take xdisk_lk and re-check si_drv1 under it in xa_size and xa_strategy (queue the bio before dropping the lock), matching the lifetime lock xa_terminate_check already uses.

Verdict

Both cited missing-lifetime-lock defects confirmed against the real functions (verbatim sha256-gated xdisk.c compiled into the guest kernel). (1) xa_size:833-835 NULL-checks si_drv1 then dereferences sc->info.d_media_blocks with no lock while xa_terminate_check NULLs si_drv1 (:665) and kfree(sc)s (:680) under xdisk_lk. Hammer race with the real xa_size on cpu0 and the real xa_terminate_check on cpu1: 157,024,256 reader iterations vs 51,550,501 frees (100 percent same-address slab reuse), 664 UAF READS captured, each proven by observing 0xC0FFEE00DF2534, a value planted only AFTER the kfree plus reallocation, so the deref provably landed on freed heap through the stale pointer. Production window quantified: diskpsize re-dispatches psize to the raw device (DSO_RAWPSIZE set by xdisk at :491; subr_disk.c:1273) and dev_doperate has no drain, so psize genuinely races teardown; primitive is an attacker-groomable 8-byte fixed-offset kernel heap disclosure returned as the device size (info leak, Low ceiling, no write/control-flow primitive, no uid=0 chain). (2) xa_strategy:801/805 has no NULL check at all: with si_drv1 NULL it write-faults at 0x90 in devstat_start_transaction (Fatal trap 12, supervisor write) - demonstrated with fabricated state and honestly labeled post-drain-only in production because disk_destroy at :663 drains strategy before si_drv1 is NULLed at :665. Fix takes xdisk_lk and re-checks si_drv1 under it in both functions; A/B validated: 0 UAF reads over 82M iterations on the patched kernel and clean ENXIO biodone from strategy.