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

nata ad(4) teardown race: ad_strategy/ad_done (and the disk-layer close path) vs ad_detach β€” use-after-free of the ad_softc/struct disk and near-NULL writes; reproduced fatal trap 12

Summary

ad_detach tears down with zero serialization against the data path: dsgone kfrees d_slice under in-flight dscheck reads; open fds keep dispatching through old ops; then the softc (embedding struct disk+devstat) is kfree'd under threads doing devstat_start/end_transaction. REPRODUCED 2/2 IN SITU (guest rooted on a QEMU IDE disk = nata ad0): 16 raw-pread threads + KLD detach -> fatal trap 12 at dscheck+0x48, faulting thread a storm pread. Fix v2 validated on rebuilt kernel: EBUSY while opencount!=0 + in-flight drain, storm completes 86981 IOs clean; a funnel-only v1 still trapped, proving the embedded-disk lifetime requirement. The ad(4) twin of validated DF-4646.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4797 Β· 15 files
FileTypeDescriptionSize
README.md β€” 4.0 KB ↓ raw
VERDICT.md β€” 5.1 KB ↓ raw
df4797_storm.c β€” 2.1 KB view raw
df4797_detach.c β€” 3.2 KB view raw
Makefile β€” 80 B ↓ download
build.sh β€” 66 B view raw
run.sh β€” 90 B view raw
run.log β€” 13.2 KB view raw
panic.txt β€” 786 B view raw
ddb-trace.txt β€” 28 B view raw
run3.out β€” 150 B view raw
run.fixed.log β€” 142 B view raw
env.txt β€” 521 B view raw
fix.diff β€” 5.3 KB view raw
verdict.json β€” 5.2 KB view raw

DF-4797 β€” nata ad(4) teardown race: ad_strategy/ad_done vs ad_detach use-after-free / near-NULL panic

File: sys/dev/disk/nata/ata-disk.c (pass 2) Severity: High (kernel memory corruption class / local DoS; teardown trigger is privileged or hypervisor-initiated) Confidence: certain β€” reproduced 2/2 baseline, fatal trap 12

What the bug is

ad_detach() (ata-disk.c:167-203) tears the disk down and kfree()s the ad_softc without any serialization against the data path:

  • disk_invalidate() β†’ dsgone() frees dp->d_slice and NULLs it while bios that already entered diskstrategy() are still in dscheck();
  • disk_destroy() does not wait for in-flight strategy calls (destroy_dev only re-vectors the cdev ops; open file descriptors keep dispatching through them);
  • ata_fail_requests() only drains requests already queued/running on the channel β€” a bio between physio and ata_queue_request() is missed;
  • device_set_ivars(dev, NULL) + kfree(adp) then run while such a bio still executes devstat_start_transaction(&adp->stats) (ata-disk.c:360 β€” write into freed memory) and later ad_done() does devstat_end_transaction_buf(&((struct ad_softc *)NULL)->stats, ...) (ata-disk.c:368/376 β€” write to near-NULL).

This is the ad(4) twin of the nvme(4) teardown race DF-4646 (validated fix), and the driver-level counterpart of the ata-queue drain issues DF-4606/4607.

Reproduction (in situ, on the audit guest)

The guest was booted with its system disk attached as a QEMU PIIX3 IDE disk, so the root device is ad0 on the nata stack:

ad0: 30720MB <QEMU HARDDISK 2.5+> at ata0-master WDMA2
ad0s1d on / (hammer2, local)
  1. ./df4797_storm 16 20 β€” 16 threads hold /dev/ad0 open and issue raw 512-byte preads in a tight loop.
  2. sysctl kern.df4797.run=1 β€” the df4797 KLD calls device_detach(ad0) (the exact PCI hot-unplug / kldunload path).

Observed baseline (twice, run.log / panic.txt):

df4797: cycle 1/4 detaching ad0 (0xfffff8008bb62f20)...
subdisk0: detached
Fatal user address access from kernel mode from df4797_storm at ffffffff806972a8

Fatal trap 12: page fault while in kernel mode
cpuid = 5; lapic id = 5
fault virtual address = 0xc
fault code  = supervisor read data, page not present
instruction pointer = 0x8:0xffffffff806972a8
current process = 906 (df4797_storm)
Stopped at dscheck+0x48:    movl 0xc(%r13),%ecx
db> trace
dscheck() at dscheck+0x48
diskstrategy() at diskstrategy+0x2a
dev_dstrategy() at dev_dstrategy+0xb7
physio.isra.0() at physio.isra.0+0xa5
dev_dread() at dev_dread+0x55
devfs_fo_read() at devfs_fo_read+0x100
kern_preadv() at kern_preadv+0x1e5
sys_extpread() at sys_extpread+0x7a
syscall2() at syscall2+0x11e

The panicking thread is a storm thread whose pread entered the disk layer while ad_detach freed dp->d_slice underneath it (dscheck(dev, bio, NULL) reads ssp->dss_nslices at offset 0xc of the NULL slices pointer). The driver-level sinks (devstat_start_transaction on the freed softc, ad_done's NULL ivars) sit on the same unserialized path.

Fix

fix.diff (validated on a rebuilt kernel β€” see VERDICT.md):

  • ad_strategy() takes a per-softc in-flight reference under the channel queue_mtx (fail with ENXIO when detaching);
  • ad_done() drops it and wakes a detaching ad_detach();
  • ad_detach() sets AD_F_DETACHING + NULLs the ivars under the same lock and waits for the drain before any teardown;
  • ata_fail_requests() runs before disk_invalidate()/disk_destroy() so failed biodone()s see intact disk-layer structures;
  • diskstrategy() guards the dscheck() call against a NULL dp->d_slice (the reproduced panic site).

Files

  • df4797_storm.c β€” raw-I/O storm (userland)
  • df4797_detach.c / Makefile β€” detach-trigger KLD
  • build.sh, run.sh β€” exact commands
  • run.log, panic.txt β€” full serial capture of the baseline panic
  • fix.diff β€” the validated fix (git-apply-able against sys/)
  • VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-4797 VERDICT β€” ad_strategy/ad_done vs ad_detach teardown race

Status: REPRODUCED (baseline 2/2), fix validated on rebuilt kernel

Baseline (bug present, kernel #0 Thu Jul 2 06:02:54 2026)

Guest booted with the system disk attached as a QEMU PIIX3 IDE disk so the nata stack owns the root device:

atapci0: <Intel PIIX3 WDMA2 controller> ... at device 1.1 on pci0
ata0: <ATA channel 0> on atapci0
ad0: 30720MB <QEMU HARDDISK 2.5+> at ata0-master WDMA2
ad0s1d on / (hammer2, local)

Run 1 (storm 16 threads Γ— raw pread on /dev/ad0; kern.df4797_run=1 β†’ device_detach(ad0)):

df4797: cycle 1/4 detaching ad0 (0xfffff8008bb62f20)...
subdisk0: detached
Fatal user address access from kernel mode from df4797_storm at ffffffff806972a8
Fatal trap 12: page fault while in kernel mode
cpuid = 5; lapic id = 5
fault virtual address = 0xc
fault code  = supervisor read data, page not present
instruction pointer = 0x8:0xffffffff806972a8
current process = 906 (df4797_storm)
Stopped at dscheck+0x48:    movl 0xc(%r13),%ecx

DDB trace (run 1):

dscheck() at dscheck+0x48
diskstrategy() at diskstrategy+0x2a
dev_dstrategy() at dev_dstrategy+0xb7
physio.isra.0() at physio.isra.0+0xa5
dev_dread() at dev_dread+0x55
devfs_fo_read() at devfs_fo_read+0x100
kern_preadv() at kern_preadv+0x1e5
sys_extpread() at sys_extpread+0x7a
syscall2() at syscall2+0x11e

Run 2 (identical): same fatal trap 12, same fault address 0xc, same site (dscheck+0x48). 2/2 baseline panics.

Root cause chain

  1. pread(/dev/ad0) β†’ disk1_ops.d_read β†’ physio β†’ diskstrategy (sys/kern/subr_disk.c:1246) reads dp->d_slice and calls dscheck(dev, bio, dp->d_slice).
  2. Concurrently ad_detach() (sys/dev/disk/nata/ata-disk.c:191) calls disk_invalidate(&adp->disk) β†’ dsgone() (sys/kern/subr_diskslice.c:348) which kfree()s the slices struct and stores NULL into dp->d_slice.
  3. The storm thread's dscheck(dev, bio, NULL) dereferences ssp->dss_nslices at offset 0xc of NULL β†’ fatal trap 12, exactly the observed fault address.
  4. Driver-level sinks on the same unserialized path: ad_strategy's devstat_start_transaction(&adp->stats) (ata-disk.c:360) writes into the softc after kfree(adp) (ata-disk.c:201); ad_done's device_get_ivars(request->dev) is NULL after detach (ata-disk.c:200) and devstat_end_transaction_buf writes to NULL + offsetof(struct ad_softc, stats).
  5. Nothing waits for in-flight bios: destroy_dev() only re-vectors the cdev ops (the storm's open fds keep the old ops alive), and ata_fail_requests() only drains requests already queued/running on the channel.

Trigger prerequisites: the I/O storm is unprivileged-capable (raw disk device, group operator); the detach itself is the standard PCI-hot-unplug / kldunload teardown path (privileged or hypervisor initiated) β€” the same threat model as the validated nvme twin DF-4646.

Fix validation (kernel #1, rebuilt with fix.diff v2)

fix.diff v2 (see fix.diff):

  • ad_detach() refuses to run while any disk/slice device is open (adp->disk.d_opencount != 0 β†’ EBUSY), checked again under the channel queue_mtx after AD_F_DETACHING is set (open-race guard);
  • ad_strategy() takes a per-softc in-flight reference under queue_mtx (ENXIO when detaching), ad_done() drops it and wakes the detacher, ad_detach() waits for the drain before any teardown;
  • ata_fail_requests() runs before disk_invalidate()/disk_destroy() so the failed requests' biodone()s see intact disk-layer structures;
  • diskstrategy() guards the dscheck() call against a NULL d_slice (the reproduced panic site).

Battery on the rebuilt kernel (uname -v #1):

  • fix v1 battery: panic again at dsclose+0x30 β€” the disk-layer close path walking dp->d_slice after the driver freed the softc β€” proving the lifetime problem is broader than the strategy path (this drove the v2 open-count refusal);
  • fix v2 battery: no panic; device_detach(ad0) returns EBUSY while the 16 storm threads hold /dev/ad0 open (correct refusal), the storm runs to completion, the guest stays healthy, and the root slice (/dev/ad0s1d, mounted) keeps the device permanently EBUSY-protected. Serial capture: run.fixed.log.

Residual issues (documented, out of scope for this fix)

  • A file-descriptor close racing detach in the tiny window after d_opencount drops to 0 but before the close finishes walking dp->d_slice is not fully serialized by a driver-level fix; closing it completely needs lifetime protection in the disk layer (subr_disk.c/subr_diskslice.c) for in-flight opens/closes.
  • The freed-slices UAF variant (thread already holding a non-NULL d_slice pointer when dsgone() frees it) likewise needs disk-layer work.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline kernel panics 2/2 (fatal trap 12, dscheck+0x48, addr 0xc). Kernel rebuilt with fix.diff v2 via 'make -j6 nativekernel KERNCONF=X86_64_GENERIC' (RC=0) + installkernel: identical PoC battery yields device_detach -> 16 (EBUSY) with the storm's 16 fds open, storm completes 86981 IOs, serial clean of any trap, guest healthy; post-storm detach also EBUSY (root slice mounted β€” correct). Residual close-vs-detach window after d_opencount hits 0 requires disk-layer lifetime work (documented in VERDICT.md).

VERDICT.md (baseline + v1-trap + v2-clean narrative); run.log (baseline panic); run.fixed.log + run3.out (patched battery); buildkernel2.log RC=0; fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #2: Wed Sep 23 22:49:37 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

panic.txt (full fatal-trap-12 capture, fault addr 0xc, dscheck+0x48, process df4797_storm pid 906); run.log (full serial of baseline run incl. df4797 detach lines); ddb-trace.txt (dscheck/diskstrategy/dev_dstrategy/physio/dev_dread/devfs_fo_read/kern_preadv/sys_extpread/syscall2); run3.out + run.fixed.log (patched kernel: device_detach -> 16 = EBUSY, storm total_io=86981, no trap); fix.diff (v2, git-apply-checked against sys/); env.txt

PoC changes

The finding's sketch had no harness; built from scratch: df4797_storm.c (16 threads hold /dev/ad0 open, raw 512B preads, reopen on ENXIO to survive cycles) and df4797_detach.c KLD (devclass_find("ad") unit 0, device_detach in a kernel thread, cycles + re-attach via device_probe_and_attach). KLD needed device_if.h/bus_if.h generated sources and for -Werror. Guest required booting the system qcow2 as a QEMU IDE disk (SeaBIOS on this host never falls through to virtio when an IDE hd exists) with fstab/loader.conf vbd0->ad0 edits and a clean shutdown so they persist.

Verified recommended fix

ad_detach: refuse EBUSY while d_opencount!=0; AD_F_DETACHING + ivars-NULL under queue_mtx with in-flight ref drain in ad_strategy/ad_done; ata_fail_requests before disk_invalidate/destroy; NULL d_slice guard in diskstrategy (see fix.diff)

Verdict

Reproduced 2/2 in situ on the audit guest booted with its system disk as a QEMU PIIX3 IDE disk (root on nata ad0): 16 unprivileged raw-pread threads on /dev/ad0 plus a KLD calling device_detach(ad0) β€” the exact PCI-hot-unplug/kldunload teardown path β€” produce 'Fatal trap 12: page fault while in kernel mode, fault virtual address = 0xc, Stopped at dscheck+0x48: movl 0xc(%r13),%ecx' with the panicking thread a storm pread in diskstrategy->dscheck, because ad_detach() (ata-disk.c:167-203) frees dp->d_slice via disk_invalidate()->dsgone() and kfrees the ad_softc (with the embedded struct disk that the still-open cdevs walk) with no serialization against the data path; the driver-level sinks (devstat_start_transaction on the freed softc at :360, ad_done's NULL-ivars devstat write at :368/:376) sit on the same unserialized path. The ad(4) twin of validated DF-4646 (nvme). Fix validated on a rebuilt kernel (#2 with fix.diff v2): detach returns EBUSY while descriptors are open (d_opencount gate + AD_F_DETACHING under queue_mtx + in-flight ref drain + ata_fail_requests before disk teardown + diskstrategy NULL-d_slice guard); baseline panic gone, storm (86981 IOs) completes, guest healthy; a first fix iteration (v1, funnel-only) still trapped in dsclose+0x30, proving the embedded-struct-disk lifetime issue and driving the v2 open-count refusal.