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)
PoC verification
Evidence pack
findings/poc/DF-4797 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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()freesdp->d_sliceand NULLs it while bios that already entereddiskstrategy()are still indscheck();disk_destroy()does not wait for in-flight strategy calls (destroy_devonly 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 betweenphysioandata_queue_request()is missed;device_set_ivars(dev, NULL)+kfree(adp)then run while such a bio still executesdevstat_start_transaction(&adp->stats)(ata-disk.c:360 β write into freed memory) and laterad_done()doesdevstat_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)
./df4797_storm 16 20β 16 threads hold/dev/ad0open and issue raw 512-byte preads in a tight loop.sysctl kern.df4797.run=1β the df4797 KLD callsdevice_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 channelqueue_mtx(fail with ENXIO when detaching);ad_done()drops it and wakes a detachingad_detach();ad_detach()setsAD_F_DETACHING+ NULLs the ivars under the same lock and waits for the drain before any teardown;ata_fail_requests()runs beforedisk_invalidate()/disk_destroy()so failed biodone()s see intact disk-layer structures;diskstrategy()guards thedscheck()call against a NULLdp->d_slice(the reproduced panic site).
Files
df4797_storm.cβ raw-I/O storm (userland)df4797_detach.c/Makefileβ detach-trigger KLDbuild.sh,run.shβ exact commandsrun.log,panic.txtβ full serial capture of the baseline panicfix.diffβ the validated fix (git-apply-able against sys/)VERDICT.md,manifest.json,verdict.json
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
pread(/dev/ad0)βdisk1_ops.d_readβphysioβdiskstrategy(sys/kern/subr_disk.c:1246) readsdp->d_sliceand callsdscheck(dev, bio, dp->d_slice).- Concurrently
ad_detach()(sys/dev/disk/nata/ata-disk.c:191) callsdisk_invalidate(&adp->disk)βdsgone()(sys/kern/subr_diskslice.c:348) whichkfree()s the slices struct and stores NULL intodp->d_slice. - The storm thread's
dscheck(dev, bio, NULL)dereferencesssp->dss_nslicesat offset 0xc of NULL β fatal trap 12, exactly the observed fault address. - Driver-level sinks on the same unserialized path:
ad_strategy'sdevstat_start_transaction(&adp->stats)(ata-disk.c:360) writes into the softc afterkfree(adp)(ata-disk.c:201);ad_done'sdevice_get_ivars(request->dev)is NULL after detach (ata-disk.c:200) anddevstat_end_transaction_bufwrites toNULL + offsetof(struct ad_softc, stats). - Nothing waits for in-flight bios:
destroy_dev()only re-vectors the cdev ops (the storm's open fds keep the old ops alive), andata_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 channelqueue_mtxafterAD_F_DETACHINGis set (open-race guard);ad_strategy()takes a per-softc in-flight reference underqueue_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 beforedisk_invalidate()/disk_destroy()so the failed requests' biodone()s see intact disk-layer structures;diskstrategy()guards thedscheck()call against a NULLd_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 walkingdp->d_sliceafter 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/ad0open (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_opencountdrops to 0 but before the close finishes walkingdp->d_sliceis 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_slicepointer whendsgone()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
fixedBaseline 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
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
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.
No comments yet.